Skip to content

Commit a3ca5b2

Browse files
committed
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.
1 parent 47e2f7a commit a3ca5b2

10 files changed

Lines changed: 149 additions & 108 deletions

File tree

.claude/rules/sim-url-state.md

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,8 @@ Conventions:
5555
- Navigations that belong in browser history (changing folder, opening a deep-linked entity): `{ history: 'push' }`.
5656
- `shallow: false` **only** when a Server Component / loader must re-read the param.
5757
- Short, stable, **kebab-case** URL keys. Renaming a key is a breaking change to shared links — treat it as one.
58-
- For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use `createParser`.
58+
- For an opaque/literal value use `parseAsStringLiteral([...] as const)`; for a custom wire format use [`createParser`](https://nuqs.dev/docs/parsers).
59+
- 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])`.
5960

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

@@ -126,7 +127,26 @@ If a client param must be re-read server-side after a change, set `shallow: fals
126127

127128
## Debounced text inputs
128129

129-
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.
130+
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.
131+
132+
- **Standalone single search param** (`useQueryState`): put `limitUrlUpdates: debounce(300)` in the param's options.
133+
- **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:
134+
135+
```typescript
136+
import { debounce } from 'nuqs'
137+
138+
const setSearch = useCallback(
139+
(value: string) => {
140+
const next = value.length > 0 ? value : null
141+
// Immediate update when clearing so the param drops out without lingering.
142+
setFilters({ search: next }, next === null ? undefined : { limitUrlUpdates: debounce(300) })
143+
},
144+
[setFilters]
145+
)
146+
```
147+
148+
- **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.
149+
- 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).
130150

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

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

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

3-
import { type ComponentType, useCallback, useEffect, useMemo, useRef, useState } from 'react'
3+
import { type ComponentType, useCallback, useMemo } from 'react'
44
import Link from 'next/link'
55
import { useParams } from 'next/navigation'
6-
import { useQueryStates } from 'nuqs'
6+
import { debounce, useQueryStates } from 'nuqs'
77
import {
88
ArrowRight,
99
ChevronDown,
@@ -34,7 +34,9 @@ import {
3434
integrationsUrlKeys,
3535
} from '@/app/workspace/[workspaceId]/integrations/search-params'
3636
import { useWorkspaceCredentials, type WorkspaceCredential } from '@/hooks/queries/credentials'
37-
import { useDebounce } from '@/hooks/use-debounce'
37+
38+
/** Debounce window for `search` URL writes; the input itself stays instant. */
39+
const SEARCH_DEBOUNCE_MS = 300 as const
3840

3941
/** Slugs surfaced in the pinned Featured section, in display order. */
4042
const FEATURED_SLUGS = ['slack', 'gmail', 'jira', 'github', 'google-sheets', 'hubspot'] as const
@@ -139,8 +141,19 @@ export function Integrations() {
139141
const [{ category: selectedCategory, search: urlSearchTerm }, setIntegrationFilters] =
140142
useQueryStates(integrationsParsers, integrationsUrlKeys)
141143

142-
const [searchTerm, setSearchTerm] = useState(urlSearchTerm)
143-
const debouncedSearchTerm = useDebounce(searchTerm, 300)
144+
// The input is controlled directly by the instant nuqs value; only the URL
145+
// write is debounced. Filtering below is cheap in-memory over a static list,
146+
// so it reads the instant value too.
147+
const setSearchTerm = useCallback(
148+
(value: string) => {
149+
const next = value.length > 0 ? value : null
150+
setIntegrationFilters(
151+
{ search: next },
152+
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
153+
)
154+
},
155+
[setIntegrationFilters]
156+
)
144157

145158
const { data: credentials = [], isPending: credentialsLoading } = useWorkspaceCredentials({
146159
workspaceId,
@@ -173,17 +186,6 @@ export function Integrations() {
173186
})
174187
}, [oauthCredentials])
175188

176-
useEffect(() => {
177-
setIntegrationFilters({ search: debouncedSearchTerm.length > 0 ? debouncedSearchTerm : null })
178-
}, [debouncedSearchTerm, setIntegrationFilters])
179-
180-
const lastSyncedUrlSearchRef = useRef(urlSearchTerm)
181-
useEffect(() => {
182-
if (urlSearchTerm === lastSyncedUrlSearchRef.current) return
183-
lastSyncedUrlSearchRef.current = urlSearchTerm
184-
setSearchTerm((current) => (current === urlSearchTerm ? current : urlSearchTerm))
185-
}, [urlSearchTerm])
186-
187189
const setSelectedCategory = useCallback(
188190
(category: string) => {
189191
setIntegrationFilters({ category })
@@ -206,7 +208,7 @@ export function Integrations() {
206208
// Connected-only view: integration sections are suppressed entirely.
207209
if (isConnectedSelected) return []
208210

209-
const normalizedSearch = searchTerm.trim().toLowerCase()
211+
const normalizedSearch = urlSearchTerm.trim().toLowerCase()
210212
const matchesSearch = (integration: Integration) =>
211213
!normalizedSearch ||
212214
integration.name.toLowerCase().includes(normalizedSearch) ||
@@ -242,14 +244,20 @@ export function Integrations() {
242244
...featuredSection,
243245
...(integrations.length > 0 ? [{ label: selectedCategory, integrations }] : []),
244246
]
245-
}, [isAllCategorySelected, isConnectedSelected, isFeaturedSelected, searchTerm, selectedCategory])
247+
}, [
248+
isAllCategorySelected,
249+
isConnectedSelected,
250+
isFeaturedSelected,
251+
urlSearchTerm,
252+
selectedCategory,
253+
])
246254

247255
const visibleConnectedItems = useMemo(() => {
248256
// Featured-only view: Connected is suppressed (mirror behavior of the
249257
// Featured-only branch above, which renders only the Featured section).
250258
if (isFeaturedSelected) return []
251259

252-
const normalizedSearch = searchTerm.trim().toLowerCase()
260+
const normalizedSearch = urlSearchTerm.trim().toLowerCase()
253261
return connectedItems.filter((item) => {
254262
const matchesCategory =
255263
isAllCategorySelected || isConnectedSelected || item.integrationType === selectedCategory
@@ -266,12 +274,12 @@ export function Integrations() {
266274
isAllCategorySelected,
267275
isConnectedSelected,
268276
isFeaturedSelected,
269-
searchTerm,
277+
urlSearchTerm,
270278
selectedCategory,
271279
])
272280

273281
const showNoResults =
274-
Boolean(searchTerm.trim() || !isAllCategorySelected) &&
282+
Boolean(urlSearchTerm.trim() || !isAllCategorySelected) &&
275283
filteredCategorySections.length === 0 &&
276284
visibleConnectedItems.length === 0
277285

@@ -286,7 +294,7 @@ export function Integrations() {
286294
icon={Search}
287295
className='min-w-0 flex-1'
288296
placeholder='Search integrations...'
289-
value={searchTerm}
297+
value={urlSearchTerm}
290298
onChange={(e) => setSearchTerm(e.target.value)}
291299
disabled={credentialsLoading}
292300
/>
@@ -349,8 +357,8 @@ export function Integrations() {
349357

350358
{showNoResults && (
351359
<div className='py-4 text-center text-[var(--text-muted)] text-sm'>
352-
{searchTerm.trim()
353-
? `No integrations found matching “${searchTerm}”`
360+
{urlSearchTerm.trim()
361+
? `No integrations found matching “${urlSearchTerm}”`
354362
: 'No integrations in this category'}
355363
</div>
356364
)}

apps/sim/app/workspace/[workspaceId]/integrations/search-params.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,9 @@ export const CONNECTED_LABEL = 'Connected'
1414
* `IntegrationType` enum values with the `All`/`Featured`/`Connected`
1515
* pseudo-categories and are derived from the data set, so a plain string is
1616
* used; the `All` default clears from the URL.
17-
* - `search` is the integration search term. It is written debounced from the
18-
* local input (logs pattern) — never on every keystroke.
17+
* - `search` is the integration search term. The input is controlled directly by
18+
* the nuqs value; only its URL write is debounced via `limitUrlUpdates`
19+
* (`debounce`) on the setter — never written on every keystroke.
1920
*/
2021
export const integrationsParsers = {
2122
category: parseAsString.withDefault(ALL_CATEGORY),

apps/sim/app/workspace/[workspaceId]/logs/hooks/use-log-filters.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
'use client'
22

33
import { useCallback, useMemo } from 'react'
4-
import { useQueryStates } from 'nuqs'
4+
import { debounce, useQueryStates } from 'nuqs'
55
import {
66
logFilterParsers,
77
logFilterUrlKeys,
@@ -10,6 +10,9 @@ import type { LogLevel, TimeRange, TriggerType } from '@/stores/logs/filters/typ
1010

1111
const DEFAULT_TIME_RANGE: TimeRange = 'All time'
1212

13+
/** Debounce window for `search` URL writes; keystrokes stay instant in the input. */
14+
const SEARCH_DEBOUNCE_MS = 300 as const
15+
1316
/**
1417
* The logs filter state, sourced entirely from typed URL query params via nuqs.
1518
*
@@ -115,7 +118,14 @@ export function useLogFilters(): UseLogFilters {
115118
const setSearchQuery = useCallback(
116119
(query: string) => {
117120
const trimmed = query.trim()
118-
setFilters({ search: trimmed.length > 0 ? trimmed : null })
121+
const next = trimmed.length > 0 ? trimmed : null
122+
// Debounce only the search param's URL write; the returned `filters.search`
123+
// value still updates instantly so the controlled input stays responsive.
124+
// Clearing flushes immediately so the param drops out without lingering.
125+
setFilters(
126+
{ search: next },
127+
next === null ? undefined : { limitUrlUpdates: debounce(SEARCH_DEBOUNCE_MS) }
128+
)
119129
},
120130
[setFilters]
121131
)

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

Lines changed: 25 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -233,8 +233,10 @@ export default function Logs() {
233233
const [executionId] = useQueryState(executionIdParam.key, executionIdParam.parser)
234234
const [pendingExecutionId, setPendingExecutionId] = useState<string | null>(() => executionId)
235235

236-
const [searchQuery, setSearchQuery] = useState(urlSearchQuery)
237-
const debouncedSearchQuery = useDebounce(searchQuery, 300)
236+
// `urlSearchQuery` is the instant nuqs value (its URL write is debounced inside
237+
// `useLogFilters`); the query/filtering still debounce off it to avoid per-keystroke
238+
// fetches.
239+
const debouncedSearchQuery = useDebounce(urlSearchQuery, 300)
238240

239241
const isLive = true
240242
const [isVisuallyRefreshing, setIsVisuallyRefreshing] = useState(false)
@@ -384,10 +386,6 @@ export default function Logs() {
384386
}
385387
}, [])
386388

387-
useEffect(() => {
388-
setUrlSearchQuery(debouncedSearchQuery)
389-
}, [debouncedSearchQuery, setUrlSearchQuery])
390-
391389
const handleLogClick = useCallback((rowId: string) => {
392390
dispatch({ type: 'TOGGLE_LOG', logId: rowId })
393391
}, [])
@@ -462,8 +460,8 @@ export default function Logs() {
462460

463461
const handleClearAllFilters = useCallback(() => {
464462
resetFilters()
465-
setSearchQuery('')
466-
}, [resetFilters, setSearchQuery])
463+
setUrlSearchQuery('')
464+
}, [resetFilters, setUrlSearchQuery])
467465

468466
const handleOpenPreview = useCallback(() => {
469467
if (contextMenuLog?.id) {
@@ -614,18 +612,6 @@ export default function Logs() {
614612
debouncedSearchQuery,
615613
])
616614

617-
/**
618-
* Mirror external URL `search` changes (back/forward navigation, programmatic
619-
* resets) into the local input state. nuqs keeps the filter state itself in
620-
* sync with the URL; this only reconciles the debounced local input mirror.
621-
*/
622-
const lastSyncedUrlSearchRef = useRef(urlSearchQuery)
623-
useEffect(() => {
624-
if (urlSearchQuery === lastSyncedUrlSearchRef.current) return
625-
lastSyncedUrlSearchRef.current = urlSearchQuery
626-
setSearchQuery((current) => (current.trim() === urlSearchQuery ? current : urlSearchQuery))
627-
}, [urlSearchQuery])
628-
629615
const loadMoreLogs = useCallback(() => {
630616
const { isFetching, hasNextPage, fetchNextPage } = logsQueryRef.current
631617
if (!isFetching && hasNextPage) {
@@ -832,13 +818,16 @@ export default function Logs() {
832818
[workflowsData, foldersData, triggersData]
833819
)
834820

835-
const handleFiltersChange = useCallback((filters: ParsedFilter[], textSearch: string) => {
836-
const filterStrings = filters.map(
837-
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
838-
)
839-
const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ')
840-
setSearchQuery(fullQuery)
841-
}, [])
821+
const handleFiltersChange = useCallback(
822+
(filters: ParsedFilter[], textSearch: string) => {
823+
const filterStrings = filters.map(
824+
(f) => `${f.field}:${f.operator !== '=' ? f.operator : ''}${f.originalValue}`
825+
)
826+
const fullQuery = [...filterStrings, textSearch].filter(Boolean).join(' ')
827+
setUrlSearchQuery(fullQuery)
828+
},
829+
[setUrlSearchQuery]
830+
)
842831

843832
const getSuggestions = useCallback(
844833
(input: string) => suggestionEngine.getSuggestions(input),
@@ -872,14 +861,14 @@ export default function Logs() {
872861

873862
const lastExternalSearchValue = useRef<string | undefined>(undefined)
874863
useEffect(() => {
875-
if (searchQuery === lastExternalSearchValue.current) return
864+
if (urlSearchQuery === lastExternalSearchValue.current) return
876865
const isMount = lastExternalSearchValue.current === undefined
877-
lastExternalSearchValue.current = searchQuery
866+
lastExternalSearchValue.current = urlSearchQuery
878867
// On mount with no initial query, skip the no-op parse
879-
if (isMount && !searchQuery) return
880-
const parsed = parseQuery(searchQuery)
868+
if (isMount && !urlSearchQuery) return
869+
const parsed = parseQuery(urlSearchQuery)
881870
initializeFromQuery(parsed.textSearch, parsed.filters)
882-
}, [searchQuery, initializeFromQuery])
871+
}, [urlSearchQuery, initializeFromQuery])
883872

884873
useEffect(() => {
885874
if (!isSuggestionsOpen || highlightedIndex < 0) return
@@ -1075,7 +1064,10 @@ export default function Logs() {
10751064
sort={sortConfig}
10761065
filter={{
10771066
content: (
1078-
<LogsFilterPanel searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} />
1067+
<LogsFilterPanel
1068+
searchQuery={urlSearchQuery}
1069+
onSearchQueryChange={setUrlSearchQuery}
1070+
/>
10791071
),
10801072
}}
10811073
filterTags={filterTags}

apps/sim/app/workspace/[workspaceId]/logs/search-params.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,9 @@ export const parseAsTriggers = createParser<TriggerType[]>({
8888
serialize(value) {
8989
return value.join(',')
9090
},
91+
eq(a, b) {
92+
return a.length === b.length && a.every((v, i) => v === b[i])
93+
},
9194
}).withDefault([])
9295

9396
/**

0 commit comments

Comments
 (0)