You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(composables): useSearchParams mutators take { replace } (#1825)
Every mutator committed through pushState, so the composable could express
"remove this param from the URL" but not "remove this param so it's gone"
— which is the reason the API gets reached for. Consuming a one-shot
value (an OAuth callback result, ?checkout=success, a flash token) leaves
the pre-delete URL, still carrying the param, as the previous history
entry: Back replays the callback and re-runs whatever consuming it
triggered.
set, delete and setAll now take an optional { replace?: boolean } that
switches the commit to replaceState. Default is unchanged, so nothing
that works today changes. Spelled to match navigate(), which already
takes { replace } — before this the two halves of the routing API
disagreed on whether replacing was expressible at all.
All three surfaces move together, because they have drifted before:
the client runtime (signals.ts), the module export
(composables/use-router.ts, including its own non-delegating
implementation), and the ambient declaration (stx.d.ts).
The guard is `!!(options && typeof options === 'object' && options.replace)`
in both implementations, deliberately, and NOT a plain truthy read of the
property. A truthy read asks whether the value HAS a replace property, and
every string carries String.prototype.replace — delete(key, 'push') would
have replaced, the exact opposite of what it reads as. Optional chaining
alone was worse: it diverged BETWEEN the two implementations on the empty
string, which short-circuits in one and reaches String.prototype.replace
in the other. Same call, two entry points, opposite history semantics.
Verified in Chrome against the rebuilt dist, arriving at /login?code=abc123
and consuming the param:
default Back -> ?code=abc123 (replayed)
{ replace: true } Back -> (none) (not replayed)
'push' Back -> ?code=abc123 (pushes, as it reads)
true Back -> ?code=abc123 (ignored; the contract is an
options object)
Tests extend the existing parity suite, which already runs every case
against all three implementations: the fake history gained replaceState
so a test can tell "the URL changed" from "the URL changed AND the old one
is still reachable with Back", plus a truth table over the odd argument
shapes above. 67 pass; the replace-specific ones fail without this change.
Docs updated where they now teach the wrong thing: the agent guide's
canonical RIGHT example was the exact #1825 scenario and prescribed
setAll({ project: undefined }), which pushes AND writes the string
"undefined" rather than deleting; the PROHIBITED_DOM_PATTERNS row mapping
history.replaceState to useSearchParams silently converted a replace into
a push; API.md, api/router.md and api/composables-ref.md documented only
get/set/setAll with "(pushes history)" and no opt-out, and referred to a
type name, SearchParamsHandle, that exists nowhere in the code.
Full suite 10754 pass, 1 fail — pre-existing (crosswind-arbitrary-values),
confirmed identical before the change. A downstream app is green.
params.setAll({ project: undefined }) // rewrites the URL, then syncFromUrl()
4208
+
// { replace: true } is load-bearing, not tidiness. This is a ONE-SHOT param:
4209
+
// it has been consumed into the cookie, so it must not survive a Back press.
4210
+
// The default is pushState, which leaves the URL still carrying ?project= as
4211
+
// the previous history entry — Back would replay the consumption (#1825).
4212
+
params.delete('project', { replace: true })
4209
4213
}
4210
4214
```
4211
4215
4216
+
The mirror of the WRONG snippet above is exact: that code reached for
4217
+
`history.replaceState` precisely because replace is the correct semantic here,
4218
+
and `{ replace: true }` is how the composable expresses it.
4219
+
4212
4220
**External URLs are not an exception.** `resources/views/pricing.stx:26` does `location.assign(data.url)` to reach Stripe. Write `navigate(data.url, true)` — same document load, one declared API, one fewer strict violation.
4213
4221
4214
4222
**CHECK:** `grep -rnE 'window\.location|window\.history|location\.(href\s*=|assign|replace)' resources/ --include='*.stx'` → must match only the sites declared under rule 8.11. Today: 29 `window.location`, 11 `location.replace`, 7 `location.assign`, 1 `location.href=`, 1 `window.history`.
### 9.7 MUST — programmatic navigation is `navigate()`, never `location.*` or `history.*`
4621
4629
4622
-
**RULE.** In any `<script>` block, use the auto-imported `navigate(url)`. Use `navigate(url, true)` when you genuinely need a document load. Use `goBack()` / `goForward()` for history. Use `useSearchParams().set()` to rewrite the query string. Never write `window.location`, `location.href =`, `location.assign()`, `location.replace()` or `history.replaceState()`.
4630
+
**RULE.** In any `<script>` block, use the auto-imported `navigate(url)`. Use `navigate(url, true)` when you genuinely need a document load. Use `goBack()` / `goForward()` for history. Use `useSearchParams().set()` to rewrite the query string, and pass `{ replace: true }` when the URL change must not become a Back destination — consuming a one-shot param such as an OAuth `?code=` needs it, or Back replays the callback (#1825). Never write `window.location`, `location.href =`, `location.assign()`, `location.replace()` or `history.replaceState()`.
4623
4631
4624
4632
**WHY.** `navigate` is defined at `signals.js:940-951` and published as a global at `signals.js:4635` (also `window.stx.navigate`, `signals.js:4148`). It delegates to `window.stxRouter.navigate` when the router is live (`signals.js:946-947`) and falls back to `location.href` only when it is not — strictly safer than the hand-written form. `goBack`/`goForward`: `signals.js:953-954`. `useSearchParams()` owns history rewriting: `signals.js:1004-1024`.
0 commit comments