@timber-js/app 0.1.22 → 0.1.24

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.
@@ -18,10 +18,11 @@
18
18
  * (populated by ssr-entry.ts) to ensure correct per-request isolation
19
19
  * across concurrent requests with streaming Suspense.
20
20
  *
21
- * Reactivity: useParams() uses useSyncExternalStore so that components
22
- * in unchanged layouts (e.g., sidebar items) re-render atomically when
23
- * params change during client-side navigation. This matches the pattern
24
- * used by usePathname() and useSearchParams().
21
+ * Reactivity: On the client, useParams() reads from NavigationContext
22
+ * which is updated atomically with the RSC tree render. This replaces
23
+ * the previous useSyncExternalStore approach that suffered from a
24
+ * timing gap between tree render and store notification — causing
25
+ * preserved layout components to briefly show stale active state.
25
26
  *
26
27
  * All mutable state is delegated to client/state.ts for singleton guarantees.
27
28
  * See design/18-build-system.md §"Singleton State Registry"
@@ -30,37 +31,40 @@
30
31
  */
31
32
  import type { Routes } from '#/index.js';
32
33
  /**
33
- * Subscribe to params changes. Called by useSyncExternalStore.
34
- * Exported for testing not intended for direct use by app code.
34
+ * Subscribe to params changes.
35
+ * Retained for backward compatibility with tests that verify the
36
+ * subscribe/notify contract. On the client, useParams() reads from
37
+ * NavigationContext instead.
35
38
  */
36
39
  export declare function subscribe(callback: () => void): () => void;
37
40
  /**
38
- * Get the current params snapshot (client).
39
- * Exported for testing not intended for direct use by app code.
41
+ * Get the current params snapshot (module-level fallback).
42
+ * Used by tests and by the hook when called outside a React component.
40
43
  */
41
44
  export declare function getSnapshot(): Record<string, string | string[]>;
42
45
  /**
43
- * Set the current route params WITHOUT notifying subscribers.
44
- * Called by the router before renderPayload() so that new components
45
- * in the RSC tree see the updated params via getSnapshot(), but
46
- * preserved layout components don't re-render prematurely with
47
- * {old tree, new params}.
46
+ * Set the current route params in the module-level store.
48
47
  *
49
- * After the React render commits, the router calls notifyParamsListeners()
50
- * to trigger re-renders in preserved layouts that read useParams().
48
+ * Called by the router on each navigation. This updates the fallback
49
+ * snapshot used by tests and by the hook when called outside a React
50
+ * component (no NavigationContext available).
51
+ *
52
+ * On the client, the primary reactivity path is NavigationContext —
53
+ * the router calls setNavigationState() then renderRoot() which wraps
54
+ * the element in NavigationProvider. setCurrentParams is still called
55
+ * for the module-level fallback.
51
56
  *
52
- * On the client, the segment router calls this on each navigation.
53
57
  * During SSR, params are also available via getSsrData().params
54
- * (ALS-backed), but setCurrentParams is still called for the
55
- * module-level fallback path.
58
+ * (ALS-backed).
56
59
  */
57
60
  export declare function setCurrentParams(params: Record<string, string | string[]>): void;
58
61
  /**
59
- * Notify all useSyncExternalStore subscribers that params have changed.
60
- * Called by the router AFTER renderPayload() so that preserved layout
61
- * components re-render only after the new tree is committed — producing
62
- * an atomic {new tree, new params} update instead of a stale
63
- * {old tree, new params} intermediate state.
62
+ * Notify all legacy subscribers that params have changed.
63
+ *
64
+ * Retained for backward compatibility with tests. On the client,
65
+ * the NavigationContext + renderRoot pattern replaces this params
66
+ * update atomically with the tree render, so explicit notification
67
+ * is no longer needed.
64
68
  */
65
69
  export declare function notifyParamsListeners(): void;
66
70
  /**
@@ -69,9 +73,15 @@ export declare function notifyParamsListeners(): void;
69
73
  * The optional `_route` argument exists only for TypeScript narrowing —
70
74
  * it does not affect the runtime return value.
71
75
  *
76
+ * On the client, reads from NavigationContext (provided by
77
+ * NavigationProvider in renderRoot). This ensures params update
78
+ * atomically with the RSC tree — no timing gap.
79
+ *
72
80
  * During SSR, reads from the ALS-backed SSR data context to ensure
73
- * per-request isolation. On the client, subscribes to the module-level
74
- * params store via useSyncExternalStore.
81
+ * per-request isolation across concurrent requests with streaming Suspense.
82
+ *
83
+ * When called outside a React component (e.g., in test assertions),
84
+ * falls back to the module-level snapshot.
75
85
  *
76
86
  * @overload Typed — when a known route path is passed, returns the
77
87
  * exact params shape from the generated Routes interface.
@@ -1 +1 @@
1
- {"version":3,"file":"use-params.d.ts","sourceRoot":"","sources":["../../src/client/use-params.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAGH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AAQzC;;;GAGG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAG1D;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAE/D;AAeD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAEhF;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAI5C;AAMD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AACjF,wBAAgB,SAAS,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC"}
1
+ {"version":3,"file":"use-params.d.ts","sourceRoot":"","sources":["../../src/client/use-params.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC;AASzC;;;;;GAKG;AACH,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,IAAI,GAAG,MAAM,IAAI,CAG1D;AAED;;;GAGG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAE/D;AAMD;;;;;;;;;;;;;;GAcG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAAG,IAAI,CAEhF;AAED;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,IAAI,IAAI,CAI5C;AAMD;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAgB,SAAS,CAAC,CAAC,SAAS,MAAM,MAAM,EAAE,KAAK,EAAE,CAAC,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,QAAQ,CAAC,CAAC;AACjF,wBAAgB,SAAS,CAAC,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,CAAC"}
@@ -4,17 +4,24 @@
4
4
  * Returns the pathname portion of the current URL (e.g. '/dashboard/settings').
5
5
  * Updates when client-side navigation changes the URL.
6
6
  *
7
- * This is a thin wrapper over window.location.pathname, provided for
8
- * Next.js API compatibility (libraries like nuqs import usePathname
9
- * from next/navigation).
7
+ * On the client, reads from NavigationContext which is updated atomically
8
+ * with the RSC tree render. This replaces the previous useSyncExternalStore
9
+ * approach which only subscribed to popstate events — meaning usePathname()
10
+ * did NOT re-render on forward navigation (pushState). The context approach
11
+ * fixes this: pathname updates in the same render pass as the new tree.
10
12
  *
11
13
  * During SSR, reads the request pathname from the SSR ALS context
12
14
  * (populated by ssr-entry.ts) instead of window.location.
15
+ *
16
+ * Compatible with Next.js's `usePathname()` from `next/navigation`.
13
17
  */
14
18
  /**
15
19
  * Read the current URL pathname.
16
20
  *
17
- * Compatible with Next.js's `usePathname()` from `next/navigation`.
21
+ * On the client, reads from NavigationContext (provided by
22
+ * NavigationProvider in renderRoot). During SSR, reads from the
23
+ * ALS-backed SSR data context. Falls back to window.location.pathname
24
+ * when called outside a React component (e.g., in tests).
18
25
  */
19
26
  export declare function usePathname(): string;
20
27
  //# sourceMappingURL=use-pathname.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"use-pathname.d.ts","sourceRoot":"","sources":["../../src/client/use-pathname.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AAuBH;;;;GAIG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAEpC"}
1
+ {"version":3,"file":"use-pathname.d.ts","sourceRoot":"","sources":["../../src/client/use-pathname.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH;;;;;;;GAOG;AACH,wBAAgB,WAAW,IAAI,MAAM,CAoBpC"}
@@ -1,7 +1,7 @@
1
1
  import "../_chunks/als-registry-c0AGnbqS.js";
2
2
  import { n as cookies } from "../_chunks/request-context-C69VW4xS.js";
3
- import "../_chunks/ssr-data-B2yikEEB.js";
4
- import { t as useCookie } from "../_chunks/use-cookie-D5aS4slY.js";
3
+ import "../_chunks/ssr-data-DLnbYpj1.js";
4
+ import { t as useCookie } from "../_chunks/use-cookie-dDbpCTx-.js";
5
5
  //#region src/cookies/define-cookie.ts
6
6
  /**
7
7
  * defineCookie — typed cookie definitions.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timber-js/app",
3
- "version": "0.1.22",
3
+ "version": "0.1.24",
4
4
  "description": "Vite-native React framework for Cloudflare Workers — correct HTTP semantics, real status codes, pages that work without JavaScript",
5
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -50,6 +50,7 @@ import { applyHeadElements } from './head.js';
50
50
  import { TimberNuqsAdapter } from './nuqs-adapter.js';
51
51
  import { isPageUnloading } from './unload-guard.js';
52
52
  import { ON_NAVIGATE_KEY } from './link-navigate-interceptor.js';
53
+ import { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context.js';
53
54
 
54
55
  // ─── Server Action Dispatch ──────────────────────────────────────
55
56
 
@@ -275,10 +276,33 @@ function bootstrap(runtimeConfig: typeof config): void {
275
276
  // it's safe to create the router before reactRoot is assigned.
276
277
  initRouter();
277
278
 
279
+ // ── Initialize navigation state BEFORE hydration ───────────────────
280
+ // Read server-embedded params and set navigation state so that
281
+ // useParams() and usePathname() return correct values during hydration.
282
+ // This must happen before hydrateRoot so the NavigationProvider
283
+ // wrapping the element has the right values on the initial render.
284
+ const earlyParams = (self as unknown as Record<string, unknown>).__timber_params;
285
+ if (earlyParams && typeof earlyParams === 'object') {
286
+ setCurrentParams(earlyParams as Record<string, string | string[]>);
287
+ setNavigationState({
288
+ params: earlyParams as Record<string, string | string[]>,
289
+ pathname: window.location.pathname,
290
+ });
291
+ delete (self as unknown as Record<string, unknown>).__timber_params;
292
+ } else {
293
+ setNavigationState({
294
+ params: {},
295
+ pathname: window.location.pathname,
296
+ });
297
+ }
298
+
278
299
  // Hydrate on document — the root layout renders the full <html> tree,
279
300
  // so React owns the entire document from the root.
280
- // Wrap with TimberNuqsAdapter so useQueryStates works out of the box.
281
- const wrapped = createElement(TimberNuqsAdapter, null, element as React.ReactNode);
301
+ // Wrap with NavigationProvider (for atomic useParams/usePathname) and
302
+ // TimberNuqsAdapter (for nuqs context).
303
+ const navState = getNavigationState();
304
+ const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
305
+ const wrapped = createElement(TimberNuqsAdapter, null, withNav);
282
306
  reactRoot = hydrateRoot(document, wrapped, {
283
307
  // Suppress recoverable hydration errors from deny/error signals
284
308
  // inside Suspense boundaries. The server already handled these
@@ -336,11 +360,22 @@ function bootstrap(runtimeConfig: typeof config): void {
336
360
  },
337
361
 
338
362
  // Render decoded RSC tree into the hydrated React root.
339
- // Wrap with TimberNuqsAdapter to maintain nuqs context across navigations.
340
- // Reads `reactRoot` from the outer closure assigned after hydrateRoot().
363
+ // Wraps with NavigationProvider (for atomic useParams/usePathname updates)
364
+ // and TimberNuqsAdapter (for nuqs context). Reads `reactRoot` and
365
+ // navigation state from closures — both set before this callback fires.
366
+ //
367
+ // The router calls setNavigationState() before renderRoot(), so
368
+ // getNavigationState() returns the new params/pathname. By wrapping
369
+ // the element in NavigationProvider here, the context value and the
370
+ // RSC tree are passed to reactRoot.render() in the same call —
371
+ // making the update atomic. Preserved layout components that call
372
+ // useParams() or usePathname() re-render in the same pass as the
373
+ // new tree, preventing the dual-active-state flash.
341
374
  renderRoot: (element: unknown) => {
342
375
  if (reactRoot) {
343
- const wrapped = createElement(TimberNuqsAdapter, null, element as React.ReactNode);
376
+ const navState = getNavigationState();
377
+ const withNav = createElement(NavigationProvider, { value: navState }, element as React.ReactNode);
378
+ const wrapped = createElement(TimberNuqsAdapter, null, withNav);
344
379
  reactRoot.render(wrapped);
345
380
  }
346
381
  },
@@ -384,11 +419,16 @@ function bootstrap(runtimeConfig: typeof config): void {
384
419
  delete (self as unknown as Record<string, unknown>).__timber_segments;
385
420
  }
386
421
 
387
- // Populate useParams() from server-embedded route params.
388
- // Without this, useParams() returns {} until the first client navigation.
389
- const timberParams = (self as unknown as Record<string, unknown>).__timber_params;
390
- if (timberParams && typeof timberParams === 'object') {
391
- setCurrentParams(timberParams as Record<string, string | string[]>);
422
+ // Note: __timber_params is read before hydrateRoot (see above) so that
423
+ // NavigationProvider has correct values during hydration. If the hydration
424
+ // path was skipped (no RSC payload), populate the fallback here.
425
+ const lateTimberParams = (self as unknown as Record<string, unknown>).__timber_params;
426
+ if (lateTimberParams && typeof lateTimberParams === 'object') {
427
+ setCurrentParams(lateTimberParams as Record<string, string | string[]>);
428
+ setNavigationState({
429
+ params: lateTimberParams as Record<string, string | string[]>,
430
+ pathname: window.location.pathname,
431
+ });
392
432
  delete (self as unknown as Record<string, unknown>).__timber_params;
393
433
  }
394
434
 
@@ -44,6 +44,10 @@ export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from '.
44
44
  // Params
45
45
  export { useParams, setCurrentParams } from './use-params';
46
46
 
47
+ // Navigation context (framework-internal, used by browser-entry for atomic updates)
48
+ export { NavigationProvider, NavigationContext, getNavigationState, setNavigationState } from './navigation-context';
49
+ export type { NavigationState } from './navigation-context';
50
+
47
51
  // Query states (URL-synced search params)
48
52
  export { useQueryStates, bindUseQueryStates } from './use-query-states';
49
53
 
@@ -0,0 +1,88 @@
1
+ /**
2
+ * NavigationContext — React context for navigation state.
3
+ *
4
+ * Holds the current route params and pathname, updated atomically
5
+ * with the RSC tree on each navigation. This replaces the previous
6
+ * useSyncExternalStore approach for useParams() and usePathname(),
7
+ * which suffered from a timing gap: the new tree could commit before
8
+ * the external store re-renders fired, causing a frame where both
9
+ * old and new active states were visible simultaneously.
10
+ *
11
+ * By wrapping the RSC payload element in NavigationProvider inside
12
+ * renderRoot(), the context value and the element tree are passed to
13
+ * reactRoot.render() in the same call — atomic by construction.
14
+ * All consumers (useParams, usePathname) see the new values in the
15
+ * same render pass as the new tree.
16
+ *
17
+ * During SSR, no NavigationProvider is mounted. Hooks fall back to
18
+ * the ALS-backed getSsrData() for per-request isolation.
19
+ *
20
+ * See design/19-client-navigation.md §"Navigation Flow"
21
+ */
22
+
23
+ import { createContext, useContext, createElement, type ReactNode } from 'react';
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Context type and creation
27
+ // ---------------------------------------------------------------------------
28
+
29
+ export interface NavigationState {
30
+ params: Record<string, string | string[]>;
31
+ pathname: string;
32
+ }
33
+
34
+ /**
35
+ * The context value is null when no provider is mounted (SSR).
36
+ * On the client, NavigationProvider always wraps the tree.
37
+ */
38
+ export const NavigationContext = createContext<NavigationState | null>(null);
39
+
40
+ /**
41
+ * Read the navigation context. Returns null during SSR (no provider).
42
+ * Internal — used by useParams() and usePathname().
43
+ */
44
+ export function useNavigationContext(): NavigationState | null {
45
+ return useContext(NavigationContext);
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Provider component
50
+ // ---------------------------------------------------------------------------
51
+
52
+ export interface NavigationProviderProps {
53
+ value: NavigationState;
54
+ children?: ReactNode;
55
+ }
56
+
57
+ /**
58
+ * Wraps children with NavigationContext.Provider.
59
+ *
60
+ * Used in browser-entry.ts renderRoot to wrap the RSC payload element
61
+ * so that navigation state updates atomically with the tree render.
62
+ */
63
+ export function NavigationProvider({ value, children }: NavigationProviderProps): React.ReactElement {
64
+ return createElement(NavigationContext.Provider, { value }, children);
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Module-level state for renderRoot to read
69
+ // ---------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Module-level navigation state. Updated by the router before calling
73
+ * renderRoot(). The renderRoot callback reads this to create the
74
+ * NavigationProvider with the correct values.
75
+ *
76
+ * This is NOT used by hooks directly — hooks read from React context.
77
+ * This exists only as a communication channel between the router
78
+ * (which knows the new nav state) and renderRoot (which wraps the element).
79
+ */
80
+ let _currentNavState: NavigationState = { params: {}, pathname: '/' };
81
+
82
+ export function setNavigationState(state: NavigationState): void {
83
+ _currentNavState = state;
84
+ }
85
+
86
+ export function getNavigationState(): NavigationState {
87
+ return _currentNavState;
88
+ }
@@ -5,7 +5,8 @@ import { SegmentCache, PrefetchCache, buildSegmentTree } from './segment-cache';
5
5
  import type { SegmentInfo } from './segment-cache';
6
6
  import { HistoryStack } from './history';
7
7
  import type { HeadElement } from './head';
8
- import { setCurrentParams, notifyParamsListeners } from './use-params.js';
8
+ import { setCurrentParams } from './use-params.js';
9
+ import { setNavigationState } from './navigation-context.js';
9
10
 
10
11
  // ─── Types ───────────────────────────────────────────────────────
11
12
 
@@ -324,9 +325,26 @@ export function createRouter(deps: RouterDeps): RouterInstance {
324
325
  }
325
326
  }
326
327
 
327
- /** Update useParams() with route params from the server response. */
328
- function updateParams(params: Record<string, string | string[]> | null | undefined): void {
329
- setCurrentParams(params ?? {});
328
+ /**
329
+ * Update navigation state (params + pathname) for the next render.
330
+ *
331
+ * Sets both the module-level fallback (for tests and SSR) and the
332
+ * navigation context state (read by renderRoot to wrap the element
333
+ * in NavigationProvider). The context update is atomic with the tree
334
+ * render — both are passed to reactRoot.render() in the same call.
335
+ */
336
+ function updateNavigationState(
337
+ params: Record<string, string | string[]> | null | undefined,
338
+ url: string
339
+ ): void {
340
+ const resolvedParams = params ?? {};
341
+ // Module-level fallback for tests (no NavigationProvider) and SSR
342
+ setCurrentParams(resolvedParams);
343
+ // Navigation context — read by renderRoot to wrap the RSC element
344
+ const pathname = url.startsWith('http')
345
+ ? new URL(url).pathname
346
+ : url.split('?')[0] || '/';
347
+ setNavigationState({ params: resolvedParams, pathname });
330
348
  }
331
349
 
332
350
  /** Apply head elements (title, meta tags) to the DOM if available. */
@@ -393,19 +411,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
393
411
  // header reflects the currently mounted segments.
394
412
  updateSegmentCache(result.segmentInfo);
395
413
 
396
- // Update the params snapshot before rendering so new components in the
397
- // RSC tree see the correct params via getSnapshot(). Subscriber
398
- // notification is deferred until after renderPayload() so preserved
399
- // layouts don't re-render with {old tree, new params}.
400
- updateParams(result.params);
401
-
402
- // Render the decoded RSC tree into the DOM.
414
+ // Update navigation state (params + pathname) before rendering.
415
+ // The renderRoot callback reads this state and wraps the RSC element
416
+ // in NavigationProvider so the context value and the element tree
417
+ // are passed to reactRoot.render() in the same call, making the
418
+ // update atomic. Preserved layouts see new params in the same render
419
+ // pass as the new tree, preventing the dual-active-row flash.
420
+ updateNavigationState(result.params, url);
403
421
  renderPayload(result.payload);
404
422
 
405
- // Now notify useParams() subscribers — preserved layout components
406
- // re-render after the new tree is committed, seeing {new tree, new params}.
407
- notifyParamsListeners();
408
-
409
423
  // Update document.title and <meta> tags with the new page's metadata
410
424
  applyHead(result.headElements);
411
425
 
@@ -464,12 +478,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
464
478
  // Update segment cache with fresh segment info from full render
465
479
  updateSegmentCache(result.segmentInfo);
466
480
 
467
- // Update params snapshot before rendering (see navigate() for rationale)
468
- updateParams(result.params);
469
-
470
- // Render the fresh RSC tree, then notify params subscribers
481
+ // Atomic update see navigate() for rationale on NavigationProvider.
482
+ updateNavigationState(result.params, currentUrl);
471
483
  renderPayload(result.payload);
472
- notifyParamsListeners();
473
484
  applyHead(result.headElements);
474
485
  } finally {
475
486
  setPending(false);
@@ -484,9 +495,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
484
495
 
485
496
  if (entry && entry.payload !== null) {
486
497
  // Replay cached payload — no server roundtrip
487
- updateParams(entry.params);
498
+ updateNavigationState(entry.params, url);
488
499
  renderPayload(entry.payload);
489
- notifyParamsListeners();
490
500
  applyHead(entry.headElements);
491
501
  afterPaint(() => {
492
502
  deps.scrollTo(0, scrollY);
@@ -502,14 +512,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
502
512
  const stateTree = segmentCache.serializeStateTree();
503
513
  const result = await fetchRscPayload(url, deps, stateTree);
504
514
  updateSegmentCache(result.segmentInfo);
505
- updateParams(result.params);
515
+ updateNavigationState(result.params, url);
506
516
  historyStack.push(url, {
507
517
  payload: result.payload,
508
518
  headElements: result.headElements,
509
519
  params: result.params,
510
520
  });
511
521
  renderPayload(result.payload);
512
- notifyParamsListeners();
513
522
  applyHead(result.headElements);
514
523
  afterPaint(() => {
515
524
  deps.scrollTo(0, scrollY);
@@ -18,10 +18,11 @@
18
18
  * (populated by ssr-entry.ts) to ensure correct per-request isolation
19
19
  * across concurrent requests with streaming Suspense.
20
20
  *
21
- * Reactivity: useParams() uses useSyncExternalStore so that components
22
- * in unchanged layouts (e.g., sidebar items) re-render atomically when
23
- * params change during client-side navigation. This matches the pattern
24
- * used by usePathname() and useSearchParams().
21
+ * Reactivity: On the client, useParams() reads from NavigationContext
22
+ * which is updated atomically with the RSC tree render. This replaces
23
+ * the previous useSyncExternalStore approach that suffered from a
24
+ * timing gap between tree render and store notification — causing
25
+ * preserved layout components to briefly show stale active state.
25
26
  *
26
27
  * All mutable state is delegated to client/state.ts for singleton guarantees.
27
28
  * See design/18-build-system.md §"Singleton State Registry"
@@ -29,18 +30,20 @@
29
30
  * Design doc: design/09-typescript.md §"Typed Routes"
30
31
  */
31
32
 
32
- import { useSyncExternalStore } from 'react';
33
33
  import type { Routes } from '#/index.js';
34
34
  import { getSsrData } from './ssr-data.js';
35
35
  import { currentParams, _setCurrentParams, paramsListeners } from './state.js';
36
+ import { useNavigationContext } from './navigation-context.js';
36
37
 
37
38
  // ---------------------------------------------------------------------------
38
- // Module-level subscribe/notify pattern — state lives in state.ts
39
+ // Module-level subscribe/notify pattern — kept for backward compat and tests
39
40
  // ---------------------------------------------------------------------------
40
41
 
41
42
  /**
42
- * Subscribe to params changes. Called by useSyncExternalStore.
43
- * Exported for testing not intended for direct use by app code.
43
+ * Subscribe to params changes.
44
+ * Retained for backward compatibility with tests that verify the
45
+ * subscribe/notify contract. On the client, useParams() reads from
46
+ * NavigationContext instead.
44
47
  */
45
48
  export function subscribe(callback: () => void): () => void {
46
49
  paramsListeners.add(callback);
@@ -48,51 +51,43 @@ export function subscribe(callback: () => void): () => void {
48
51
  }
49
52
 
50
53
  /**
51
- * Get the current params snapshot (client).
52
- * Exported for testing not intended for direct use by app code.
54
+ * Get the current params snapshot (module-level fallback).
55
+ * Used by tests and by the hook when called outside a React component.
53
56
  */
54
57
  export function getSnapshot(): Record<string, string | string[]> {
55
58
  return currentParams;
56
59
  }
57
60
 
58
- /**
59
- * Get the server-side params snapshot (SSR).
60
- * Falls back to the module-level currentParams if no SSR context
61
- * is available (shouldn't happen, but defensive).
62
- */
63
- function getServerSnapshot(): Record<string, string | string[]> {
64
- return getSsrData()?.params ?? currentParams;
65
- }
66
-
67
61
  // ---------------------------------------------------------------------------
68
62
  // Framework API — called by the segment router on each navigation
69
63
  // ---------------------------------------------------------------------------
70
64
 
71
65
  /**
72
- * Set the current route params WITHOUT notifying subscribers.
73
- * Called by the router before renderPayload() so that new components
74
- * in the RSC tree see the updated params via getSnapshot(), but
75
- * preserved layout components don't re-render prematurely with
76
- * {old tree, new params}.
66
+ * Set the current route params in the module-level store.
67
+ *
68
+ * Called by the router on each navigation. This updates the fallback
69
+ * snapshot used by tests and by the hook when called outside a React
70
+ * component (no NavigationContext available).
77
71
  *
78
- * After the React render commits, the router calls notifyParamsListeners()
79
- * to trigger re-renders in preserved layouts that read useParams().
72
+ * On the client, the primary reactivity path is NavigationContext —
73
+ * the router calls setNavigationState() then renderRoot() which wraps
74
+ * the element in NavigationProvider. setCurrentParams is still called
75
+ * for the module-level fallback.
80
76
  *
81
- * On the client, the segment router calls this on each navigation.
82
77
  * During SSR, params are also available via getSsrData().params
83
- * (ALS-backed), but setCurrentParams is still called for the
84
- * module-level fallback path.
78
+ * (ALS-backed).
85
79
  */
86
80
  export function setCurrentParams(params: Record<string, string | string[]>): void {
87
81
  _setCurrentParams(params);
88
82
  }
89
83
 
90
84
  /**
91
- * Notify all useSyncExternalStore subscribers that params have changed.
92
- * Called by the router AFTER renderPayload() so that preserved layout
93
- * components re-render only after the new tree is committed — producing
94
- * an atomic {new tree, new params} update instead of a stale
95
- * {old tree, new params} intermediate state.
85
+ * Notify all legacy subscribers that params have changed.
86
+ *
87
+ * Retained for backward compatibility with tests. On the client,
88
+ * the NavigationContext + renderRoot pattern replaces this params
89
+ * update atomically with the tree render, so explicit notification
90
+ * is no longer needed.
96
91
  */
97
92
  export function notifyParamsListeners(): void {
98
93
  for (const listener of paramsListeners) {
@@ -110,9 +105,15 @@ export function notifyParamsListeners(): void {
110
105
  * The optional `_route` argument exists only for TypeScript narrowing —
111
106
  * it does not affect the runtime return value.
112
107
  *
108
+ * On the client, reads from NavigationContext (provided by
109
+ * NavigationProvider in renderRoot). This ensures params update
110
+ * atomically with the RSC tree — no timing gap.
111
+ *
113
112
  * During SSR, reads from the ALS-backed SSR data context to ensure
114
- * per-request isolation. On the client, subscribes to the module-level
115
- * params store via useSyncExternalStore.
113
+ * per-request isolation across concurrent requests with streaming Suspense.
114
+ *
115
+ * When called outside a React component (e.g., in test assertions),
116
+ * falls back to the module-level snapshot.
116
117
  *
117
118
  * @overload Typed — when a known route path is passed, returns the
118
119
  * exact params shape from the generated Routes interface.
@@ -121,25 +122,20 @@ export function notifyParamsListeners(): void {
121
122
  export function useParams<R extends keyof Routes>(route: R): Routes[R]['params'];
122
123
  export function useParams(route?: string): Record<string, string | string[]>;
123
124
  export function useParams(_route?: string): Record<string, string | string[]> {
124
- // useSyncExternalStore handles both client and SSR:
125
- // - Client: calls getSnapshot() reads currentParams from state.ts
126
- // - SSR: calls getServerSnapshot() reads from ALS-backed getSsrData()
127
- //
128
- // We must always call the hook (Rules of Hooks — no conditional hook calls).
129
- // React picks the right snapshot function based on the environment.
130
- //
131
- // When called outside a React component (e.g., in test assertions),
132
- // useSyncExternalStore throws because there's no dispatcher. In that case,
133
- // fall back to reading the snapshot directly.
125
+ // Try reading from NavigationContext (client-side, inside React tree).
126
+ // During SSR, no NavigationProvider is mounted, so this returns null.
127
+ // When called outside a React component, useContext throws — caught below.
134
128
  try {
135
- return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
129
+ const navContext = useNavigationContext();
130
+ if (navContext !== null) {
131
+ return navContext.params;
132
+ }
136
133
  } catch {
137
- // No React dispatcher available return the best available snapshot.
138
- // This path is hit when useParams() is called outside a component,
139
- // e.g. in test assertions that verify the current params value.
140
- // Use getServerSnapshot() because it checks the ALS-backed SSR context
141
- // first (request-isolated), falling back to module-level currentParams
142
- // only when no SSR context exists (client-side / tests).
143
- return getServerSnapshot();
134
+ // No React dispatcher available (called outside a component).
135
+ // Fall through to module-level snapshot below.
144
136
  }
137
+
138
+ // SSR path: read from ALS-backed SSR data context.
139
+ // Falls back to module-level currentParams for tests.
140
+ return getSsrData()?.params ?? currentParams;
145
141
  }