Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
fef3c03
feat(url-state): introduce nuqs for type-safe query-param state
waleedlatif1 Jun 21, 2026
fcfe99f
feat(url-state): migrate deferred sites to nuqs + add url-state rule
waleedlatif1 Jun 21, 2026
df0df7e
feat(url-state): migrate remaining view-state to nuqs + harness updates
waleedlatif1 Jun 21, 2026
0bbb633
fix(nuqs): revert landing-page param migrations and tighten workspace…
waleedlatif1 Jun 21, 2026
dfe3619
fix(logs): add logs-page Suspense boundary and co-locate nuqs params
waleedlatif1 Jun 21, 2026
47e2f7a
fix(url-state): honor deep-linked log-details tab on first mount
waleedlatif1 Jun 21, 2026
a3ca5b2
improvement(nuqs): adopt limitUrlUpdates debounce + add eq to array p…
waleedlatif1 Jun 21, 2026
016b282
feat(nuqs): migrate table-detail sort, KB document filters, and calen…
waleedlatif1 Jun 21, 2026
308b247
fix(nuqs): resolve 7 PR review findings on URL query-param state
waleedlatif1 Jun 21, 2026
65082c1
fix(url-state): trim whitespace-only search in integrations filter
waleedlatif1 Jun 21, 2026
5159136
fix(url-state): clear log tab on all close paths + trim KB search
waleedlatif1 Jun 21, 2026
ebcad6b
fix(scheduled-tasks): use local-time date parser for calendar anchor …
waleedlatif1 Jun 21, 2026
88720d7
feat(home): migrate ?resource deep-link to nuqs (URL as source of truth)
waleedlatif1 Jun 21, 2026
5ef4f6b
docs(home): note nuqs deferred-flush ordering in resource hash-strip
waleedlatif1 Jun 21, 2026
cf4aecc
docs(url-state): convert inline comments to TSDoc
waleedlatif1 Jun 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions .claude/commands/cleanup.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
---
description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, and emcn design review
description: Run all code quality skills in sequence — effects, memo, callbacks, state, React Query, emcn design review, and url-state
argument-hint: [scope] [fix=true|false]
---

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

After all skills have run, output a summary of what was found and fixed (or proposed) across all six passes.
After all skills have run, output a summary of what was found and fixed (or proposed) across all seven passes.
45 changes: 45 additions & 0 deletions .claude/commands/you-might-not-need-url-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
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
argument-hint: [scope] [fix=true|false]
---

# You Might Not Need URL State

Arguments:
- scope: what to analyze (default: your current changes). Examples: "diff to main", "PR #123", "app/workspace/[workspaceId]/tables/", "whole codebase"
- fix: whether to apply fixes (default: true). Set to false to only propose changes.

User arguments: $ARGUMENTS

## Context

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`.

`.claude/rules/sim-url-state.md` is the source of truth — read it first.

## References

Read these before analyzing:
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
2. https://nuqs.dev/docs/parsers — parsers (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`)
3. https://nuqs.dev/docs/options — `withDefault`, `history`, `shallow`, `clearOnDefault`
4. https://nuqs.dev/docs/server-side — `createSearchParamsCache` for server reads

## Anti-patterns to detect

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`.)
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.)
3. **`window.history.replaceState`/`pushState`** to mutate a param.
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.
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)`).
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.
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.
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.
9. **`import { z }` for param validation in client code**: use nuqs parsers instead.

## Steps

1. Read `.claude/rules/sim-url-state.md` and the nuqs docs above to understand the guidelines
2. Analyze the specified scope for the anti-patterns listed above
3. For each finding, decide the correct home using the decision table — do not force URL state onto ephemeral/high-frequency/socket-synced state
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.
2 changes: 2 additions & 0 deletions .claude/rules/sim-queries.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ paths:

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.

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.

## Query Key Factory

Every query file defines a hierarchical keys factory with an `all` root key and intermediate plural keys for prefix-level invalidation:
Expand Down
211 changes: 211 additions & 0 deletions .claude/rules/sim-url-state.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
---
paths:
- "apps/sim/app/**/*.tsx"
- "apps/sim/app/**/*.ts"
- "apps/sim/app/**/search-params.ts"
---

# URL / Query-Param State (nuqs)

URL query state is managed with [`nuqs`](https://nuqs.dev). The `NuqsAdapter` is wired once in `apps/sim/app/layout.tsx` — do not add another. This rule is the source of truth for *what* belongs in the URL and *how* to wire it.

## Decision framework — where does this state live?

Pick exactly one home for each piece of state:

- **React Query** → server/remote data. Unchanged; see `.claude/rules/sim-queries.md`.
- **URL params (nuqs)** → client view-state worth putting in a link: active tab/panel, selected entity id, filters, search query, pagination, view mode (list/grid), an open "view" drawer/modal that represents a destination.
- **Zustand** → cross-component client state that must NOT be in the URL: high-frequency, large, ephemeral, or socket-synced (canvas pan/zoom, cursor, drag state, resize widths, unsaved buffers, live collaborative selection).
- **`useState`** → purely local, single-component UI.

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.

### When to use what (decision table)

| Home | Trigger | Example |
| --- | --- | --- |
| **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` |
| **React Query** | Server/remote data fetched from an endpoint | `useMcpServers(workspaceId)`, `useSkills(workspaceId)` |
| **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 |
| **`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 |

## Anti-patterns (forbidden)

- Direct `useSearchParams().get(...)` or `new URLSearchParams(window.location.search)` to **read** state.
- Hand-built query strings + `router.replace`/`router.push` to **mutate** state.
- `window.history.replaceState`/`pushState` to mutate a param.
- Duplicating URL state into a store and syncing it with effects / `popstate` listeners.
- High-frequency or large state in the URL (cursor, pan/zoom, un-debounced keystrokes, big JSON blobs).
- `import { z } from 'zod'` in client code for param validation — use nuqs parsers (`parseAsString`, `parseAsInteger`, `parseAsBoolean`, `parseAsStringLiteral`, `parseAsArrayOf`) or a custom `createParser`.

These reads/mutations are **not** anti-patterns and stay as-is:

- **Outbound URL builders** — `new URLSearchParams({...})` to construct a `href`, a download endpoint, an external WebSocket/API URL, or a `window.open(_, '_blank')` destination.
- **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`.
- **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`.

## Per-feature `search-params.ts` — single source of truth

Co-locate a `search-params.ts` next to the feature. Export the parser map (and shared options). Both the client (`useQueryStates`/`useQueryState`) and any server component (`createSearchParamsCache` from `nuqs/server`) import from this one file. Import parsers from `nuqs/server` so the module is safe to import in both client and server contexts.

Conventions:

- `.withDefault(...)` on every parser so reads are non-null.
- Filter / search / toggle / pagination options: `{ history: 'replace', shallow: true, clearOnDefault: true }` — clean URLs, no back-stack churn.
- Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`.
- `shallow: false` **only** when a Server Component / loader must re-read the param.
- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one.
- For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use [`createParser`](https://nuqs.dev/docs/parsers).
- A `createParser` for a value **not** comparable with `===` (arrays, objects, `Date`) **must** define an `eq` — `clearOnDefault` uses it to detect the default, so without it an empty-array/object default never strips from the URL. Built-in `parseAsArrayOf(...)` already ships its own `eq`; only string/number/boolean custom parsers can omit it. Example (array): `eq: (a, b) => a.length === b.length && a.every((v, i) => v === b[i])`.

### Example — grouped filters (single source of truth)

```typescript
// apps/sim/app/workspace/[workspaceId]/things/search-params.ts
import { parseAsArrayOf, parseAsString, parseAsStringLiteral } from 'nuqs/server'

const VIEW_MODES = ['list', 'grid'] as const

export const thingsParsers = {
search: parseAsString.withDefault(''),
tags: parseAsArrayOf(parseAsString).withDefault([]),
view: parseAsStringLiteral(VIEW_MODES).withDefault('list'),
} as const

/** Clean URLs, no back-stack churn for filter changes. */
export const thingsUrlKeys = {
history: 'replace',
shallow: true,
clearOnDefault: true,
} as const
```

### Client — `useQueryStates` (grouped) / `useQueryState` (single)

```typescript
'use client'

import { useQueryStates } from 'nuqs'
import { thingsParsers, thingsUrlKeys } from '@/app/workspace/[workspaceId]/things/search-params'

export function useThingFilters() {
const [filters, setFilters] = useQueryStates(thingsParsers, thingsUrlKeys)
// filters.search / filters.tags / filters.view are non-null (defaults applied)
// setFilters({ view: 'grid' }) — pass null to clear a single key back to default
return { filters, setFilters }
}
```

For a single param, use `useQueryState(key, parser)`:

```typescript
const [serverId, setServerId] = useQueryState(mcpServerIdParam.key, mcpServerIdParam.parser)
```

### Server — `createSearchParamsCache`

When a Server Component or loader must read a param, build a cache from the **same** parser map:

```typescript
// in a server component / page.tsx
import { createSearchParamsCache } from 'nuqs/server'
import { thingsParsers } from '@/app/workspace/[workspaceId]/things/search-params'

const thingsCache = createSearchParamsCache(thingsParsers)

export default async function Page({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {
const { search, view } = await thingsCache.parse(await searchParams)
// ...
}
```

If a client param must be re-read server-side after a change, set `shallow: false` on the write.

## Suspense boundary

`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`.

## Debounced text inputs

Use nuqs's built-in [`limitUrlUpdates: debounce(ms)`](https://nuqs.dev/docs/options) — never hand-roll a local `useState` mirror + `useDebounce` + a URL write-back effect + a ref-guarded URL→local reconcile effect. The hook's returned value updates instantly (so the input is controlled directly by the nuqs value and stays snappy); only the *URL write* is debounced. Back/forward and deep links flow back natively because the input reads the nuqs value — no reconcile effect needed.

- **Standalone single search param** (`useQueryState`): put `limitUrlUpdates: debounce(300)` in the param's options.
- **Search inside a grouped `useQueryStates`**: keep the group's immediate writes for the discrete filters; pass the option **per call** only on the search setter, never on the whole group:

```typescript
import { debounce } from 'nuqs'

const setSearch = useCallback(
(value: string) => {
const next = value.length > 0 ? value : null
// Immediate update when clearing so the param drops out without lingering.
setFilters({ search: next }, next === null ? undefined : { limitUrlUpdates: debounce(300) })
},
[setFilters]
)
```

- **Keep fetches/filtering debounced.** Where the search value feeds a React Query key or an expensive in-memory filter, derive a debounced value off the instant nuqs value (`const debounced = useDebounce(urlSearch, 300)`) and feed *that* to the query — the instant value is only for the input box. Cheap in-memory filtering over a small static list may read the instant value directly.
- Preserve `.trim()` handling, `clearOnDefault` (empty clears the param), the existing default, and `history: 'replace'`. Import `debounce` from `nuqs` (client) — not `nuqs/server`. See logs (`use-log-filters.ts` grouped, query stays debounced), integrations/recently-deleted (cheap in-memory filter, instant value), and tables (filter stays debounced).

## Sort convention (`sort` + `dir`)

Sortable lists use **two scalar params**, never a serialized `{column,direction}` object:

```typescript
const SORT_COLUMNS = ['name', 'created', 'updated'] as const
const SORT_DIRECTIONS = ['asc', 'desc'] as const

export const thingsParsers = {
sort: parseAsStringLiteral(SORT_COLUMNS).withDefault('updated'),
dir: parseAsStringLiteral(SORT_DIRECTIONS).withDefault('desc'),
} as const
```

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.

## Dates in the URL (`parseAsIsoDate`)

A date-only param (a calendar anchor, a date filter) uses the built-in `parseAsIsoDate` (`yyyy-MM-dd`) — never serialize a full `Date`/timestamp when only the day matters. When the default is **dynamic** (e.g. "today"), make the param **nullable** (omit `.withDefault`) and derive the fallback in the hook (`const anchor = param ?? today`), so a clean URL means the dynamic default and navigating back to it writes `null` (clears the param). See `scheduled-tasks/search-params.ts` + `hooks/use-calendar.ts`.

## Selected-entity deep-link (store the id, derive the object)

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:

```typescript
const [skillId, setSkillId] = useQueryState(skillIdParam.key, {
...skillIdParam.parser,
history: 'push', // opening an entity is a destination; "back" closes it
clearOnDefault: true,
})
// Derive — do not duplicate into useState or sync with an effect:
const editingSkill = skillId ? (skills.find((s) => s.id === skillId) ?? null) : null
```

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`.

## Read-then-strip deep links

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`.

## Workflow editor carve-out — what must NOT go in the URL

The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/socket-synced via `socket-provider.tsx`. Its view-state is intentionally store-backed (Zustand), not URL-backed. Do **not** move the following into the URL:

- **Live cursor** and **broadcast live selection** (presence; emitted over the socket, throttled).
- **Pan / zoom / viewport** (ReactFlow-owned, continuous, not persisted).
- **Drag state** and **resize widths/heights** (panel/terminal/sidebar — high-frequency, persisted as local preferences).
- **Ephemeral diff staging** (`hasActiveDiff`, `baselineWorkflow`, `diffAnalysis`).

Borderline candidates that *look* shareable but currently stay in Zustand because moving them fights existing machinery:

- **Panel `activeTab`** and **`canvasMode`** — persisted local *preferences* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`). They are layout prefs, not destinations; moving them would unwind the SSR machinery and risk tab-flash on load.
- **`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.

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.

## Docs

- Adapters (App Router `NuqsAdapter`): https://nuqs.dev/docs/adapters
- Parsers & options (`parseAsString`/`parseAsInteger`/`parseAsBoolean`/`parseAsStringLiteral`/`parseAsArrayOf`/`createParser`, `withDefault`, `history`, `shallow`, `clearOnDefault`): https://nuqs.dev/docs/parsers and https://nuqs.dev/docs/options
- Server-side reads (`createSearchParamsCache`): https://nuqs.dev/docs/server-side
Loading
Loading