Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
27 changes: 18 additions & 9 deletions .claude/rules/sim-url-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ paths:
- "apps/sim/app/**/*.tsx"
- "apps/sim/app/**/*.ts"
- "apps/sim/app/**/search-params.ts"
- "apps/sim/ee/**/*.tsx"
- "apps/sim/ee/**/*.ts"
---

# URL / Query-Param State (nuqs)
Expand Down Expand Up @@ -50,11 +52,13 @@ Co-locate a `search-params.ts` next to the feature. Export the parser map (and s

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.
- `.withDefault(...)` on every parser so reads are non-null. A deliberately **nullable** parser (dynamic default, custom-range-only dates, nullable sort) must carry a comment saying why.
- Filter / search / toggle / pagination options: `{ history: 'replace', clearOnDefault: true }` — clean URLs, no back-stack churn. Note all three of `history: 'replace'`, `clearOnDefault: true`, and `shallow: true` are already the nuqs v2 defaults — writing the first two explicitly is documentation (and guards the groups whose options differ, e.g. `history: 'push'`), and `shallow: true` may be omitted entirely.
- 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.
- `shallow: false` **only** when a Server Component / loader must re-read the param. For loading states during the server re-render, pass React's `startTransition` via `.withOptions({ startTransition, shallow: false })`.
- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one. When the parser-map key is camelCase (for clean destructuring), remap the wire key via the `urlKeys` option in the shared options object (see `files/search-params.ts` `uploadedBy: 'uploaded-by'`, `ee/audit-logs/search-params.ts` `timeRange: 'time-range'`); nuqs also exports a `UrlKeys<typeof parsers>` type helper for standalone mappings.
- `throttleMs` is deprecated in nuqs — rate-limit URL writes with `limitUrlUpdates: throttle(ms)` / `debounce(ms)` (the debounced-search hook below already does this).
- A parser **shared across surfaces with different defaults** (e.g. `parseAsTimeRange`) must `parse` unknown tokens to `null` — never to one surface's default — so each consumer's `.withDefault(...)` decides the fallback.
- 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])`.

Expand All @@ -75,11 +79,12 @@ export const thingsParsers = {
/** Clean URLs, no back-stack churn for filter changes. */
export const thingsUrlKeys = {
history: 'replace',
shallow: true,
clearOnDefault: true,
} as const
```

(The `*UrlKeys` suffix is the repo's naming convention for a feature's shared **options** object — which may itself contain a nuqs `urlKeys` key-remapping entry; the two are different things.)

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

```typescript
Expand Down Expand Up @@ -182,7 +187,7 @@ Sort params live alongside — not inside — the feature's grouped filter parse

A date-only param (a calendar anchor, a date filter) is stored as `yyyy-MM-dd` — never serialize a full `Date`/timestamp when only the day matters.

**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString()`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`).
**Local vs UTC — pick the parser that matches your date math.** nuqs's built-in `parseAsIsoDate` is **UTC-based** (`serialize` via `toISOString().slice(0, 10)`, `parse` to UTC midnight). If your `Date` is local-time (e.g. produced by local-time helpers and read by `date-fns` `startOfWeek`/`isSameDay`, which are all local), `parseAsIsoDate` will shift the day by ±1 in any non-UTC timezone on reload/deep-link/back-forward. For local-time date math, use a small local-date `createParser` that serializes/parses on local calendar fields (`getFullYear`/`getMonth`/`getDate` ↔ `new Date(y, m-1, d)`) with an `eq` comparing y/m/d. Only use `parseAsIsoDate` when the value is genuinely UTC/midnight-UTC. See `scheduled-tasks/search-params.ts` (`parseAsLocalDate`).

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/hooks/use-calendar.ts`.

Expand All @@ -200,7 +205,11 @@ const [skillId, setSkillId] = useQueryState(skillIdParam.key, {
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`.
Open the panel/modal only when the id **resolves to a loaded entity** — never gate on the raw param alone, or a dead/stale id (deleted entity, old bookmark) renders a broken detail view and a still-loading list flashes one. A dead id simply falls back to the list; the lingering param is harmless. Because this reads `useSearchParams` it needs a **Suspense** boundary on the page (see "Suspense boundary" above). A separate "create new" flow has no id and stays in local `useState`.

**Close with `replace`, open with `push`.** Opening pushed a history entry; closing must not push another. Close via the setter's per-call options — `setSkillId(null, { history: 'replace' })` — so Back from the list leaves the page instead of reopening the detail (see `mcp.tsx`, `workflow-mcp-servers.tsx`, access-control, custom-blocks, forks). Secondary params scoped to the detail view (e.g. its active tab, `server-tab`) are cleared in the same close handler with their own setter — nuqs batches same-tick writes into one URL update.

**Reusable components** rendered both as a settings/list page and inside a modal (e.g. `BYOKKeyManager`) expose an optional controlled `searchTerm`/`onSearchTermChange` prop pair: the page consumer binds the URL (`useSettingsSearch()`), modal consumers omit the props and keep local state. Never bind URL state from inside a component that can mount in a non-destination context.

## Read-then-strip deep links

Expand All @@ -217,8 +226,8 @@ The workflow editor (`apps/sim/app/workspace/[workspaceId]/w/**`) is realtime/so

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.
- **Panel `activeTab`** — a persisted local *preference* wired into an SSR flash-prevention path (`data-panel-active-tab` + `_hasHydrated`); moving it would unwind that machinery and risk tab-flash on load. **Canvas mode** (`mode` on `useCanvasModeStore`) is likewise a persisted layout preference, not a destination.
- **The panel editor's `currentBlockId`** (`stores/panel/editor/store.ts` — a would-be "look at this block" deep link) — the only genuinely shareable candidate, but it is persisted and entangled with panel-open orchestration. Adding a URL param for 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.

Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/(landing)/integrations/search-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server'
* so the filtered view is server-rendered for shareable, crawlable `?category=`/`?q=`
* URLs — the same SSR pattern the blog index uses.
*
* - `q` is the search filter; its URL write is debounced on the setter, never
* written per keystroke.
* - `q` is the search filter; its URL write is debounced via
* `useDebouncedSearchSetter`, never written per keystroke.
* - `category` filters by integration type (`''` = all).
*/
export const integrationsParsers = {
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/(landing)/models/search-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ import { createSearchParamsCache, parseAsString } from 'nuqs/server'
* so the filtered view is server-rendered for shareable, crawlable `?provider=`/`?q=`
* URLs — the same SSR pattern the blog index uses.
*
* - `q` is the search filter; its URL write is debounced on the setter, never
* written per keystroke.
* - `q` is the search filter; its URL write is debounced via
* `useDebouncedSearchSetter`, never written per keystroke.
* - `provider` filters by provider id (`''` = all).
*/
export const modelsParsers = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export const CONNECTED_LABEL = 'Connected'
* pseudo-categories and are derived from the data set, so a plain string is
* used; the `All` default clears from the URL.
* - `search` is the integration search term. The input is controlled directly by
* the nuqs value; only its URL write is debounced via `limitUrlUpdates`
* (`debounce`) on the setter — never written on every keystroke.
* the nuqs value; only its URL write is debounced via
* `useDebouncedSearchSetter` — never written on every keystroke.
*/
export const integrationsParsers = {
category: parseAsString.withDefault(ALL_CATEGORY),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,12 @@ export function Document({
const { workspaceId } = useParams()
const router = useRouter()
const [
{ page: currentPageFromURL, chunk: chunkFromURL, search: searchQuery },
{
page: currentPageFromURL,
chunk: chunkFromURL,
search: searchQuery,
enabled: enabledFilterParam,
},
setDocumentParams,
] = useQueryStates(documentParsers, documentUrlKeys)
const userPermissions = useUserPermissionsContext()
Expand All @@ -160,16 +165,31 @@ export function Document({
)
/** Raw URL value drives the input; the chunk search query always sees it trimmed. */
const debouncedSearchQuery = useDebounce(searchQuery, CHUNK_SEARCH_DEBOUNCE_MS).trim()
const [enabledFilter, setEnabledFilter] = useState<string[]>([])
const {
activeSort,
onSort: onSortColumn,
onClear: onClearSort,
} = useUrlSort(documentChunkSortParams, documentUrlKeys)

const enabledFilterParam = useMemo(
() => (enabledFilter.length === 1 ? (enabledFilter[0] as 'enabled' | 'disabled') : 'all'),
[enabledFilter]
/** Multi-select UI view of the scalar `enabled` param (`all` ↔ nothing selected). */
const enabledFilter = useMemo<string[]>(
() => (enabledFilterParam === 'all' ? [] : [enabledFilterParam]),
[enabledFilterParam]
)

/**
* Collapses the dropdown's multi-select values to the scalar param (one value
* filters; none or both mean `all`) and resets `page` in the same write so a
* filter change always lands on the first page.
*/
const setEnabledFilter = useCallback(
(values: string[]) => {
void setDocumentParams({
enabled: values.length === 1 ? (values[0] as 'enabled' | 'disabled') : null,
page: null,
})
},
[setDocumentParams]
)

const {
Expand Down Expand Up @@ -619,8 +639,7 @@ export function Document({

const enabledDisplayLabel = useMemo(() => {
if (enabledFilter.length === 0) return 'All'
if (enabledFilter.length === 1) return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
return `${enabledFilter.length} selected`
return enabledFilter[0] === 'enabled' ? 'Enabled' : 'Disabled'
}, [enabledFilter])

const filterContent = useMemo(
Expand All @@ -638,7 +657,6 @@ export function Document({
onMultiSelectChange={(values) => {
setEnabledFilter(values)
setSelectedChunks(new Set())
void goToPage(1)
}}
overlayContent={
<span className='truncate text-[var(--text-primary)]'>{enabledDisplayLabel}</span>
Expand All @@ -654,7 +672,6 @@ export function Document({
onClick={() => {
setEnabledFilter([])
setSelectedChunks(new Set())
void goToPage(1)
}}
className='flex h-[32px] w-full items-center justify-center rounded-md text-[var(--text-secondary)] text-caption transition-colors hover-hover:bg-[var(--surface-active)]'
>
Expand All @@ -663,20 +680,19 @@ export function Document({
)}
</div>
),
[enabledFilter, enabledDisplayLabel, goToPage]
[enabledFilter, enabledDisplayLabel, setEnabledFilter]
)

const filterTags: FilterTag[] = useMemo(
() =>
enabledFilter.map((value) => ({
label: `Status: ${value === 'enabled' ? 'Enabled' : 'Disabled'}`,
onRemove: () => {
setEnabledFilter((prev) => prev.filter((v) => v !== value))
setEnabledFilter(enabledFilter.filter((v) => v !== value))
setSelectedChunks(new Set())
void goToPage(1)
},
})),
[enabledFilter, goToPage]
[enabledFilter, setEnabledFilter]
)

const handleChunkClick = useCallback(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { parseAsInteger, parseAsString } from 'nuqs/server'
import { parseAsInteger, parseAsString, parseAsStringLiteral } from 'nuqs/server'
import { createSortParams } from '@/lib/url-state'

/** Chunk `enabled` filter buckets, matching the status filter dropdown. */
const ENABLED_FILTERS = ['all', 'enabled', 'disabled'] as const

/** Sortable chunk columns, matching the `Resource.Options` sort menu ids. */
export const CHUNK_SORT_COLUMNS = ['index', 'tokens', 'status'] as const

Expand All @@ -24,11 +27,14 @@ export const documentChunkSortParams = createSortParams(CHUNK_SORT_COLUMNS)
* - `search` is the chunk content search. The input is controlled directly by
* the instant nuqs value; only its URL write is debounced via
* `useDebouncedSearchSetter` — never written on every keystroke.
* - `enabled` filters chunks by enabled status (`all` clears from the URL),
* mirroring the same filter on the knowledge base document list.
*/
export const documentParsers = {
page: parseAsInteger.withDefault(1),
chunk: parseAsString,
search: parseAsString.withDefault(''),
enabled: parseAsStringLiteral(ENABLED_FILTERS).withDefault('all'),
} as const

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,8 @@ export const knowledgeSortParams = createSortParams(KNOWLEDGE_SORT_COLUMNS, {
*
* - `search` is the knowledge base name/description filter. The input is
* controlled directly by the instant nuqs value; only its URL write is
* debounced via `limitUrlUpdates` (`debounce`) on the setter — never written
* on every keystroke.
* debounced via `useDebouncedSearchSetter` — never written on every
* keystroke.
* - `connector` filters by connector presence; `content` filters by document
* presence; `owner` filters by creator id. All are multi-select arrays.
*
Expand Down
30 changes: 25 additions & 5 deletions apps/sim/app/workspace/[workspaceId]/logs/search-params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,12 +41,13 @@ const TOKEN_TO_TIME_RANGE: Record<string, TimeRange> = Object.fromEntries(
) as Record<string, TimeRange>

/**
* Parser for the `timeRange` param. Serializes labels to kebab tokens and
* tolerantly maps unknown tokens back to the default ("All time").
* Parser for the `timeRange` param. Serializes labels to kebab tokens. Unknown
* tokens parse to `null` so each consuming surface's `.withDefault(...)` decides
* the fallback (logs: "All time"; audit-logs: "Past 30 days").
*/
export const parseAsTimeRange = createParser<TimeRange>({
parse(value) {
return TOKEN_TO_TIME_RANGE[value] ?? DEFAULT_TIME_RANGE
return TOKEN_TO_TIME_RANGE[value] ?? null
},
serialize(value) {
return TIME_RANGE_TO_TOKEN[value] ?? 'all-time'
Expand Down Expand Up @@ -76,6 +77,21 @@ export const parseAsLogLevel = createParser<LogLevel>({
},
})

/**
* Parser for free-form date/datetime strings (`startDate`/`endDate`). Rejects
* unparseable values at the URL boundary — an invalid date string reaching
* `new Date(...).toISOString()` throws, so a malformed deep link must parse to
* `null` (treated as a missing bound) instead of crashing the consumer.
*/
export const parseAsDateString = createParser<string>({
parse(value) {
return Number.isNaN(Date.parse(value)) ? null : value
},
serialize(value) {
return value
},
})

const CORE_TRIGGER_SET = new Set<string>(CORE_TRIGGER_TYPES)

/**
Expand Down Expand Up @@ -104,8 +120,12 @@ export const parseAsTriggers = createParser<TriggerType[]>({
*/
export const logFilterParsers = {
timeRange: parseAsTimeRange.withDefault(DEFAULT_TIME_RANGE),
startDate: parseAsString,
endDate: parseAsString,
/**
* Deliberately nullable: only populated when timeRange is "Custom range";
* every preset range derives its window from the label instead.
*/
startDate: parseAsDateString,
endDate: parseAsDateString,
level: parseAsLogLevel.withDefault('all'),
workflowIds: parseAsArrayOf(parseAsString).withDefault([]),
folderIds: parseAsArrayOf(parseAsString).withDefault([]),
Expand Down
Loading
Loading