@timber-js/app 0.2.0-alpha.67 → 0.2.0-alpha.68

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -39,6 +39,8 @@ import {
39
39
  PENDING_LINK_STATUS,
40
40
  type LinkPendingInstance,
41
41
  } from './link-pending-store.js';
42
+ import { setNavLinkMetadata } from './nav-link-store.js';
43
+ import { hasNavigationApi } from './navigation-api.js';
42
44
 
43
45
  // ─── Current Search Params ────────────────────────────────────────
44
46
 
@@ -474,15 +476,8 @@ export function Link({
474
476
  const router = getRouterOrNull();
475
477
  if (!router) return; // SSR or pre-hydration — fall through to browser nav
476
478
 
477
- event.preventDefault();
478
479
  const shouldScroll = scroll !== false;
479
480
 
480
- // Re-merge preserved search params at click time to pick up any
481
- // URL changes since render (e.g. from other navigations or pushState).
482
- const navHref = preserveSearchParams
483
- ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
484
- : resolvedHref;
485
-
486
481
  // Eagerly show pending state on this link (urgent update, immediate).
487
482
  // Only this Link re-renders — all other Links are unaffected.
488
483
  setLinkStatus(PENDING_LINK_STATUS);
@@ -492,6 +487,33 @@ export function Link({
492
487
  // Also resets any previous pending link to IDLE.
493
488
  setLinkForCurrentNavigation(linkInstanceRef.current);
494
489
 
490
+ // When Navigation API is active, let the <a> click propagate
491
+ // naturally — do NOT call preventDefault(). The navigate event
492
+ // handler intercepts it and runs the RSC pipeline. This is a
493
+ // user-initiated navigation, so Chrome shows the native loading
494
+ // indicator (tab spinner). Metadata (scroll, link instance) is
495
+ // passed via nav-link-store so the handler can configure the nav.
496
+ //
497
+ // Without Navigation API (fallback), preventDefault and drive
498
+ // navigation through the router as before.
499
+ if (hasNavigationApi()) {
500
+ setNavLinkMetadata({
501
+ scroll: shouldScroll,
502
+ linkInstance: linkInstanceRef.current,
503
+ });
504
+ // Don't preventDefault — let the <a> click fire the navigate event
505
+ return;
506
+ }
507
+
508
+ // History API fallback — prevent default and navigate via router
509
+ event.preventDefault();
510
+
511
+ // Re-merge preserved search params at click time to pick up any
512
+ // URL changes since render (e.g. from other navigations or pushState).
513
+ const navHref = preserveSearchParams
514
+ ? mergePreservedSearchParams(baseHref, getCurrentSearch(), preserveSearchParams)
515
+ : resolvedHref;
516
+
495
517
  void router.navigate(navHref, { scroll: shouldScroll });
496
518
  }
497
519
  : userOnClick; // External links — just pass through user's onClick
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Navigation Link Store — passes per-link metadata from Link's onClick
3
+ * to the Navigation API's navigate event handler.
4
+ *
5
+ * When the Navigation API is active, Link does NOT call event.preventDefault()
6
+ * or router.navigate(). Instead it stores metadata (scroll option, link
7
+ * pending instance) here, and lets the <a> click propagate naturally.
8
+ * The navigate event handler reads this metadata to configure the RSC
9
+ * navigation with the correct options.
10
+ *
11
+ * This store is consumed once per navigation — after reading, the metadata
12
+ * is cleared. If no metadata is present (e.g., a plain <a> tag without
13
+ * our Link component), the navigate handler uses default options.
14
+ *
15
+ * See design/19-client-navigation.md §"Navigation API Integration"
16
+ */
17
+
18
+ import type { LinkPendingInstance } from './link-pending-store.js';
19
+
20
+ export interface NavLinkMetadata {
21
+ /** Whether to scroll to top after navigation. Default: true. */
22
+ scroll: boolean;
23
+ /** The Link's pending state instance for per-link status tracking. */
24
+ linkInstance: LinkPendingInstance | null;
25
+ }
26
+
27
+ let pendingMetadata: NavLinkMetadata | null = null;
28
+
29
+ /**
30
+ * Store metadata from Link's onClick for the next navigate event.
31
+ * Called synchronously in the click handler — the navigate event
32
+ * fires synchronously after onClick returns.
33
+ */
34
+ export function setNavLinkMetadata(metadata: NavLinkMetadata): void {
35
+ pendingMetadata = metadata;
36
+ }
37
+
38
+ /**
39
+ * Consume the stored metadata. Returns null if no Link onClick
40
+ * preceded this navigation (e.g., plain <a> tag, programmatic nav).
41
+ * Clears the store after reading.
42
+ */
43
+ export function consumeNavLinkMetadata(): NavLinkMetadata | null {
44
+ const metadata = pendingMetadata;
45
+ pendingMetadata = null;
46
+ return metadata;
47
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * Ambient type declarations for the Navigation API.
3
+ *
4
+ * The Navigation API is not yet in TypeScript's standard lib. These types
5
+ * are used internally via type assertions — we never import Navigation API
6
+ * types unconditionally. Progressive enhancement only: the API is feature-
7
+ * detected at runtime.
8
+ *
9
+ * See https://developer.mozilla.org/en-US/docs/Web/API/Navigation_API
10
+ */
11
+
12
+ // ─── Navigation Entry ────────────────────────────────────────────
13
+
14
+ export interface NavigationHistoryEntry {
15
+ readonly key: string;
16
+ readonly id: string;
17
+ readonly url: string | null;
18
+ readonly index: number;
19
+ readonly sameDocument: boolean;
20
+ getState(): unknown;
21
+ addEventListener(type: string, listener: EventListener): void;
22
+ removeEventListener(type: string, listener: EventListener): void;
23
+ }
24
+
25
+ // ─── Navigation Destination ──────────────────────────────────────
26
+
27
+ export interface NavigationDestination {
28
+ readonly url: string;
29
+ readonly key: string | null;
30
+ readonly id: string | null;
31
+ readonly index: number;
32
+ readonly sameDocument: boolean;
33
+ getState(): unknown;
34
+ }
35
+
36
+ // ─── Navigate Event ──────────────────────────────────────────────
37
+
38
+ export interface NavigateEvent extends Event {
39
+ readonly navigationType: 'push' | 'replace' | 'reload' | 'traverse';
40
+ readonly destination: NavigationDestination;
41
+ readonly canIntercept: boolean;
42
+ readonly userInitiated: boolean;
43
+ readonly hashChange: boolean;
44
+ readonly signal: AbortSignal;
45
+ readonly formData: FormData | null;
46
+ readonly downloadRequest: string | null;
47
+ readonly info: unknown;
48
+ intercept(options?: NavigateInterceptOptions): void;
49
+ scroll(): void;
50
+ }
51
+
52
+ export interface NavigateInterceptOptions {
53
+ handler?: () => Promise<void>;
54
+ focusReset?: 'after-transition' | 'manual';
55
+ scroll?: 'after-transition' | 'manual';
56
+ }
57
+
58
+ // ─── Navigation Transition ───────────────────────────────────────
59
+
60
+ export interface NavigationTransition {
61
+ readonly navigationType: 'push' | 'replace' | 'reload' | 'traverse';
62
+ readonly from: NavigationHistoryEntry;
63
+ readonly finished: Promise<void>;
64
+ }
65
+
66
+ // ─── Navigation Result ───────────────────────────────────────────
67
+
68
+ export interface NavigationResult {
69
+ committed: Promise<NavigationHistoryEntry>;
70
+ finished: Promise<NavigationHistoryEntry>;
71
+ }
72
+
73
+ // ─── Navigation Interface ────────────────────────────────────────
74
+
75
+ export interface NavigationApi {
76
+ readonly currentEntry: NavigationHistoryEntry | null;
77
+ readonly transition: NavigationTransition | null;
78
+ readonly canGoBack: boolean;
79
+ readonly canGoForward: boolean;
80
+ entries(): NavigationHistoryEntry[];
81
+ navigate(url: string, options?: NavigationNavigateOptions): NavigationResult;
82
+ reload(options?: NavigationReloadOptions): NavigationResult;
83
+ traverseTo(key: string, options?: NavigationOptions): NavigationResult;
84
+ back(options?: NavigationOptions): NavigationResult;
85
+ forward(options?: NavigationOptions): NavigationResult;
86
+ updateCurrentEntry(options: NavigationUpdateCurrentEntryOptions): void;
87
+ addEventListener(type: 'navigate', listener: (event: NavigateEvent) => void): void;
88
+ addEventListener(type: 'navigatesuccess', listener: (event: Event) => void): void;
89
+ addEventListener(type: 'navigateerror', listener: (event: Event) => void): void;
90
+ addEventListener(type: 'currententrychange', listener: (event: Event) => void): void;
91
+ addEventListener(type: string, listener: EventListener): void;
92
+ removeEventListener(type: string, listener: EventListener): void;
93
+ }
94
+
95
+ export interface NavigationNavigateOptions {
96
+ state?: unknown;
97
+ history?: 'auto' | 'push' | 'replace';
98
+ info?: unknown;
99
+ }
100
+
101
+ export interface NavigationReloadOptions {
102
+ state?: unknown;
103
+ info?: unknown;
104
+ }
105
+
106
+ export interface NavigationOptions {
107
+ info?: unknown;
108
+ }
109
+
110
+ export interface NavigationUpdateCurrentEntryOptions {
111
+ state: unknown;
112
+ }
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Navigation API integration — progressive enhancement for client navigation.
3
+ *
4
+ * When the Navigation API (`window.navigation`) is available, this module
5
+ * provides an intercept-based navigation model that replaces the separate
6
+ * popstate + click handler approach with a single navigate event listener.
7
+ *
8
+ * Key benefits:
9
+ * - Intercepts ALL navigations (link clicks, form submissions, back/forward)
10
+ * - Built-in AbortSignal per navigation (auto-aborts in-flight fetches)
11
+ * - Per-entry state via NavigationHistoryEntry.getState()
12
+ * - navigation.transition for progress tracking
13
+ *
14
+ * When unavailable, all functions are no-ops and the History API fallback
15
+ * in browser-entry.ts handles navigation.
16
+ *
17
+ * See design/19-client-navigation.md
18
+ */
19
+
20
+ import type { NavigationApi, NavigateEvent } from './navigation-api-types.js';
21
+ import { consumeNavLinkMetadata } from './nav-link-store.js';
22
+
23
+ // ─── Feature Detection ───────────────────────────────────────────
24
+
25
+ /**
26
+ * Returns true if the Navigation API is available in the current environment.
27
+ * Feature-detected at runtime — no polyfill.
28
+ */
29
+ export function hasNavigationApi(): boolean {
30
+ return (
31
+ typeof window !== 'undefined' &&
32
+ 'navigation' in window &&
33
+ (window as unknown as { navigation: unknown }).navigation != null
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Get the Navigation API instance. Returns null if unavailable.
39
+ * Uses type assertion — we never import Navigation API types unconditionally.
40
+ */
41
+ export function getNavigationApi(): NavigationApi | null {
42
+ if (!hasNavigationApi()) return null;
43
+ return (window as unknown as { navigation: NavigationApi }).navigation;
44
+ }
45
+
46
+ // ─── Navigation API Controller ───────────────────────────────────
47
+
48
+ /**
49
+ * Callbacks for the Navigation API event handler.
50
+ *
51
+ * When the Navigation API intercepts a navigation, it delegates to these
52
+ * callbacks which run the RSC fetch + render pipeline.
53
+ */
54
+ export interface NavigationApiCallbacks {
55
+ /**
56
+ * Handle a push/replace navigation intercepted by the Navigation API.
57
+ * This covers both Link <a> clicks (user-initiated, with metadata from
58
+ * nav-link-store) and external navigations (plain <a> tags, programmatic).
59
+ * The Navigation API handles the URL update via event.intercept().
60
+ */
61
+ onExternalNavigate: (
62
+ url: string,
63
+ options: { replace: boolean; signal: AbortSignal; scroll?: boolean }
64
+ ) => Promise<void>;
65
+
66
+ /**
67
+ * Handle a traversal (back/forward button). The Navigation API intercepts
68
+ * the traversal and delegates to us for RSC replay/fetch.
69
+ */
70
+ onTraverse: (url: string, scrollY: number, signal: AbortSignal) => Promise<void>;
71
+ }
72
+
73
+ /**
74
+ * Controller returned by setupNavigationApi. Provides methods to
75
+ * coordinate between the router and the navigate event listener.
76
+ */
77
+ export interface NavigationApiController {
78
+ /**
79
+ * Set the router-navigating flag. When `true`, the next navigate event
80
+ * (from pushState/replaceState) is recognized as router-initiated. The
81
+ * handler still intercepts it — but ties the browser's native loading
82
+ * state to a deferred promise instead of running the RSC pipeline again.
83
+ *
84
+ * This means `navigation.transition` is active for the full duration of
85
+ * every router-initiated navigation, giving the browser a native loading
86
+ * indicator (tab spinner, address bar) aligned with the TopLoader.
87
+ *
88
+ * Must be called synchronously around pushState/replaceState:
89
+ * controller.setRouterNavigating(true);
90
+ * history.pushState(...); // navigate event fires, intercepted
91
+ * controller.setRouterNavigating(false); // flag off, deferred stays open
92
+ */
93
+ setRouterNavigating: (value: boolean) => void;
94
+
95
+ /**
96
+ * Resolve the deferred promise created by setRouterNavigating(true),
97
+ * clearing the browser's native loading state. Call this when the
98
+ * navigation fully completes — aligned with when the TopLoader's
99
+ * pendingUrl clears (same finally block in router.navigate).
100
+ */
101
+ completeRouterNavigation: () => void;
102
+
103
+ /**
104
+ * Initiate a navigation via the Navigation API (`navigation.navigate()`).
105
+ * Unlike `history.pushState()`, this fires the navigate event BEFORE
106
+ * committing the URL — allowing Chrome to show its native loading
107
+ * indicator while the intercept handler runs.
108
+ *
109
+ * Must be called with setRouterNavigating(true) active so the handler
110
+ * recognizes it as router-initiated and uses the deferred promise.
111
+ */
112
+ navigate: (url: string, replace: boolean) => void;
113
+
114
+ /**
115
+ * Save scroll position into the current navigation entry's state.
116
+ * Uses navigation.updateCurrentEntry() for per-entry scroll storage.
117
+ */
118
+ saveScrollPosition: (scrollY: number) => void;
119
+
120
+ /**
121
+ * Check if the Navigation API has an active transition.
122
+ * Returns the transition object if available, null otherwise.
123
+ */
124
+ hasActiveTransition: () => boolean;
125
+
126
+ /** Remove the navigate event listener. */
127
+ cleanup: () => void;
128
+ }
129
+
130
+ /**
131
+ * Set up the Navigation API navigate event listener.
132
+ *
133
+ * Intercepts same-origin navigations and delegates to the provided callbacks.
134
+ * Router-initiated navigations (pushState from router.navigate) are detected
135
+ * via a synchronous flag and NOT intercepted — the router already handles them.
136
+ *
137
+ * Returns a controller for coordinating with the router.
138
+ */
139
+ export function setupNavigationApi(callbacks: NavigationApiCallbacks): NavigationApiController {
140
+ const nav = getNavigationApi()!;
141
+
142
+ let routerNavigating = false;
143
+
144
+ // Deferred promise for router-initiated navigations. Created when
145
+ // setRouterNavigating(true) is called, resolved by completeRouterNavigation().
146
+ // The navigate event handler intercepts with this promise so the browser's
147
+ // native loading state (tab spinner) stays active until the navigation
148
+ // completes — aligned with TopLoader's pendingUrl lifecycle.
149
+ let routerNavDeferred: { promise: Promise<void>; resolve: () => void } | null = null;
150
+
151
+ function handleNavigate(event: NavigateEvent): void {
152
+ // Skip non-interceptable navigations (cross-origin, etc.)
153
+ if (!event.canIntercept) return;
154
+
155
+ // Skip download requests
156
+ if (event.downloadRequest) return;
157
+
158
+ // Skip hash-only changes — let the browser handle scroll-to-anchor
159
+ if (event.hashChange) return;
160
+
161
+ // Shallow URL updates (e.g., nuqs search param changes). The navigation
162
+ // only changes the URL — no server round trip needed. Intercept with a
163
+ // no-op handler so the Navigation API commits the URL change without
164
+ // triggering a full page navigation (which is the default if we don't
165
+ // intercept). The info property is the Navigation API's built-in
166
+ // per-navigation metadata — no side-channel flags needed.
167
+ const info = event.info as { shallow?: boolean } | null | undefined;
168
+ if (info?.shallow) {
169
+ event.intercept({
170
+ handler: () => Promise.resolve(),
171
+ focusReset: 'manual',
172
+ scroll: 'manual',
173
+ });
174
+ return;
175
+ }
176
+
177
+ // Skip form submissions with a body (POST/PUT/etc.). These need the
178
+ // browser's native form handling to send the request body to the server.
179
+ // Intercepting would convert them into GET RSC navigations, dropping
180
+ // the form data. Server actions use fetch() directly (not form navigation),
181
+ // so they are unaffected by this check.
182
+ if (event.formData) return;
183
+
184
+ // Skip cross-origin (defense-in-depth — canIntercept covers this)
185
+ const destUrl = new URL(event.destination.url);
186
+ if (destUrl.origin !== location.origin) return;
187
+
188
+ // Router-initiated navigation (Link click → router.navigate → pushState).
189
+ // The router is already running the RSC pipeline — don't run it again.
190
+ // Instead, intercept with the deferred promise so the browser's native
191
+ // loading state tracks the navigation's full lifecycle. This aligns the
192
+ // tab spinner / address bar indicator with the TopLoader.
193
+ if (routerNavigating && routerNavDeferred) {
194
+ event.intercept({
195
+ scroll: 'manual',
196
+ focusReset: 'manual',
197
+ handler: () => routerNavDeferred!.promise,
198
+ });
199
+ return;
200
+ }
201
+
202
+ // Skip reload navigations — let the browser handle full page reload
203
+ if (event.navigationType === 'reload') return;
204
+
205
+ const url = destUrl.pathname + destUrl.search;
206
+
207
+ if (event.navigationType === 'traverse') {
208
+ // Back/forward button — intercept and delegate to router.
209
+ // Read scroll position from the destination entry's state.
210
+ const entryState = event.destination.getState() as
211
+ | { scrollY?: number; timber?: boolean }
212
+ | null
213
+ | undefined;
214
+ const scrollY = entryState && typeof entryState.scrollY === 'number' ? entryState.scrollY : 0;
215
+
216
+ event.intercept({
217
+ // Manual scroll — we handle scroll restoration ourselves
218
+ // via afterPaint (same as the History API path).
219
+ scroll: 'manual',
220
+ focusReset: 'manual',
221
+ async handler() {
222
+ await callbacks.onTraverse(url, scrollY, event.signal);
223
+ },
224
+ });
225
+ } else if (event.navigationType === 'push' || event.navigationType === 'replace') {
226
+ // Push/replace — either a Link <a> click (with metadata in
227
+ // nav-link-store) or an external navigation (plain <a>, programmatic).
228
+ // Consume link metadata if present — tells us scroll preference
229
+ // and which Link component to track pending state for.
230
+ const linkMeta = consumeNavLinkMetadata();
231
+
232
+ event.intercept({
233
+ scroll: 'manual',
234
+ focusReset: 'manual',
235
+ async handler() {
236
+ await callbacks.onExternalNavigate(url, {
237
+ replace: event.navigationType === 'replace',
238
+ signal: event.signal,
239
+ scroll: linkMeta?.scroll,
240
+ });
241
+ },
242
+ });
243
+ }
244
+ }
245
+
246
+ nav.addEventListener('navigate', handleNavigate as EventListener);
247
+
248
+ return {
249
+ setRouterNavigating(value: boolean): void {
250
+ routerNavigating = value;
251
+ if (value) {
252
+ // Create a new deferred promise. The navigate event handler will
253
+ // intercept and tie the browser's loading state to this promise.
254
+ let resolve!: () => void;
255
+ const promise = new Promise<void>((r) => {
256
+ resolve = r;
257
+ });
258
+ routerNavDeferred = { promise, resolve };
259
+ } else {
260
+ // Flag off — but DON'T resolve the deferred here. The navigation
261
+ // is still in flight (RSC fetch + render). completeRouterNavigation()
262
+ // resolves it when the navigation fully completes.
263
+ routerNavigating = false;
264
+ }
265
+ },
266
+
267
+ completeRouterNavigation(): void {
268
+ if (routerNavDeferred) {
269
+ routerNavDeferred.resolve();
270
+ routerNavDeferred = null;
271
+ }
272
+ },
273
+
274
+ navigate(url: string, replace: boolean): void {
275
+ // Use navigation.navigate() instead of history.pushState().
276
+ // This fires the navigate event BEFORE committing the URL,
277
+ // which lets Chrome show its native loading indicator while
278
+ // the intercept handler (deferred promise) is pending.
279
+ // history.pushState() commits the URL synchronously, so Chrome
280
+ // sees the navigation as already complete and skips the indicator.
281
+ nav.navigate(url, {
282
+ history: replace ? 'replace' : 'push',
283
+ });
284
+ },
285
+
286
+ saveScrollPosition(scrollY: number): void {
287
+ try {
288
+ const currentState = (nav.currentEntry?.getState() ?? {}) as Record<string, unknown>;
289
+ nav.updateCurrentEntry({
290
+ state: { ...currentState, timber: true, scrollY },
291
+ });
292
+ } catch {
293
+ // Ignore errors — updateCurrentEntry may throw if entry is disposed
294
+ }
295
+ },
296
+
297
+ hasActiveTransition(): boolean {
298
+ return nav.transition != null;
299
+ },
300
+
301
+ cleanup(): void {
302
+ nav.removeEventListener('navigate', handleNavigate as EventListener);
303
+ },
304
+ };
305
+ }
@@ -218,3 +218,23 @@ export function PendingNavigationProvider({
218
218
  }
219
219
  return createElement(ctx.Provider, { value }, children);
220
220
  }
221
+
222
+ // ---------------------------------------------------------------------------
223
+ // Navigation API transition state (optional progressive enhancement)
224
+ // ---------------------------------------------------------------------------
225
+
226
+ /**
227
+ * Check if the browser's Navigation API has an active transition.
228
+ *
229
+ * When the Navigation API is available and a navigation has been intercepted
230
+ * via event.intercept(), `navigation.transition` is non-null until the
231
+ * handler resolves. This provides browser-native progress tracking that
232
+ * can be used alongside the existing pendingUrl mechanism.
233
+ *
234
+ * Returns false when Navigation API is unavailable or no transition is active.
235
+ */
236
+ export function hasNativeNavigationTransition(): boolean {
237
+ if (typeof window === 'undefined') return false;
238
+ const nav = (window as unknown as { navigation?: { transition?: unknown } }).navigation;
239
+ return nav?.transition != null;
240
+ }
@@ -20,6 +20,7 @@ import {
20
20
  type unstable_AdapterOptions as AdapterOptions,
21
21
  } from 'nuqs/adapters/custom';
22
22
  import { getRouter } from './router-ref.js';
23
+ import { getNavigationApi } from './navigation-api.js';
23
24
 
24
25
  // ─── Adapter Hook ─────────────────────────────────────────────────
25
26
 
@@ -59,9 +60,21 @@ function useTimberAdapter(_watchKeys: string[]): AdapterInterface {
59
60
 
60
61
  if (options.shallow) {
61
62
  // Shallow: update URL only, no server roundtrip.
62
- const method =
63
- options.history === 'push' ? window.history.pushState : window.history.replaceState;
64
- method.call(window.history, window.history.state, '', url.toString());
63
+ // When Navigation API is available, use navigation.navigate() with
64
+ // info: { shallow: true } so the navigate event handler knows to
65
+ // skip interception. Without this, the navigate event fired by
66
+ // history.pushState/replaceState would trigger a full RSC fetch.
67
+ const nav = getNavigationApi();
68
+ if (nav) {
69
+ nav.navigate(url.toString(), {
70
+ history: options.history === 'push' ? 'push' : 'replace',
71
+ info: { shallow: true },
72
+ });
73
+ } else {
74
+ const method =
75
+ options.history === 'push' ? window.history.pushState : window.history.replaceState;
76
+ method.call(window.history, window.history.state, '', url.toString());
77
+ }
65
78
 
66
79
  // Update local state to reflect the new URL
67
80
  setSearchParams(new URLSearchParams(url.search));