diff --git a/apps/showcase/src/app/pages/docs/query-param-state/demos/demo-query-param-state-demo.ts b/apps/showcase/src/app/pages/docs/query-param-state/demos/demo-query-param-state-demo.ts index c05e30ed1..aa4f66cec 100644 --- a/apps/showcase/src/app/pages/docs/query-param-state/demos/demo-query-param-state-demo.ts +++ b/apps/showcase/src/app/pages/docs/query-param-state/demos/demo-query-param-state-demo.ts @@ -74,7 +74,10 @@ import { changeDetection: ChangeDetectionStrategy.OnPush, }) export class DemoQueryParamStateDemo { - readonly search = injectQueryParam('q', parseAsString.withDefault('')); + readonly search = injectQueryParam( + 'q', + parseAsString.withDefault('').withOptions({ debounceMs: 300 }), + ); readonly page = injectQueryParam('page', parseAsInteger.withDefault(1)); diff --git a/libs/ui-lab/src/lib/utilities/query-param-state/inject-query-param.ts b/libs/ui-lab/src/lib/utilities/query-param-state/inject-query-param.ts index 50ce92e62..435ca79b4 100644 --- a/libs/ui-lab/src/lib/utilities/query-param-state/inject-query-param.ts +++ b/libs/ui-lab/src/lib/utilities/query-param-state/inject-query-param.ts @@ -1,4 +1,11 @@ -import { DestroyRef, WritableSignal, inject, signal } from '@angular/core'; +import { + DestroyRef, + WritableSignal, + debounced, + effect, + inject, + signal, +} from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ActivatedRoute } from '@angular/router'; import { QueryParamSyncService } from './query-param-sync'; @@ -68,7 +75,7 @@ export function injectQueryParam( sync.write(key, null, parser._options); } - // Subscribe to route changes and keep signal in sync + // External URL changes (back/forward, manual edits) flow into the signal. route.queryParamMap .pipe(takeUntilDestroyed(destroyRef)) .subscribe((params) => { @@ -81,19 +88,32 @@ export function injectQueryParam( } }); + // Signal is the source of truth; URL is a derived view of it. + const debounceMs = parser._options?.debounceMs ?? 0; + const debouncedView = debounceMs > 0 ? debounced(sig, debounceMs) : null; + const read = debouncedView ? () => debouncedView.value() : () => sig(); + + let firstRun = true; + effect(() => { + const value = read(); + if (firstRun) { + firstRun = false; + return; + } + const raw = + value === null || isDefault(value) ? null : parser.serialize(value); + if (raw === sync.getRaw(key)) return; + sync.write(key, raw, parser._options); + }); + return { value: sig as WritableSignal, set(value: T | null): void { - const raw = - value === null || isDefault(value) ? null : parser.serialize(value); - sync.write(key, raw, parser._options); - // Optimistic local update for instant UI response sig.set(value ?? defaultValue); }, clear(): void { - sync.write(key, null, parser._options); sig.set(defaultValue); }, }; diff --git a/libs/ui-lab/src/lib/utilities/query-param-state/query-param-types.ts b/libs/ui-lab/src/lib/utilities/query-param-state/query-param-types.ts index 286f45e26..82942ca6e 100644 --- a/libs/ui-lab/src/lib/utilities/query-param-state/query-param-types.ts +++ b/libs/ui-lab/src/lib/utilities/query-param-state/query-param-types.ts @@ -8,6 +8,13 @@ export interface Parser { export interface QueryParamOptions { /** 'replace' (default) or 'push' — controls browser history behaviour */ history?: 'replace' | 'push'; + /** + * Debounce URL writes by this many milliseconds. The signal is still + * updated synchronously — only the navigation is delayed. Defaults to 0 + * (writes flushed on the next microtask). Useful for inputs where the + * user types rapidly and you don't want a history entry per keystroke. + */ + debounceMs?: number; } export interface ParserBuilder extends Parser {