Skip to content

Commit f5d42ce

Browse files
authored
feat(url-state): adopt nuqs for type-safe URL query-param state (#5163)
* feat(url-state): introduce nuqs for type-safe query-param state Add nuqs and migrate ad-hoc URL query-param handling to typed parsers. - Wrap the provider tree in `NuqsAdapter` (app/layout.tsx). - Co-locate typed param modules: - logs/search-params.ts — timeRange/level/workflowIds/folderIds/triggers/ search parsers (history: 'replace', clearOnDefault) preserving the exact prior wire encoding (kebab time-range tokens, comma-joined arrays). - integrations/[block]/search-params.ts — ephemeral `connect` literal param. - Replace the logs filter store's hand-rolled URL sync (initializeFromURL / syncWithURL / popstate) with a URL-backed `useLogFilters` hook over useQueryStates; the zustand store now holds only the non-URL viewMode toggle. - Migrate logs.tsx (executionId + search), logs-toolbar, dashboard, and the integration detail `connect` deep-link (read-then-strip) to nuqs. URL keys, defaults, and history semantics are unchanged. * feat(url-state): migrate deferred sites to nuqs + add url-state rule Migrate the deferred query-param sites to typed nuqs parsers, each with a co-located search-params.ts single source of truth: - settings/[section]: mcpServerId deep-link - files: folderId (history: push) + new compose flag - knowledge/[id]: addConnector read-then-strip deep-link - knowledge/[id]/[documentId]: page (int, default 1) + chunk deep-link Workflow editor intentionally left store-backed (socket-synced / high-frequency / persisted-preference view-state); documented in the rule's carve-out. Add .claude/rules/sim-url-state.md (decision framework, conventions, server cache + debounced-input patterns, editor carve-out); cross-link from CLAUDE.md and sim-queries.md. * feat(url-state): migrate remaining view-state to nuqs + harness updates Make the URL the single source of truth for shareable view-state across the remaining sweep-confirmed sites: - settings/mcp: replace initialServerId prop + effect-sync with a direct useQueryState (mcpServerId, history: push); stop prop-drilling from settings - integrations: selectedCategory + debounced search; add Suspense boundary - tables: debounced search + sort/dir + row-count/owner filters (activeTable stays route state — selecting a table navigates to tables/[tableId]); wire the existing loading.tsx as the Suspense fallback - knowledge/[id]: pagination page param - settings/recently-deleted: tab + sort/dir + debounced search - settings/admin: committed search (q) + pagination offset - settings/mothership: tab + environment - skills: editingSkill object -> skillId deep-link (derive from useSkills); add Suspense boundary - files: shareFileId deep-link added to files/search-params - landing integrations + models directories: debounced search + category/ provider filter; add Suspense boundaries Harness: add a When-to-use decision table, the sort (sort+dir) convention, the selected-entity deep-link pattern, and nuqs doc links to sim-url-state.md; add the /you-might-not-need-url-state command and wire it into /cleanup. * fix(nuqs): revert landing-page param migrations and tighten workspace URL-state - Revert integrations/models landing pages to static SEO HTML (drop nuqs migration + their search-params files) - MCP settings: refresh tools only for an initial deep-linked server id, not on subsequent user selections - Add a Suspense boundary with real chrome around the nuqs-using integration detail page - Trim inaccurate "server component reads these params" TSDoc from search-params files (createSearchParamsCache is unused) - Export mcpServerIdUrlKeys from the settings search-params file instead of inlining the options in mcp.tsx - Convert two new relative imports (logs use-log-filters, tables loading) to absolute - Wrap setSelectedCategory in useCallback; clear active skillId edit param when opening the create-skill form * fix(logs): add logs-page Suspense boundary and co-locate nuqs params - Wrap <Logs/> in <Suspense fallback={<LogsLoading/>}> so the nuqs reads (useLogFilters, executionId) have a boundary ancestor like sibling pages. - Co-locate the executionId param in logs/search-params.ts (read-only, intentionally not stripped) and consume it in logs.tsx. - Migrate log-details activeTab to a deep-linkable nuqs tab param (single LogDetails instance; preview path uses ExecutionSnapshot, not LogDetails). - Align cleanup.md description pass order with the numbered steps. - Replace mothership.tsx local Tab type with exported MothershipTab. * fix(url-state): honor deep-linked log-details tab on first mount * improvement(nuqs): adopt limitUrlUpdates debounce + add eq to array parser Replace the hand-rolled debounced-search pattern (local useState mirror + useDebounce + URL write-back effect + ref-guarded reconcile effect) with nuqs's built-in limitUrlUpdates: debounce() across logs, integrations, tables, and recently-deleted. The input is now controlled directly by the instant nuqs value; only the URL write is debounced. Query keys / expensive filters still derive a debounced value off the instant value; cheap in-memory filters read it directly. admin (commit-on-submit) intentionally left alone. Add an eq to parseAsTriggers (TriggerType[]) so clearOnDefault can detect the empty-array default and strip it from the URL, per nuqs createParser docs. Update .claude/rules/sim-url-state.md to prescribe the debounce pattern and the createParser eq requirement for array/object/Date values. * feat(nuqs): migrate table-detail sort, KB document filters, and calendar view to URL state - Table detail: sort+dir to nuqs; Filter stays in useState (recursive/nested, too large for URL) - Knowledge base: search (debounced), enabled filter, sort+dir to nuqs; tagFilterEntries stays in useState (rich rule objects) - Scheduled-tasks calendar: scope + date-only anchor (parseAsIsoDate, nullable, derive-today) to nuqs - Add Suspense boundaries to table-detail and scheduled-tasks pages - Document parseAsIsoDate / nullable-dynamic-default pattern in sim-url-state.md * fix(nuqs): resolve 7 PR review findings on URL query-param state - logs: clear the log-details `tab` param when the sidebar closes so a lingering `?tab=trace` no longer carries into the next opened log; deep-linked tabs still open on first mount. - logs dashboard: drive the in-memory workflow filtering off the same debounced search value the stats query uses (passed as a prop) so the chart and list stay consistent while typing. - knowledge document: make the URL `chunk` param the single source of truth for the open chunk (back/forward, deep links, and external navigation now drive the editor) instead of a one-time useState seed. - logs: drop the redundant `setUrlSearchQuery('')` after `resetFilters()` (resetFilters already clears `search`). - files: use a per-call `{ history: 'replace' }` override for the `shareFileId` share-modal open/close writes so toggling the modal does not pollute the back/forward stack; folder navigation keeps `push`. - tables + recently-deleted: trim search input before deriving the URL value so whitespace-only input no longer writes `?search=%20`. * fix(url-state): trim whitespace-only search in integrations filter * fix(url-state): clear log tab on all close paths + trim KB search * fix(scheduled-tasks): use local-time date parser for calendar anchor (avoid UTC day-shift) * feat(home): migrate ?resource deep-link to nuqs (URL as source of truth) Replace the banned window.history.replaceState effect on the home/Chat surface with a nuqs useQueryState('resource') binding. The URL is now the single source of truth for the selected resource. - Add co-located home/search-params.ts (resource param, history: replace) - useChat accepts a controlled activeResourceState binding; home passes the nuqs-backed tuple. The workflow editor copilot keeps internal useState so its resource selection stays out of the URL (editor carve-out) - Preserve the old effect's url.hash='' fragment strip in the binding setter (fragment-only rewrite, not a param mutation) - Drop initialResourceId SSR prop from both page entries (nuqs reads the URL on mount; no dual source) and wrap Home in Suspense for useSearchParams * docs(home): note nuqs deferred-flush ordering in resource hash-strip * docs(url-state): convert inline comments to TSDoc
1 parent 9a2e06e commit f5d42ce

57 files changed

Lines changed: 2081 additions & 774 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/commands/cleanup.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
---
2-
description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, and emcn design review
2+
description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, emcn design review, and url-state
33
argument-hint: [scope] [fix=true|false]
44
---
55

@@ -21,5 +21,6 @@ Run each of these skills in order on the specified scope, passing through the sc
2121
4. `/you-might-not-need-state $ARGUMENTS`
2222
5. `/react-query-best-practices $ARGUMENTS`
2323
6. `/emcn-design-review $ARGUMENTS`
24+
7. `/you-might-not-need-url-state $ARGUMENTS`
2425

25-
After all skills have run, output a summary of what was found and fixed (or proposed) across all six passes.
26+
After all skills have run, output a summary of what was found and fixed (or proposed) across all seven passes.
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
---
2+
description: Analyze and fix URL/query-param state anti-patterns — manual useSearchParams reads, hand-built query mutations, view-state trapped in useState, and objects in the URL
3+
argument-hint: [scope] [fix=true|false]
4+
---
5+
6+
# You Might Not Need URL State
7+
8+
Arguments:
9+
- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "app/workspace/[workspaceId]/tables/", "whole codebase"
10+
- fix: whether to apply fixes (default: true). Set to false to only propose changes.
11+
12+
User arguments: $ARGUMENTS
13+
14+
## Context
15+
16+
Shareable client view-state (active tab/panel, filters, search query, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination) lives in the URL via [`nuqs`](https://nuqs.dev) — driven by a co-located `search-params.ts`, never read via `useSearchParams().get(...)` and never mutated by hand-built query strings. Remote data stays in React Query; high-frequency / large / ephemeral / socket-synced state stays in Zustand; purely local UI stays in `useState`.
17+
18+
`.claude/rules/sim-url-state.md` is the source of truth — read it first.
19+
20+
## References
21+
22+
Read these before analyzing:
23+
1. `.claude/rules/sim-url-state.md` — the decision framework, conventions, debounced-input pattern, sort convention, selected-entity deep-link pattern, and the workflow-editor carve-out
24+
2. https://nuqs.dev/docs/parsers — parsers (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`)
25+
3. https://nuqs.dev/docs/options`withDefault`, `history`, `shallow`, `clearOnDefault`
26+
4. https://nuqs.dev/docs/server-side`createSearchParamsCache` for server reads
27+
28+
## Anti-patterns to detect
29+
30+
1. **Manual param reads for state**: `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` used to *read* view-state. Replace with `useQueryState`/`useQueryStates` bound to a `search-params.ts`. (Read-once auth/invite/redirect tokens — `token`, `callbackUrl`, `redirect`, `error`, `invite_flow`, `code` — are NOT view-state; leave them on `useSearchParams`.)
31+
2. **Hand-built query mutation**: constructing a query string + `router.replace`/`router.push` to change a param on the current path. Use a nuqs setter. (A `router.push` that changes the route *path* is fine; an outbound `new URLSearchParams` building an `href`/`window.open`/download/API URL is fine.)
32+
3. **`window.history.replaceState`/`pushState`** to mutate a param.
33+
4. **URL state duplicated into a store/useState + synced with an effect** (or a `popstate` listener). The URL is the single source of truth; derive from it, don't mirror it.
34+
5. **Objects in the URL**: serializing a `TableDefinition`/`SkillDefinition`/etc. Store the id and derive the object from the loaded list (`items.find(i => i.id === id)`).
35+
6. **High-frequency / large state in the URL**: cursor, pan/zoom, un-debounced keystrokes, big JSON blobs. Debounce text search (local `useState` mirror + reconcile effect); keep canvas/presence/resize state in Zustand.
36+
7. **Shareable view-state trapped in `useState`**: a tab/filter/sort/pagination/selected-entity that should be a link but lives in local state. Migrate it to the URL.
37+
8. **Missing Suspense boundary**: a component newly calling `useQueryState`/`useQueryStates` whose page entry has no `<Suspense>` wrapper (Next.js requires it for `useSearchParams`). Add one with a real-chrome fallback.
38+
9. **`import { z }` for param validation in client code**: use nuqs parsers instead.
39+
40+
## Steps
41+
42+
1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines
43+
2. Analyze the specified scope for the anti-patterns listed above
44+
3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state
45+
4. If fix=true, apply the fixes (co-locate a `search-params.ts`, wire `useQueryState(s)`, add the Suspense boundary, delete the replaced state + sync effects). If fix=false, propose the fixes without applying.

.claude/rules/sim-queries.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ paths:
77

88
All React Query hooks live in `hooks/queries/`. All server state must go through React Query — never use `useState` + `fetch` in components for data fetching or mutations.
99

10+
For *client* view-state that belongs in a shareable link (tabs, filters, search, pagination, selected entity id), use URL query params via nuqs — see `.claude/rules/sim-url-state.md`. React Query owns remote data; nuqs owns shareable client view-state.
11+
1012
## Query Key Factory
1113

1214
Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation:

0 commit comments

Comments
 (0)