Skip to content

Commit df0df7e

Browse files
committed
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.
1 parent fcfe99f commit df0df7e

30 files changed

Lines changed: 731 additions & 88 deletions

File tree

.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, url-state, React Query, and emcn design review
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-url-state.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,15 @@ Pick exactly one home for each piece of state:
2020

2121
Put state in the URL **only** when it is *all* of: shareable, deep-linkable, bookmarkable, survives reload + back/forward — **and** is discrete, low-frequency, and small. If it fails any of those, it does not go in the URL.
2222

23+
### When to use what (decision table)
24+
25+
| Home | Trigger | Example |
26+
| --- | --- | --- |
27+
| **URL (nuqs)** | Client view-state worth a link: tab, filter, search, sort, pagination, selected-entity id, an open "view" modal/drawer that is a destination | `?tab=licenses`, `?category=Communication`, `?page=3`, `?skillId=abc` |
28+
| **React Query** | Server/remote data fetched from an endpoint | `useMcpServers(workspaceId)`, `useSkills(workspaceId)` |
29+
| **Zustand** | Cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, socket-synced | canvas pan/zoom, live cursor, drag state, resize widths, unsaved buffers |
30+
| **`useState`** | Purely local single-component UI; also the snappy mirror of a debounced URL search | a hover flag, a transient dialog target, the live text of a debounced search box |
31+
2332
## Anti-patterns (forbidden)
2433

2534
- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
@@ -119,6 +128,38 @@ If a client param must be re-read server-side after a change, set `shallow: fals
119128

120129
Keep local `useState` for snappy typing; push to the URL debounced, and reconcile from the URL with a ref-guarded effect so external URL changes (back/forward, deep link) flow back into the input without clobbering in-flight keystrokes. This is the established logs pattern — follow it rather than writing every keystroke to the URL.
121130

131+
## Sort convention (`sort` + `dir`)
132+
133+
Sortable lists use **two scalar params**, never a serialized `{column,direction}` object:
134+
135+
```typescript
136+
const SORT_COLUMNS = ['name', 'created', 'updated'] as const
137+
const SORT_DIRECTIONS = ['asc', 'desc'] as const
138+
139+
export const thingsParsers = {
140+
sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'),
141+
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'),
142+
} as const
143+
```
144+
145+
Both carry the shared filter options (`{ history: 'replace', clearOnDefault: true }`). The defaults must match the list's existing default sort exactly. If a UI exposes "no active sort" as `null`, derive that in the component (`sort === DEFAULT && dir === DEFAULT ? null : { column, direction }`) — the URL still holds the resolved values. "Clear sort" writes the defaults back (which `clearOnDefault` strips from the URL); never write `null`/garbage columns.
146+
147+
## Selected-entity deep-link (store the id, derive the object)
148+
149+
To deep-link a row/modal/drawer to one entity, store **only its id** and look the object up in the already-loaded list — never serialize the object into the URL:
150+
151+
```typescript
152+
const [skillId, setSkillId] = useQueryState(skillIdParam.key, {
153+
...skillIdParam.parser,
154+
history: 'push', // opening an entity is a destination; "back" closes it
155+
clearOnDefault: true,
156+
})
157+
// Derive — do not duplicate into useState or sync with an effect:
158+
const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null
159+
```
160+
161+
Open the panel/modal when the id resolves to a loaded entity; closing it calls `setSkillId(null)`. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see below). A separate "create new" flow has no id and stays in local `useState`.
162+
122163
## Read-then-strip deep links
123164

124165
For an ephemeral deep-link that pre-opens a modal/drawer and should not linger in the URL (e.g. integrations `?connect=oauth`, knowledge `?addConnector=`), read the param, act on it once behind a `useRef` guard, then clear it: `setParam(null, { history: 'replace', scroll: false })`. See `apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx`.
@@ -138,3 +179,9 @@ Borderline candidates that *look* shareable but currently stay in Zustand becaus
138179
- **`focusedBlockId`** ("look at this block") — the only genuinely shareable candidate, but it is entangled with the persisted editor store and panel-open orchestration. Adding it is a *new feature*, not a migration; ship it deliberately (with runtime verification against a live socket), not as part of a sweep.
139180

140181
Rule of thumb for the editor: if state is socket-coupled, high-frequency, viewport-related, or a persisted resize/preference, it stays in Zustand. When in doubt, leave it and flag it — do not force fragile URL state into the canvas.
182+
183+
## Docs
184+
185+
- Adapters (App Router `NuqsAdapter`): https://nuqs.dev/docs/adapters
186+
- Parsers & options (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`, `withDefault`, `history`, `shallow`, `clearOnDefault`): https://nuqs.dev/docs/parsers and https://nuqs.dev/docs/options
187+
- Server-side reads (`createSearchParamsCache`): https://nuqs.dev/docs/server-side

apps/sim/app/(landing)/integrations/(shell)/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { Badge } from '@/components/emcn'
34
import { SITE_URL } from '@/lib/core/utils/urls'
@@ -205,7 +206,9 @@ export default function IntegrationsPage() {
205206
All Integrations
206207
</h2>
207208
</div>
208-
<IntegrationGrid integrations={allIntegrations} />
209+
<Suspense fallback={null}>
210+
<IntegrationGrid integrations={allIntegrations} />
211+
</Suspense>
209212
</section>
210213

211214
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />

apps/sim/app/(landing)/integrations/components/integration-grid.tsx

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
'use client'
22

3-
import { useState } from 'react'
3+
import { useEffect, useRef, useState } from 'react'
4+
import { useQueryStates } from 'nuqs'
45
import { ChipInput, Search } from '@/components/emcn'
56
import { blockTypeToIconMap, formatIntegrationType, type Integration } from '@/lib/integrations'
67
import { IntegrationRow } from '@/app/(landing)/integrations/components/integration-card'
8+
import {
9+
integrationsDirectoryParsers,
10+
integrationsDirectoryUrlKeys,
11+
} from '@/app/(landing)/integrations/search-params'
12+
import { useDebounce } from '@/hooks/use-debounce'
713

814
const PILL_BASE =
915
'rounded-[5px] border border-[var(--landing-border-strong)] px-[9px] py-0.5 text-[13.5px] text-[var(--landing-text)] transition-colors' as const
@@ -15,8 +21,29 @@ interface IntegrationGridProps {
1521
}
1622

1723
export function IntegrationGrid({ integrations }: IntegrationGridProps) {
18-
const [query, setQuery] = useState('')
19-
const [activeCategory, setActiveCategory] = useState<string | null>(null)
24+
const [{ search: urlQuery, category: urlCategory }, setDirectoryFilters] = useQueryStates(
25+
integrationsDirectoryParsers,
26+
integrationsDirectoryUrlKeys
27+
)
28+
29+
const [query, setQuery] = useState(urlQuery)
30+
const debouncedQuery = useDebounce(query, 300)
31+
32+
useEffect(() => {
33+
setDirectoryFilters({ search: debouncedQuery.length > 0 ? debouncedQuery : null })
34+
}, [debouncedQuery, setDirectoryFilters])
35+
36+
const lastSyncedUrlSearchRef = useRef(urlQuery)
37+
useEffect(() => {
38+
if (urlQuery === lastSyncedUrlSearchRef.current) return
39+
lastSyncedUrlSearchRef.current = urlQuery
40+
setQuery((current) => (current === urlQuery ? current : urlQuery))
41+
}, [urlQuery])
42+
43+
const activeCategory = urlCategory.length > 0 ? urlCategory : null
44+
const setActiveCategory = (category: string | null) => {
45+
setDirectoryFilters({ category })
46+
}
2047

2148
const counts = new Map<string, number>()
2249
for (const i of integrations) {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { parseAsString } from 'nuqs/server'
2+
3+
/**
4+
* Co-located, typed URL query-param definitions for the public integrations
5+
* directory. Both the client (`IntegrationGrid`) and any server component that
6+
* reads these params consume this single source of truth.
7+
*
8+
* - `search` is the directory search term, written debounced from the local
9+
* input (logs pattern) — never on every keystroke.
10+
* - `category` is the active integration-type filter. Categories are derived
11+
* from the data set, so a plain string is used; the empty default (no filter)
12+
* clears from the URL.
13+
*/
14+
export const integrationsDirectoryParsers = {
15+
search: parseAsString.withDefault(''),
16+
category: parseAsString.withDefault(''),
17+
} as const
18+
19+
/** Filter/search view-state: clean URLs, no back-stack churn. */
20+
export const integrationsDirectoryUrlKeys = {
21+
history: 'replace',
22+
clearOnDefault: true,
23+
} as const

apps/sim/app/(landing)/models/(shell)/page.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Suspense } from 'react'
12
import type { Metadata } from 'next'
23
import { Badge } from '@/components/emcn'
34
import { SITE_URL } from '@/lib/core/utils/urls'
@@ -225,7 +226,9 @@ export default function ModelsPage() {
225226
All models
226227
</h2>
227228
</div>
228-
<ModelDirectory />
229+
<Suspense fallback={null}>
230+
<ModelDirectory />
231+
</Suspense>
229232
</section>
230233

231234
<div className='h-px w-full bg-[var(--landing-bg-elevated)]' />

apps/sim/app/(landing)/models/components/model-directory.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
'use client'
22

3-
import { useMemo, useState } from 'react'
3+
import { useEffect, useMemo, useRef, useState } from 'react'
44
import Link from 'next/link'
5+
import { useQueryStates } from 'nuqs'
56
import { Input } from '@/components/emcn'
67
import { ChevronArrow, ProviderIcon } from '@/app/(landing)/models/components/model-primitives'
8+
import { modelDirectoryParsers, modelDirectoryUrlKeys } from '@/app/(landing)/models/search-params'
79
import {
810
type CatalogModel,
911
type CatalogProvider,
@@ -12,10 +14,32 @@ import {
1214
MODEL_PROVIDERS_WITH_CATALOGS,
1315
MODEL_PROVIDERS_WITH_DYNAMIC_CATALOGS,
1416
} from '@/app/(landing)/models/utils'
17+
import { useDebounce } from '@/hooks/use-debounce'
1518

1619
export function ModelDirectory() {
17-
const [query, setQuery] = useState('')
18-
const [activeProviderId, setActiveProviderId] = useState<string | null>(null)
20+
const [{ search: urlQuery, provider: urlProvider }, setModelFilters] = useQueryStates(
21+
modelDirectoryParsers,
22+
modelDirectoryUrlKeys
23+
)
24+
25+
const [query, setQuery] = useState(urlQuery)
26+
const debouncedQuery = useDebounce(query, 300)
27+
28+
useEffect(() => {
29+
setModelFilters({ search: debouncedQuery.length > 0 ? debouncedQuery : null })
30+
}, [debouncedQuery, setModelFilters])
31+
32+
const lastSyncedUrlSearchRef = useRef(urlQuery)
33+
useEffect(() => {
34+
if (urlQuery === lastSyncedUrlSearchRef.current) return
35+
lastSyncedUrlSearchRef.current = urlQuery
36+
setQuery((current) => (current === urlQuery ? current : urlQuery))
37+
}, [urlQuery])
38+
39+
const activeProviderId = urlProvider.length > 0 ? urlProvider : null
40+
const setActiveProviderId = (providerId: string | null) => {
41+
setModelFilters({ provider: providerId })
42+
}
1943

2044
const providerOptions = useMemo(
2145
() =>
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { parseAsString } from 'nuqs/server'
2+
3+
/**
4+
* Co-located, typed URL query-param definitions for the public model directory.
5+
* Both the client (`ModelDirectory`) and any server component that reads these
6+
* params consume this single source of truth.
7+
*
8+
* - `search` is the directory search term, written debounced from the local
9+
* input (logs pattern) — never on every keystroke.
10+
* - `provider` is the active provider filter. Provider ids are derived from the
11+
* data set, so a plain string is used; the empty default (no filter) clears
12+
* from the URL.
13+
*/
14+
export const modelDirectoryParsers = {
15+
search: parseAsString.withDefault(''),
16+
provider: parseAsString.withDefault(''),
17+
} as const
18+
19+
/** Filter/search view-state: clean URLs, no back-stack churn. */
20+
export const modelDirectoryUrlKeys = {
21+
history: 'replace',
22+
clearOnDefault: true,
23+
} as const

apps/sim/app/workspace/[workspaceId]/files/files.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -173,10 +173,8 @@ export function Files() {
173173

174174
const params = useParams()
175175
const router = useRouter()
176-
const [{ folderId: currentFolderId, new: isNewFile }, setFilesParams] = useQueryStates(
177-
filesParsers,
178-
filesUrlKeys
179-
)
176+
const [{ folderId: currentFolderId, new: isNewFile, shareFileId }, setFilesParams] =
177+
useQueryStates(filesParsers, filesUrlKeys)
180178
const workspaceId = params?.workspaceId as string
181179

182180
const posthog = usePostHog()
@@ -274,7 +272,6 @@ export function Files() {
274272
folderIds: string[]
275273
name: string
276274
} | null>(null)
277-
const [shareFileId, setShareFileId] = useState<string | null>(null)
278275

279276
const listRename = useInlineRename({
280277
onSave: (rowId, name) => {
@@ -306,7 +303,7 @@ export function Files() {
306303
const shareModal = shareFile ? (
307304
<ShareModal
308305
open
309-
onOpenChange={(open) => !open && setShareFileId(null)}
306+
onOpenChange={(open) => !open && setFilesParams({ shareFileId: null })}
310307
workspaceId={workspaceId}
311308
fileId={shareFile.id}
312309
fileName={shareFile.name}
@@ -998,8 +995,8 @@ export function Files() {
998995

999996
const handleShareSelected = useCallback(() => {
1000997
const file = selectedFileRef.current
1001-
if (file) setShareFileId(file.id)
1002-
}, [])
998+
if (file) setFilesParams({ shareFileId: file.id })
999+
}, [setFilesParams])
10031000

10041001
const handleBulkDelete = useCallback(() => {
10051002
if (selectedFileIds.length === 0 && selectedFolderIds.length === 0) return
@@ -1247,9 +1244,9 @@ export function Files() {
12471244

12481245
const handleContextMenuShare = useCallback(() => {
12491246
const item = contextMenuItemRef.current
1250-
if (item?.kind === 'file') setShareFileId(item.file.id)
1247+
if (item?.kind === 'file') setFilesParams({ shareFileId: item.file.id })
12511248
closeContextMenu()
1252-
}, [closeContextMenu])
1249+
}, [closeContextMenu, setFilesParams])
12531250

12541251
const handleContextMenuDelete = useCallback(() => {
12551252
const item = contextMenuItemRef.current

0 commit comments

Comments
 (0)