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
improvement(url-state): use nuqs setters and derive state instead of mirroring it (#6486)
* improvement(url-state): use nuqs setters and derive state instead of mirroring it
Wave 1 of a URL-state audit sweep.
- files: replace the last hand-built same-path query mutation with the nuqs
group setter, which no longer drops shareFileId/search/type/size/uploaded-by/sort/dir
- suspense: give six page entries their co-located loading.tsx skeleton
instead of fallback={null}
- invite: derive isNewUser/urlError/token during render so the invitation
query key is correct on first commit
- resume: derive selectedStatus/queuePosition from the query cache the
mutation already writes
- verify, logs, terminal: delete dead and duplicate state
- rules: document same-path router.replace as a query mutation, and the
loading.tsx-as-Suspense-fallback convention
* fix(invite): wait for the stored token before enabling the invitation query
An authenticated user opening an invite without a token in the URL fired the
query with a null token before the effect restored the session-stored one,
producing a transient forbidden state and a redundant request under a second
cache key. Distinguish 'storage not yet read' (undefined) from 'read and empty'
(null) and gate the query on that.
Copy file name to clipboardExpand all lines: .claude/rules/sim-url-state.md
+18-3Lines changed: 18 additions & 3 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -34,7 +34,7 @@ Put state in the URL **only** when it is *all* of: shareable, deep-linkable, boo
34
34
## Anti-patterns (forbidden)
35
35
36
36
- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
37
-
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.
37
+
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.**If the target path equals the current path, it is a query mutation, not a navigation** — even when written as a full path template. Re-serializing the path by hand is lossy by construction: it drops every param the template forgets. Use the nuqs setter (`setParams({ key: null }, { history: 'replace', scroll: false })`) — `null` always removes the key, and only the params you name are touched. Both options are already nuqs defaults (see "Conventions"); write them explicitly because a group whose shared options set `history: 'push'` (e.g. `filesUrlKeys`) would otherwise push a back-stack entry for a strip.
38
38
-`window.history.replaceState`/`pushState` to mutate a param.
39
39
- Duplicating URL state into a store and syncing it with effects / `popstate` listeners.
40
40
- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs).
@@ -44,7 +44,7 @@ These reads/mutations are **not** anti-patterns and stay as-is:
44
44
45
45
-**Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination.
46
46
-**Route navigations** — `router.push('/path/[id]?folderId=x')` that changes the route *path*, not just the current query. A nuqs setter only mutates the query on the current path; cross-path navigation stays on `router`.
47
-
-**Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`.
47
+
-**Read-once auth / redirect signals** — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `new` (invite signup flow), `upgraded`, `redirect_workflow`, etc. These are navigation signals consumed once (often read-then-strip), not synced view-state. Leave them on `useSearchParams`. Key names are per-surface: files' `new` is a genuine nuqs param (`files/search-params.ts`), while invite's `new` is a one-shot signup signal.
48
48
49
49
## Per-feature `search-params.ts` — single source of truth
50
50
@@ -128,7 +128,22 @@ If a client param must be re-read server-side after a change, set `shallow: fals
128
128
129
129
## Suspense boundary
130
130
131
-
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame — see `apps/sim/app/workspace/[workspaceId]/files/page.tsx`.
131
+
`useQueryState`/`useQueryStates` read `useSearchParams` internally, so any client component using them must sit under a `<Suspense>` boundary (Next.js requirement). Wrap the page entry with a real-chrome fallback so a suspend never flashes a blank frame.
132
+
133
+
**Never `fallback={null}` on a page entry.** The route's co-located `loading.tsx` default export *is* the correct fallback — one skeleton serves both the route-level navigation transition (which Next renders automatically) and the in-page suspend (which this boundary renders). If the segment has no `loading.tsx`, add one; the route transition needs it anyway. Import it absolutely (`sim-imports.md`):
This applies to **page entries**. An inner `<Suspense>` wrapping a `lazy()` component is the exception: there `fallback={null}` is correct, precisely so the suspend resolves at the nearest boundary instead of flashing the whole route — see `sim-imports.md`, "Code-splitting through barrels".
0 commit comments