Skip to content

feat(url-state): adopt nuqs for type-safe URL query-param state#5163

Merged
waleedlatif1 merged 15 commits into
stagingfrom
feat/nuqs-query-params
Jun 22, 2026
Merged

feat(url-state): adopt nuqs for type-safe URL query-param state#5163
waleedlatif1 merged 15 commits into
stagingfrom
feat/nuqs-query-params

Conversation

@waleedlatif1

Copy link
Copy Markdown
Collaborator

Summary

Introduces nuqs as the single, type-safe way to manage shareable URL/query-param state, and migrates all genuine view-state across the app to it. The URL becomes the source of truth for filters, search, tabs, sort, pagination, and deep-linked entities — replacing ad-hoc useSearchParams().get(...) reads, hand-rolled query-string mutation, and store↔URL effect-sync.

What changed

Infrastructure

  • NuqsAdapter wired once at the root layout.
  • One co-located search-params.ts per feature = typed parser map shared as the single source of truth.

Migrated to URL state

  • Logs — full filter set; deleted ~320 lines of hand-rolled syncWithURL/initializeFromURL/popstate machinery (store slimmed to the view-mode toggle only).
  • FilesfolderId, new, shareFileId.
  • Integrationscategory + search; [block] connect deep-link.
  • Knowledge — base-list page, document page/chunk, addConnector.
  • SettingsmcpServerId (end-to-end), admin offset/q, mothership tab/env, recently-deleted tab/sort/search.
  • SkillsskillId deep-link · Tablessearch/sort/dir/rows/owner · Logs detailtab.

Idiomatic nuqs patterns

  • Debounced search uses nuqs limitUrlUpdates: debounce() (instant controlled value, debounced URL write; per-call option on grouped setters; a useDebounce value feeds React Query keys so fetches stay debounced).
  • Custom array parser defines eq so clearOnDefault strips empty values correctly.
  • clearOnDefault everywhere; history: 'replace' for filters/tabs/sort/pagination, 'push' for back-closes destinations; parseAsStringLiteral for enums; ID-only-in-URL (objects derived from loaded lists); every nuqs page under a real-chrome <Suspense>.

Deliberately not migrated (documented): statically-prerendered public landing catalogs (/models, /integrations grids) and the landing hero tabs — nuqs's useSearchParams suspends at static-prerender time and would ship empty content into the static HTML (SEO/LCP regression). Read-once auth/redirect tokens stay on useSearchParams. /blog already reads searchParams server-side (correct).

Harness

  • New .claude/rules/sim-url-state.md (decision matrix, conventions, debounce + eq patterns, doc links).
  • New /you-might-not-need-url-state command, wired into /cleanup.
  • CLAUDE.md + sim-queries.md cross-links.

Verification

tsc --noEmit clean · Biome clean · check:api-validation passed. Reviewed line-by-line by multiple independent passes; no build-breaking or data-loss regressions.

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.
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.
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.
… 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
- 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.
…arser

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.
@waleedlatif1 waleedlatif1 requested a review from a team as a code owner June 21, 2026 17:36
@vercel

vercel Bot commented Jun 21, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
docs Skipped Skipped Jun 21, 2026 7:24pm

Request Review

@cursor

cursor Bot commented Jun 21, 2026

Copy link
Copy Markdown

PR Summary

Medium Risk
Broad UX/navigation behavior change across many routes (shared links, back/forward, deep links); logs filter migration is the largest behavioral swap away from custom URL sync.

Overview
Adopts nuqs as the standard for shareable client view-state: root NuqsAdapter, per-feature co-located search-params.ts parser maps, and <Suspense> with real-chrome fallbacks on affected pages.

Migrations move filters, search, sort, pagination, tabs, and entity deep-links off useSearchParams().get, hand-built router.replace / history.replaceState, and mirrored useState/Zustand + effects. Notable areas: logs (useLogFilters replaces store URL sync; filter store keeps only dashboard/list viewMode), files (folderId, new, shareFileId), home/chat (?resource= via controlled useChat binding), integrations, knowledge, tables, skills (skillId), scheduled-tasks calendar (scope/anchor with local-date parser), and settings (MCP server, admin, mothership, recently-deleted). Debounced search uses limitUrlUpdates: debounce(); destinations use history: 'push', filters use replace + clearOnDefault.

Docs/tooling: new .claude/rules/sim-url-state.md, /you-might-not-need-url-state in /cleanup, and cross-links in CLAUDE.md / sim-queries.md. Adds nuqs to apps/sim/package.json.

Reviewed by Cursor Bugbot for commit cf4aecc. Configure here.

Comment thread apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx Outdated
@greptile-apps

greptile-apps Bot commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adopts nuqs as the single, type-safe mechanism for URL query-param state across the Sim workspace, replacing ~320 lines of hand-rolled syncWithURL/initializeFromURL/popstate machinery in the logs store and scattered window.history.replaceState / useSearchParams().get(...) calls across 15+ feature areas. Each feature gets a co-located search-params.ts file as the typed single source of truth; the NuqsAdapter is wired once at the root layout with every relevant page wrapped in <Suspense>.

  • Logs: filter store slimmed to the view-mode toggle only; all filter state moved to URL via useLogFilters; initializeFromURL / syncWithURL / popstate handler removed entirely.
  • Files, Integrations, Knowledge, Settings, Skills, Tables, Scheduled Tasks, Home Chat: query-param state migrated to typed nuqs parsers with consistent clearOnDefault, debounced search writes, and correct history: 'push' vs 'replace' semantics.
  • Static routes (/models, /integrations public grid, /blog) explicitly excluded to avoid SSG/SEO regressions.

Confidence Score: 5/5

The migration is well-scoped and self-consistent — every consumer sits under the required Suspense boundary, all parsers are colocated with their feature, and the logs store deletion leaves no dangling references.

The change touches 57 files but is structurally uniform; each file follows the same typed-parser pattern. The only issues found are two read-side filter calls in recently-deleted.tsx that don't trim the URL value before passing it to includes(), visible only through manually crafted URLs, not normal UI interaction.

apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx — two occurrences where the in-memory filter uses the un-trimmed URL search value

Important Files Changed

Filename Overview
apps/sim/app/layout.tsx NuqsAdapter wired at the outermost provider layer, wrapping the full provider tree correctly.
apps/sim/stores/logs/filters/store.ts Reduced from ~300 lines of URL-sync machinery to a 17-line Zustand slice holding only the view-mode toggle; clean removal.
apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts New hook replacing the log filter Zustand store with URL-backed nuqs state; clean debounce on search, functional-updater form for array toggles, and stable memoized return shape.
apps/sim/app/workspace/[workspaceId]/logs/logs.tsx Migrated to useLogFilters/useQueryState; removed initializeFromURL, popstate handler, and manual search state; tab-param cleanup on sidebar close is correct.
apps/sim/app/workspace/[workspaceId]/logs/components/log-details/log-details.tsx Tab state migrated to URL; initial mount guards deep-linked tab on first render, resets to overview on subsequent log switches; scroll reset still fires unconditionally.
apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx Tab/sort/search migrated to URL; in-memory filter uses urlSearchTerm.toLowerCase() without .trim() in two places — diverges from the trimmed value the setter writes.
apps/sim/app/workspace/[workspaceId]/scheduled-tasks/hooks/use-calendar.ts Scope/anchor calendar state moved to URL; local-time date parser avoids UTC day-shift; timezone-change effect correctly handles the null-anchor-means-today invariant.
apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx Search/sort/filter/pagination and addConnector deep-link migrated to URL; debounced search write correct; setEnabledFilter wrapper consolidates page reset.
apps/sim/app/workspace/[workspaceId]/home/home.tsx Active resource migrated from window.history.replaceState to nuqs; fragment-stripping race correctly handled by synchronous replaceState before the deferred nuqs write.
apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx Sort moved to URL while filter stays in local state (correctly excluded due to recursive object complexity); derived sortQuery memo is clean.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[URL / Browser History] -->|nuqs parses on mount| B[useQueryState / useQueryStates]
    B -->|instant React state| C[Component renders]
    C -->|user interaction| D{Write type?}
    D -->|search input| E[limitUrlUpdates: debounce 300ms]
    D -->|filter / tab / sort| F[history: replace, clearOnDefault]
    D -->|destination nav - folder / skill / MCP| G[history: push]
    E -->|deferred URL write| A
    F -->|immediate URL write| A
    G -->|new history entry| A
    B -->|derived value| H[Derived state / memo]
    H --> C
    subgraph Logs store slim
        I[useFilterStore] -->|viewMode only| J[logs / dashboard toggle]
    end
    B -->|filter state replaces store| I
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
flowchart TD
    A[URL / Browser History] -->|nuqs parses on mount| B[useQueryState / useQueryStates]
    B -->|instant React state| C[Component renders]
    C -->|user interaction| D{Write type?}
    D -->|search input| E[limitUrlUpdates: debounce 300ms]
    D -->|filter / tab / sort| F[history: replace, clearOnDefault]
    D -->|destination nav - folder / skill / MCP| G[history: push]
    E -->|deferred URL write| A
    F -->|immediate URL write| A
    G -->|new history entry| A
    B -->|derived value| H[Derived state / memo]
    H --> C
    subgraph Logs store slim
        I[useFilterStore] -->|viewMode only| J[logs / dashboard toggle]
    end
    B -->|filter state replaces store| I
Loading

Reviews (5): Last reviewed commit: "docs(url-state): convert inline comments..." | Re-trigger Greptile

Comment thread apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
Comment thread apps/sim/app/workspace/[workspaceId]/files/search-params.ts
Comment thread apps/sim/app/workspace/[workspaceId]/tables/tables.tsx
…dar 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
- 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`.
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

Comment thread apps/sim/app/workspace/[workspaceId]/logs/logs.tsx
Comment thread apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit 5159136. Configure here.

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
@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@greptile review

@waleedlatif1

Copy link
Copy Markdown
Collaborator Author

@cursor review

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Bugbot reviewed your changes and found no new issues!

Comment @cursor review or bugbot run to trigger another review on this PR

Reviewed by Cursor Bugbot for commit cf4aecc. Configure here.

@waleedlatif1 waleedlatif1 merged commit f5d42ce into staging Jun 22, 2026
16 checks passed
@waleedlatif1 waleedlatif1 deleted the feat/nuqs-query-params branch June 22, 2026 03:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant