@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.
- package/dist/_chunks/{ssr-data-B2yikEEB.js → ssr-data-DLnbYpj1.js} +2 -4
- package/dist/_chunks/{ssr-data-B2yikEEB.js.map → ssr-data-DLnbYpj1.js.map} +1 -1
- package/dist/_chunks/{use-cookie-D5aS4slY.js → use-cookie-dDbpCTx-.js} +2 -2
- package/dist/_chunks/{use-cookie-D5aS4slY.js.map → use-cookie-dDbpCTx-.js.map} +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +2 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +104 -93
- package/dist/client/index.js.map +1 -1
- package/dist/client/navigation-context.d.ts +50 -0
- package/dist/client/navigation-context.d.ts.map +1 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +35 -25
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts +11 -4
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -2
- package/package.json +1 -1
- package/src/client/browser-entry.ts +50 -10
- package/src/client/index.ts +4 -0
- package/src/client/navigation-context.ts +88 -0
- package/src/client/router.ts +33 -24
- package/src/client/use-params.ts +50 -54
- package/src/client/use-pathname.ts +31 -24
|
@@ -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()
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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.
|
|
34
|
-
*
|
|
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 (
|
|
39
|
-
*
|
|
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
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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)
|
|
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
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
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
|
|
74
|
-
*
|
|
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
|
|
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
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
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
|
|
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"}
|
package/dist/cookies/index.js
CHANGED
|
@@ -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-
|
|
4
|
-
import { t as useCookie } from "../_chunks/use-cookie-
|
|
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
|
@@ -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
|
|
281
|
-
|
|
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
|
-
//
|
|
340
|
-
//
|
|
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
|
|
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
|
-
//
|
|
388
|
-
//
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
|
package/src/client/index.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/client/router.ts
CHANGED
|
@@ -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
|
|
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
|
-
/**
|
|
328
|
-
|
|
329
|
-
|
|
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
|
|
397
|
-
//
|
|
398
|
-
//
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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
|
-
//
|
|
468
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
package/src/client/use-params.ts
CHANGED
|
@@ -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()
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
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 —
|
|
39
|
+
// Module-level subscribe/notify pattern — kept for backward compat and tests
|
|
39
40
|
// ---------------------------------------------------------------------------
|
|
40
41
|
|
|
41
42
|
/**
|
|
42
|
-
* Subscribe to params changes.
|
|
43
|
-
*
|
|
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 (
|
|
52
|
-
*
|
|
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
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
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
|
-
*
|
|
79
|
-
*
|
|
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)
|
|
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
|
|
92
|
-
*
|
|
93
|
-
*
|
|
94
|
-
*
|
|
95
|
-
*
|
|
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
|
|
115
|
-
*
|
|
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
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
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
|
-
|
|
129
|
+
const navContext = useNavigationContext();
|
|
130
|
+
if (navContext !== null) {
|
|
131
|
+
return navContext.params;
|
|
132
|
+
}
|
|
136
133
|
} catch {
|
|
137
|
-
// No React dispatcher available
|
|
138
|
-
//
|
|
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
|
}
|