From 37f2f23976261d845f9dd5e2cbcb5b4b361c72de Mon Sep 17 00:00:00 2001 From: kgridou <32600911+kgridou@users.noreply.github.com> Date: Fri, 8 May 2026 17:46:56 +0200 Subject: [PATCH] feat(query-param-state): make signal source of truth with debounced URL writes The signal now drives the URL via an effect; set/clear no longer write to the URL directly. When debounceMs is provided, the effect reads through Angular's experimental debounced() so rapid input updates the signal immediately while the URL is updated only after the user stops typing. Default values are stripped so the URL only carries deviations. Co-Authored-By: Claude Opus 4.7 --- .../demos/demo-query-param-state-demo.ts | 5 ++- .../query-param-state/inject-query-param.ts | 34 +++++++++++++++---- .../query-param-state/query-param-types.ts | 7 ++++ 3 files changed, 38 insertions(+), 8 deletions(-) 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 {