@timber-js/app 0.2.0-alpha.34 → 0.2.0-alpha.35
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/{als-registry-B7DbZ2hS.js → als-registry-Ba7URUIn.js} +1 -1
- package/dist/_chunks/als-registry-Ba7URUIn.js.map +1 -0
- package/dist/_chunks/chunk-DYhsFzuS.js +33 -0
- package/dist/_chunks/{debug-B3Gypr3D.js → debug-ECi_61pb.js} +1 -1
- package/dist/_chunks/{debug-B3Gypr3D.js.map → debug-ECi_61pb.js.map} +1 -1
- package/dist/_chunks/define-cookie-w5GWm_bL.js +93 -0
- package/dist/_chunks/define-cookie-w5GWm_bL.js.map +1 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js +211 -0
- package/dist/_chunks/error-boundary-TYEQJZ1-.js.map +1 -0
- package/dist/_chunks/{format-RyoGQL74.js → format-cX7wzEp2.js} +2 -2
- package/dist/_chunks/{format-RyoGQL74.js.map → format-cX7wzEp2.js.map} +1 -1
- package/dist/_chunks/{interception-BOoWmLUA.js → interception-D2djYaIm.js} +112 -77
- package/dist/_chunks/interception-D2djYaIm.js.map +1 -0
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js → metadata-routes-BU684ls2.js} +1 -1
- package/dist/_chunks/{metadata-routes-Cjmvi3rQ.js.map → metadata-routes-BU684ls2.js.map} +1 -1
- package/dist/_chunks/{request-context-BQUC8PHn.js → request-context-CZz_T0Bc.js} +40 -71
- package/dist/_chunks/request-context-CZz_T0Bc.js.map +1 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js +34 -0
- package/dist/_chunks/segment-context-Dpq2XOKg.js.map +1 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js +47 -0
- package/dist/_chunks/stale-reload-C0ValzG7.js.map +1 -0
- package/dist/_chunks/{tracing-CemImE6h.js → tracing-BPyIzIdu.js} +2 -2
- package/dist/_chunks/{tracing-CemImE6h.js.map → tracing-BPyIzIdu.js.map} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js → use-query-states-BvW0TKDn.js} +1 -1
- package/dist/_chunks/{use-query-states-D5KaffOK.js.map → use-query-states-BvW0TKDn.js.map} +1 -1
- package/dist/_chunks/wrappers-C1SN725w.js +331 -0
- package/dist/_chunks/wrappers-C1SN725w.js.map +1 -0
- package/dist/cache/index.js +1 -1
- package/dist/client/error-boundary.d.ts +10 -1
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -125
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +193 -90
- package/dist/client/index.js.map +1 -1
- package/dist/client/link.d.ts +8 -8
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +2 -2
- package/dist/client/router.d.ts +25 -3
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +23 -2
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/stale-reload.d.ts +15 -0
- package/dist/client/stale-reload.d.ts.map +1 -1
- package/dist/client/top-loader.d.ts +1 -1
- package/dist/client/top-loader.d.ts.map +1 -1
- package/dist/client/use-params.d.ts +2 -2
- package/dist/client/use-params.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +1 -1
- package/dist/codec.d.ts +21 -0
- package/dist/codec.d.ts.map +1 -0
- package/dist/cookies/define-cookie.d.ts +33 -12
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -81
- package/dist/index.d.ts +87 -12
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +346 -210
- package/dist/index.js.map +1 -1
- package/dist/params/define.d.ts +76 -0
- package/dist/params/define.d.ts.map +1 -0
- package/dist/params/index.d.ts +8 -0
- package/dist/params/index.d.ts.map +1 -0
- package/dist/params/index.js +104 -0
- package/dist/params/index.js.map +1 -0
- package/dist/plugins/adapter-build.d.ts.map +1 -1
- package/dist/plugins/build-manifest.d.ts.map +1 -1
- package/dist/plugins/client-chunks.d.ts +32 -0
- package/dist/plugins/client-chunks.d.ts.map +1 -0
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/server-bundle.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen.d.ts +2 -2
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/index.js +1 -1
- package/dist/routing/scanner.d.ts.map +1 -1
- package/dist/routing/status-file-lint.d.ts +2 -1
- package/dist/routing/status-file-lint.d.ts.map +1 -1
- package/dist/routing/types.d.ts +6 -4
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/rsc-runtime/rsc.d.ts +1 -1
- package/dist/rsc-runtime/rsc.d.ts.map +1 -1
- package/dist/search-params/codecs.d.ts +1 -1
- package/dist/search-params/define.d.ts +153 -0
- package/dist/search-params/define.d.ts.map +1 -0
- package/dist/search-params/index.d.ts +4 -5
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +3 -474
- package/dist/search-params/registry.d.ts +1 -1
- package/dist/search-params/wrappers.d.ts +53 -0
- package/dist/search-params/wrappers.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts +4 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-encryption.d.ts +76 -0
- package/dist/server/action-encryption.d.ts.map +1 -0
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +4 -4
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/build-manifest.d.ts +2 -2
- package/dist/server/early-hints.d.ts +13 -5
- package/dist/server/early-hints.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +4 -0
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/flight-injection-state.d.ts +78 -0
- package/dist/server/flight-injection-state.d.ts.map +1 -0
- package/dist/server/form-data.d.ts +29 -0
- package/dist/server/form-data.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1819 -1629
- package/dist/server/index.js.map +1 -1
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/pipeline.d.ts.map +1 -1
- package/dist/server/request-context.d.ts +28 -40
- package/dist/server/request-context.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/route-matcher.d.ts +2 -2
- package/dist/server/route-matcher.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +3 -0
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +12 -8
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +1 -3
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/version-skew.d.ts +61 -0
- package/dist/server/version-skew.d.ts.map +1 -0
- package/dist/shims/navigation-client.d.ts +1 -1
- package/dist/shims/navigation-client.d.ts.map +1 -1
- package/dist/shims/navigation.d.ts +1 -1
- package/dist/shims/navigation.d.ts.map +1 -1
- package/dist/utils/state-machine.d.ts +80 -0
- package/dist/utils/state-machine.d.ts.map +1 -0
- package/package.json +12 -8
- package/src/client/browser-entry.ts +55 -13
- package/src/client/error-boundary.tsx +18 -1
- package/src/client/index.ts +9 -1
- package/src/client/link.tsx +9 -9
- package/src/client/navigation-context.ts +2 -2
- package/src/client/router.ts +102 -55
- package/src/client/rsc-fetch.ts +63 -2
- package/src/client/segment-cache.ts +1 -1
- package/src/client/stale-reload.ts +28 -0
- package/src/client/top-loader.tsx +2 -2
- package/src/client/use-params.ts +3 -3
- package/src/client/use-query-states.ts +1 -1
- package/src/codec.ts +21 -0
- package/src/cookies/define-cookie.ts +69 -18
- package/src/index.ts +255 -65
- package/src/params/define.ts +260 -0
- package/src/params/index.ts +28 -0
- package/src/plugins/adapter-build.ts +6 -0
- package/src/plugins/build-manifest.ts +11 -0
- package/src/plugins/client-chunks.ts +65 -0
- package/src/plugins/entries.ts +3 -6
- package/src/plugins/routing.ts +40 -14
- package/src/plugins/server-bundle.ts +32 -1
- package/src/plugins/shims.ts +1 -1
- package/src/plugins/static-build.ts +8 -4
- package/src/routing/codegen.ts +109 -88
- package/src/routing/scanner.ts +55 -6
- package/src/routing/status-file-lint.ts +2 -1
- package/src/routing/types.ts +7 -4
- package/src/rsc-runtime/rsc.ts +2 -0
- package/src/search-params/codecs.ts +1 -1
- package/src/search-params/define.ts +504 -0
- package/src/search-params/index.ts +12 -18
- package/src/search-params/registry.ts +1 -1
- package/src/search-params/wrappers.ts +85 -0
- package/src/server/access-gate.tsx +38 -8
- package/src/server/action-encryption.ts +144 -0
- package/src/server/action-handler.ts +16 -0
- package/src/server/als-registry.ts +4 -4
- package/src/server/build-manifest.ts +4 -4
- package/src/server/early-hints.ts +36 -15
- package/src/server/error-boundary-wrapper.ts +57 -14
- package/src/server/flight-injection-state.ts +152 -0
- package/src/server/form-data.ts +76 -0
- package/src/server/html-injectors.ts +42 -26
- package/src/server/index.ts +2 -4
- package/src/server/node-stream-transforms.ts +68 -41
- package/src/server/pipeline.ts +98 -26
- package/src/server/request-context.ts +49 -124
- package/src/server/route-element-builder.ts +102 -99
- package/src/server/route-matcher.ts +2 -2
- package/src/server/rsc-entry/error-renderer.ts +3 -2
- package/src/server/rsc-entry/index.ts +26 -11
- package/src/server/rsc-entry/rsc-payload.ts +2 -2
- package/src/server/rsc-entry/ssr-renderer.ts +4 -4
- package/src/server/slot-resolver.ts +204 -206
- package/src/server/ssr-entry.ts +3 -1
- package/src/server/ssr-render.ts +3 -0
- package/src/server/tree-builder.ts +84 -48
- package/src/server/types.ts +1 -3
- package/src/server/version-skew.ts +104 -0
- package/src/shims/navigation-client.ts +1 -1
- package/src/shims/navigation.ts +1 -1
- package/src/utils/state-machine.ts +111 -0
- package/dist/_chunks/als-registry-B7DbZ2hS.js.map +0 -1
- package/dist/_chunks/interception-BOoWmLUA.js.map +0 -1
- package/dist/_chunks/request-context-BQUC8PHn.js.map +0 -1
- package/dist/_chunks/ssr-data-MjmprTmO.js +0 -88
- package/dist/_chunks/ssr-data-MjmprTmO.js.map +0 -1
- package/dist/_chunks/use-cookie-DX-l1_5E.js +0 -91
- package/dist/_chunks/use-cookie-DX-l1_5E.js.map +0 -1
- package/dist/client/error-boundary.js.map +0 -1
- package/dist/cookies/index.js.map +0 -1
- package/dist/plugins/dynamic-transform.d.ts +0 -72
- package/dist/plugins/dynamic-transform.d.ts.map +0 -1
- package/dist/search-params/analyze.d.ts +0 -54
- package/dist/search-params/analyze.d.ts.map +0 -1
- package/dist/search-params/builtin-codecs.d.ts +0 -105
- package/dist/search-params/builtin-codecs.d.ts.map +0 -1
- package/dist/search-params/create.d.ts +0 -106
- package/dist/search-params/create.d.ts.map +0 -1
- package/dist/search-params/index.js.map +0 -1
- package/dist/server/prerender.d.ts +0 -77
- package/dist/server/prerender.d.ts.map +0 -1
- package/src/plugins/dynamic-transform.ts +0 -161
- package/src/search-params/analyze.ts +0 -192
- package/src/search-params/builtin-codecs.ts +0 -228
- package/src/search-params/create.ts +0 -321
- package/src/server/prerender.ts +0 -139
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
NavigationProvider,
|
|
48
48
|
getNavigationState,
|
|
49
49
|
setNavigationState,
|
|
50
|
+
type NavigationState,
|
|
50
51
|
} from './navigation-context.js';
|
|
51
52
|
import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.js';
|
|
52
53
|
// browser-links.ts removed — Link components own their click/hover handlers directly.
|
|
@@ -54,9 +55,16 @@ import { setupServerLogReplay, setupClientErrorForwarding } from './browser-dev.
|
|
|
54
55
|
import { TransitionRoot, transitionRender, navigateTransition } from './transition-root.js';
|
|
55
56
|
import {
|
|
56
57
|
isStaleClientReference,
|
|
58
|
+
isChunkLoadError,
|
|
57
59
|
triggerStaleReload,
|
|
58
60
|
clearStaleReloadFlag,
|
|
59
61
|
} from './stale-reload.js';
|
|
62
|
+
import {
|
|
63
|
+
setClientDeploymentId,
|
|
64
|
+
getClientDeploymentId,
|
|
65
|
+
DEPLOYMENT_ID_HEADER,
|
|
66
|
+
RELOAD_HEADER,
|
|
67
|
+
} from './rsc-fetch.js';
|
|
60
68
|
|
|
61
69
|
// ─── Server Action Dispatch ──────────────────────────────────────
|
|
62
70
|
|
|
@@ -85,14 +93,28 @@ setServerCallback(async (id: string, args: unknown[]) => {
|
|
|
85
93
|
let hasRedirect = false;
|
|
86
94
|
let headElementsJson: string | null = null;
|
|
87
95
|
|
|
96
|
+
// Build action request headers. Include deployment ID for version
|
|
97
|
+
// skew detection (TIM-446) — the server rejects stale actions gracefully.
|
|
98
|
+
const actionHeaders: Record<string, string> = {
|
|
99
|
+
'Accept': 'text/x-component',
|
|
100
|
+
'x-rsc-action': id,
|
|
101
|
+
};
|
|
102
|
+
const actionDeploymentId = getClientDeploymentId();
|
|
103
|
+
if (actionDeploymentId) {
|
|
104
|
+
actionHeaders[DEPLOYMENT_ID_HEADER] = actionDeploymentId;
|
|
105
|
+
}
|
|
106
|
+
|
|
88
107
|
const response = fetch(window.location.href, {
|
|
89
108
|
method: 'POST',
|
|
90
|
-
headers:
|
|
91
|
-
'Accept': 'text/x-component',
|
|
92
|
-
'x-rsc-action': id,
|
|
93
|
-
},
|
|
109
|
+
headers: actionHeaders,
|
|
94
110
|
body,
|
|
95
111
|
}).then((res) => {
|
|
112
|
+
// Version skew detection (TIM-446): if the server signals a reload,
|
|
113
|
+
// trigger a full page load to pick up the new deployment.
|
|
114
|
+
if (res.headers.get(RELOAD_HEADER) === '1') {
|
|
115
|
+
window.location.reload();
|
|
116
|
+
throw new Error('Version skew detected — reloading page');
|
|
117
|
+
}
|
|
96
118
|
hasRevalidation = res.headers.get('X-Timber-Revalidation') === '1';
|
|
97
119
|
hasRedirect = res.headers.get('X-Timber-Redirect') != null;
|
|
98
120
|
headElementsJson = res.headers.get('X-Timber-Head');
|
|
@@ -183,6 +205,13 @@ function getScrollY(): number {
|
|
|
183
205
|
function bootstrap(runtimeConfig: typeof config): void {
|
|
184
206
|
const _config = runtimeConfig;
|
|
185
207
|
|
|
208
|
+
// Initialize deployment ID for version skew detection (TIM-446).
|
|
209
|
+
// In dev mode this is null — skew checks are skipped.
|
|
210
|
+
const deploymentId = (_config as Record<string, unknown>).deploymentId as string | null;
|
|
211
|
+
if (deploymentId) {
|
|
212
|
+
setClientDeploymentId(deploymentId);
|
|
213
|
+
}
|
|
214
|
+
|
|
186
215
|
// Take manual control of scroll restoration. Even though segment tree
|
|
187
216
|
// merging preserves shared layout DOM via cloneElement (so React doesn't
|
|
188
217
|
// reset scroll on those elements), the root-level reactRoot.render() with
|
|
@@ -319,7 +348,7 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
319
348
|
|
|
320
349
|
// ── Initialize navigation state BEFORE hydration ───────────────────
|
|
321
350
|
// Read server-embedded params and set navigation state so that
|
|
322
|
-
//
|
|
351
|
+
// useSegmentParams() and usePathname() return correct values during hydration.
|
|
323
352
|
// This must happen before hydrateRoot so the NavigationProvider
|
|
324
353
|
// wrapping the element has the right values on the initial render.
|
|
325
354
|
const earlyParams = (self as unknown as Record<string, unknown>).__timber_params;
|
|
@@ -443,8 +472,10 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
443
472
|
// For navigation renders (navigate, refresh, popstate-with-fetch),
|
|
444
473
|
// navigateTransition is used instead — it wraps the entire navigation
|
|
445
474
|
// in a React transition with useOptimistic for the pending URL.
|
|
446
|
-
|
|
447
|
-
|
|
475
|
+
//
|
|
476
|
+
// navState is passed explicitly by the router — no temporal coupling
|
|
477
|
+
// with getNavigationState().
|
|
478
|
+
renderRoot: (element: unknown, navState: NavigationState) => {
|
|
448
479
|
const withNav = createElement(
|
|
449
480
|
NavigationProvider,
|
|
450
481
|
{ value: navState },
|
|
@@ -460,13 +491,11 @@ function bootstrap(runtimeConfig: typeof config): void {
|
|
|
460
491
|
// commits (atomic with the new tree + params).
|
|
461
492
|
//
|
|
462
493
|
// The perform callback receives a wrapPayload function that wraps the
|
|
463
|
-
// decoded RSC payload with NavigationProvider + NuqsAdapter
|
|
464
|
-
//
|
|
465
|
-
// UPDATED navigation state (set by the router inside perform).
|
|
494
|
+
// decoded RSC payload with NavigationProvider + NuqsAdapter. navState
|
|
495
|
+
// is passed explicitly by the router — no getNavigationState() needed.
|
|
466
496
|
navigateTransition: (pendingUrl: string, perform) => {
|
|
467
497
|
return navigateTransition(pendingUrl, async () => {
|
|
468
|
-
const payload = await perform((rawPayload: unknown) => {
|
|
469
|
-
const navState = getNavigationState();
|
|
498
|
+
const payload = await perform((rawPayload: unknown, navState: NavigationState) => {
|
|
470
499
|
const withNav = createElement(
|
|
471
500
|
NavigationProvider,
|
|
472
501
|
{ value: navState },
|
|
@@ -626,8 +655,21 @@ clearStaleReloadFlag();
|
|
|
626
655
|
// If the payload references a module ID from a newer deployment, the error
|
|
627
656
|
// surfaces as an unhandled rejection during React's render/hydration cycle.
|
|
628
657
|
// This handler catches those errors and triggers a full page reload.
|
|
658
|
+
//
|
|
659
|
+
// Also catches chunk load failures (dynamic import of missing assets after
|
|
660
|
+
// a deployment) — these surface as "Failed to fetch dynamically imported module"
|
|
661
|
+
// or "Loading chunk <name> failed" errors. See TIM-446.
|
|
629
662
|
window.addEventListener('unhandledrejection', (event) => {
|
|
630
|
-
if (isStaleClientReference(event.reason)) {
|
|
663
|
+
if (isStaleClientReference(event.reason) || isChunkLoadError(event.reason)) {
|
|
664
|
+
event.preventDefault();
|
|
665
|
+
triggerStaleReload();
|
|
666
|
+
}
|
|
667
|
+
});
|
|
668
|
+
|
|
669
|
+
// Also catch synchronous errors from chunk loads (some browsers throw
|
|
670
|
+
// TypeError synchronously instead of via unhandled rejection).
|
|
671
|
+
window.addEventListener('error', (event) => {
|
|
672
|
+
if (isChunkLoadError(event.error)) {
|
|
631
673
|
event.preventDefault();
|
|
632
674
|
triggerStaleReload();
|
|
633
675
|
}
|
|
@@ -65,7 +65,16 @@ type ParsedDigest = DenyDigest | RenderErrorDigest | RedirectDigest;
|
|
|
65
65
|
|
|
66
66
|
export interface TimberErrorBoundaryProps {
|
|
67
67
|
/** The component to render when an error is caught. */
|
|
68
|
-
fallbackComponent
|
|
68
|
+
fallbackComponent?: (...args: unknown[]) => ReactNode;
|
|
69
|
+
/**
|
|
70
|
+
* Pre-rendered fallback element. Used for MDX status files which are server
|
|
71
|
+
* components and cannot be passed as function props across the RSC→client
|
|
72
|
+
* boundary. When set, rendered directly instead of calling fallbackComponent.
|
|
73
|
+
*
|
|
74
|
+
* See design/10-error-handling.md §"Status-Code File Variants" — MDX status
|
|
75
|
+
* files are server components by default (zero client JS).
|
|
76
|
+
*/
|
|
77
|
+
fallbackElement?: ReactNode;
|
|
69
78
|
/**
|
|
70
79
|
* Status code filter. If set, only catches errors matching this status.
|
|
71
80
|
* 400 = any 4xx, 500 = any 5xx, specific number = exact match.
|
|
@@ -162,6 +171,14 @@ export class TimberErrorBoundary extends Component<
|
|
|
162
171
|
}
|
|
163
172
|
}
|
|
164
173
|
|
|
174
|
+
// Pre-rendered fallback element (MDX status files) — render directly.
|
|
175
|
+
// MDX components are server components that cannot be passed as function
|
|
176
|
+
// props across the RSC→client boundary. Instead, they are pre-rendered
|
|
177
|
+
// as elements in the RSC environment and passed here as fallbackElement.
|
|
178
|
+
if (this.props.fallbackElement != null) {
|
|
179
|
+
return this.props.fallbackElement;
|
|
180
|
+
}
|
|
181
|
+
|
|
165
182
|
// Render the fallback component with the right props shape.
|
|
166
183
|
if (parsed?.type === 'deny') {
|
|
167
184
|
return createElement(this.props.fallbackComponent as never, {
|
package/src/client/index.ts
CHANGED
|
@@ -12,6 +12,7 @@ export type {
|
|
|
12
12
|
RouterInstance,
|
|
13
13
|
NavigationOptions,
|
|
14
14
|
RouterDeps,
|
|
15
|
+
RouterPhase,
|
|
15
16
|
RscDecoder,
|
|
16
17
|
RootRenderer,
|
|
17
18
|
} from './router';
|
|
@@ -42,7 +43,7 @@ export { useActionState, useFormAction, useFormErrors } from './form';
|
|
|
42
43
|
export type { UseActionStateFn, UseActionStateReturn, FormErrorsResult } from './form';
|
|
43
44
|
|
|
44
45
|
// Params
|
|
45
|
-
export {
|
|
46
|
+
export { useSegmentParams, setCurrentParams } from './use-params';
|
|
46
47
|
|
|
47
48
|
// Navigation context (framework-internal, used by browser-entry for atomic updates)
|
|
48
49
|
export { NavigationProvider, getNavigationState, setNavigationState } from './navigation-context';
|
|
@@ -55,6 +56,13 @@ export { useQueryStates, bindUseQueryStates } from './use-query-states';
|
|
|
55
56
|
export { useCookie } from './use-cookie';
|
|
56
57
|
export type { ClientCookieOptions, CookieSetter } from './use-cookie';
|
|
57
58
|
|
|
59
|
+
// Register the client cookie module with defineCookie's lazy reference.
|
|
60
|
+
// This runs at module load time in the client/SSR environment, wiring up
|
|
61
|
+
// the useCookie hook without a top-level import in define-cookie.ts.
|
|
62
|
+
import * as _useCookieMod from './use-cookie.js';
|
|
63
|
+
import { _registerUseCookieModule } from '#/cookies/define-cookie.js';
|
|
64
|
+
_registerUseCookieModule(_useCookieMod);
|
|
65
|
+
|
|
58
66
|
// SSR data (framework-internal, used by ssr-entry to provide request data to hooks)
|
|
59
67
|
export { setSsrData, clearSsrData, getSsrData } from './ssr-data';
|
|
60
68
|
export type { SsrData } from './ssr-data';
|
package/src/client/link.tsx
CHANGED
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
// - searchParams and inline query string are mutually exclusive
|
|
20
20
|
|
|
21
21
|
import type { AnchorHTMLAttributes, ReactNode, MouseEvent as ReactMouseEvent } from 'react';
|
|
22
|
-
import type { SearchParamsDefinition } from '#/search-params/
|
|
22
|
+
import type { SearchParamsDefinition } from '#/search-params/define.js';
|
|
23
23
|
import { LinkStatusProvider } from './link-status-provider.js';
|
|
24
24
|
import { getRouterOrNull } from './router-ref.js';
|
|
25
25
|
|
|
@@ -61,7 +61,7 @@ interface LinkBaseProps extends Omit<AnchorHTMLAttributes<HTMLAnchorElement>, 'h
|
|
|
61
61
|
*/
|
|
62
62
|
export interface LinkPropsWithHref extends LinkBaseProps {
|
|
63
63
|
href: string;
|
|
64
|
-
|
|
64
|
+
segmentParams?: never;
|
|
65
65
|
/**
|
|
66
66
|
* Typed search params — serialized via the route's SearchParamsDefinition.
|
|
67
67
|
* Mutually exclusive with an inline query string in href.
|
|
@@ -73,9 +73,9 @@ export interface LinkPropsWithHref extends LinkBaseProps {
|
|
|
73
73
|
}
|
|
74
74
|
|
|
75
75
|
/**
|
|
76
|
-
* Link with a route pattern +
|
|
77
|
-
* e.g. <Link href="/products/[id]"
|
|
78
|
-
* <Link href="/products/[id]"
|
|
76
|
+
* Link with a route pattern + segmentParams for interpolation.
|
|
77
|
+
* e.g. <Link href="/products/[id]" segmentParams={{ id: "123" }}>
|
|
78
|
+
* <Link href="/products/[id]" segmentParams={{ id: 123 }}>
|
|
79
79
|
*/
|
|
80
80
|
export interface LinkPropsWithParams extends LinkBaseProps {
|
|
81
81
|
/** Route pattern with dynamic segments (e.g. "/products/[id]") */
|
|
@@ -85,7 +85,7 @@ export interface LinkPropsWithParams extends LinkBaseProps {
|
|
|
85
85
|
* Single dynamic segments accept string | number (numbers are stringified).
|
|
86
86
|
* Catch-all segments accept string[].
|
|
87
87
|
*/
|
|
88
|
-
|
|
88
|
+
segmentParams: Record<string, string | number | string[]>;
|
|
89
89
|
/**
|
|
90
90
|
* Typed search params — serialized via the route's SearchParamsDefinition.
|
|
91
91
|
*/
|
|
@@ -303,14 +303,14 @@ function shouldInterceptClick(
|
|
|
303
303
|
* its own click handling.
|
|
304
304
|
*
|
|
305
305
|
* Supports typed routes via codegen overloads. At runtime:
|
|
306
|
-
* - `
|
|
306
|
+
* - `segmentParams` prop interpolates dynamic segments in the href pattern
|
|
307
307
|
* - `searchParams` prop serializes query parameters via a SearchParamsDefinition
|
|
308
308
|
*/
|
|
309
309
|
export function Link({
|
|
310
310
|
href,
|
|
311
311
|
prefetch,
|
|
312
312
|
scroll,
|
|
313
|
-
|
|
313
|
+
segmentParams,
|
|
314
314
|
searchParams,
|
|
315
315
|
onNavigate,
|
|
316
316
|
onClick: userOnClick,
|
|
@@ -318,7 +318,7 @@ export function Link({
|
|
|
318
318
|
children,
|
|
319
319
|
...rest
|
|
320
320
|
}: LinkProps) {
|
|
321
|
-
const { href: resolvedHref } = buildLinkProps({ href, params, searchParams });
|
|
321
|
+
const { href: resolvedHref } = buildLinkProps({ href, params: segmentParams, searchParams });
|
|
322
322
|
const internal = isInternalHref(resolvedHref);
|
|
323
323
|
|
|
324
324
|
// ─── Click handler ───────────────────────────────────────────
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*
|
|
6
6
|
* Holds the current route params and pathname, updated atomically
|
|
7
7
|
* with the RSC tree on each navigation. This replaces the previous
|
|
8
|
-
* useSyncExternalStore approach for
|
|
8
|
+
* useSyncExternalStore approach for useSegmentParams() and usePathname(),
|
|
9
9
|
* which suffered from a timing gap: the new tree could commit before
|
|
10
10
|
* the external store re-renders fired, causing a frame where both
|
|
11
11
|
* old and new active states were visible simultaneously.
|
|
@@ -90,7 +90,7 @@ function getOrCreateContext(): React.Context<NavigationState | null> | undefined
|
|
|
90
90
|
/**
|
|
91
91
|
* Read the navigation context. Returns null during SSR (no provider)
|
|
92
92
|
* or in the RSC environment (no context available).
|
|
93
|
-
* Internal — used by
|
|
93
|
+
* Internal — used by useSegmentParams() and usePathname().
|
|
94
94
|
*/
|
|
95
95
|
export function useNavigationContext(): NavigationState | null {
|
|
96
96
|
const ctx = getOrCreateContext();
|
package/src/client/router.ts
CHANGED
|
@@ -6,9 +6,13 @@ import type { SegmentInfo } from './segment-cache';
|
|
|
6
6
|
import { HistoryStack } from './history';
|
|
7
7
|
import type { HeadElement } from './head';
|
|
8
8
|
import { setCurrentParams } from './use-params.js';
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
setNavigationState,
|
|
11
|
+
getNavigationState,
|
|
12
|
+
type NavigationState,
|
|
13
|
+
} from './navigation-context.js';
|
|
10
14
|
import { SegmentElementCache, cacheSegmentElements, mergeSegmentTree } from './segment-merger.js';
|
|
11
|
-
import { fetchRscPayload, RedirectError } from './rsc-fetch.js';
|
|
15
|
+
import { fetchRscPayload, RedirectError, VersionSkewError } from './rsc-fetch.js';
|
|
12
16
|
import type { FetchResult } from './rsc-fetch.js';
|
|
13
17
|
|
|
14
18
|
// ─── Types ───────────────────────────────────────────────────────
|
|
@@ -31,8 +35,12 @@ export type RscDecoder = (fetchPromise: Promise<Response>) => unknown;
|
|
|
31
35
|
* Function that renders a decoded RSC element tree into the DOM.
|
|
32
36
|
* In production: reactRoot.render(element).
|
|
33
37
|
* In tests: a no-op or mock.
|
|
38
|
+
*
|
|
39
|
+
* Receives the current NavigationState explicitly — no temporal
|
|
40
|
+
* coupling with setNavigationState/getNavigationState. The renderer
|
|
41
|
+
* wraps the element in NavigationProvider with this state.
|
|
34
42
|
*/
|
|
35
|
-
export type RootRenderer = (element: unknown) => void;
|
|
43
|
+
export type RootRenderer = (element: unknown, navState: NavigationState) => void;
|
|
36
44
|
|
|
37
45
|
/**
|
|
38
46
|
* Platform dependencies injected for testability. In production these
|
|
@@ -64,13 +72,17 @@ export interface RouterDeps {
|
|
|
64
72
|
*
|
|
65
73
|
* The `perform` callback receives a `wrapPayload` function to wrap the
|
|
66
74
|
* decoded RSC payload with NavigationProvider + NuqsAdapter before
|
|
67
|
-
* TransitionRoot sets it as the new element.
|
|
75
|
+
* TransitionRoot sets it as the new element. The `wrapPayload` function
|
|
76
|
+
* receives the NavigationState explicitly — no temporal coupling with
|
|
77
|
+
* getNavigationState().
|
|
68
78
|
*
|
|
69
79
|
* If not provided (tests), the router falls back to renderRoot.
|
|
70
80
|
*/
|
|
71
81
|
navigateTransition?: (
|
|
72
82
|
pendingUrl: string,
|
|
73
|
-
perform: (
|
|
83
|
+
perform: (
|
|
84
|
+
wrapPayload: (payload: unknown, navState: NavigationState) => unknown
|
|
85
|
+
) => Promise<unknown>
|
|
74
86
|
) => Promise<void>;
|
|
75
87
|
}
|
|
76
88
|
|
|
@@ -130,21 +142,40 @@ function isAbortError(error: unknown): boolean {
|
|
|
130
142
|
* Create a router instance. In production, called once at app hydration
|
|
131
143
|
* with real browser APIs. In tests, called with mock dependencies.
|
|
132
144
|
*/
|
|
145
|
+
/**
|
|
146
|
+
* Router navigation phase — discriminated union replacing scattered
|
|
147
|
+
* `pending` + `pendingUrl` boolean flags.
|
|
148
|
+
*
|
|
149
|
+
* - `idle`: No navigation in flight. The committed params/pathname
|
|
150
|
+
* are current.
|
|
151
|
+
* - `navigating`: A fetch or render is in progress. `targetUrl` is
|
|
152
|
+
* the destination being navigated to.
|
|
153
|
+
*/
|
|
154
|
+
export type RouterPhase = { phase: 'idle' } | { phase: 'navigating'; targetUrl: string };
|
|
155
|
+
|
|
133
156
|
export function createRouter(deps: RouterDeps): RouterInstance {
|
|
134
157
|
const segmentCache = new SegmentCache();
|
|
135
158
|
const prefetchCache = new PrefetchCache();
|
|
136
159
|
const historyStack = new HistoryStack();
|
|
137
160
|
const segmentElementCache = new SegmentElementCache();
|
|
138
161
|
|
|
139
|
-
let
|
|
140
|
-
let pendingUrl: string | null = null;
|
|
162
|
+
let routerPhase: RouterPhase = { phase: 'idle' };
|
|
141
163
|
const pendingListeners = new Set<(pending: boolean) => void>();
|
|
142
164
|
|
|
143
165
|
function setPending(value: boolean, url?: string): void {
|
|
144
|
-
const
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
166
|
+
const next: RouterPhase =
|
|
167
|
+
value && url ? { phase: 'navigating', targetUrl: url } : { phase: 'idle' };
|
|
168
|
+
// Skip no-op updates
|
|
169
|
+
if (
|
|
170
|
+
routerPhase.phase === next.phase &&
|
|
171
|
+
(routerPhase.phase === 'idle' ||
|
|
172
|
+
(routerPhase.phase === 'navigating' &&
|
|
173
|
+
next.phase === 'navigating' &&
|
|
174
|
+
routerPhase.targetUrl === next.targetUrl))
|
|
175
|
+
) {
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
routerPhase = next;
|
|
148
179
|
// Notify external store listeners (non-React consumers).
|
|
149
180
|
// React-facing pending state is handled by useOptimistic in
|
|
150
181
|
// TransitionRoot via navigateTransition — not this function.
|
|
@@ -163,9 +194,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
163
194
|
}
|
|
164
195
|
|
|
165
196
|
/** Render a decoded RSC payload into the DOM if a renderer is available. */
|
|
166
|
-
function renderPayload(payload: unknown): void {
|
|
197
|
+
function renderPayload(payload: unknown, navState: NavigationState): void {
|
|
167
198
|
if (deps.renderRoot) {
|
|
168
|
-
deps.renderRoot(payload);
|
|
199
|
+
deps.renderRoot(payload, navState);
|
|
169
200
|
}
|
|
170
201
|
}
|
|
171
202
|
|
|
@@ -194,32 +225,34 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
194
225
|
/**
|
|
195
226
|
* Update navigation state (params + pathname) for the next render.
|
|
196
227
|
*
|
|
197
|
-
* Sets
|
|
198
|
-
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
228
|
+
* Sets the module-level fallback (for tests and SSR) and the
|
|
229
|
+
* globalThis bridge, then returns the NavigationState so callers
|
|
230
|
+
* can pass it explicitly to renderRoot/wrapPayload — eliminating
|
|
231
|
+
* temporal coupling with getNavigationState().
|
|
201
232
|
*/
|
|
202
233
|
function updateNavigationState(
|
|
203
234
|
params: Record<string, string | string[]> | null | undefined,
|
|
204
235
|
url: string
|
|
205
|
-
):
|
|
236
|
+
): NavigationState {
|
|
206
237
|
const resolvedParams = params ?? {};
|
|
207
238
|
// Module-level fallback for tests (no NavigationProvider) and SSR
|
|
208
239
|
setCurrentParams(resolvedParams);
|
|
209
|
-
//
|
|
240
|
+
// globalThis bridge — kept for backward compat
|
|
210
241
|
const pathname = url.startsWith('http') ? new URL(url).pathname : url.split('?')[0] || '/';
|
|
211
|
-
|
|
242
|
+
const navState: NavigationState = { params: resolvedParams, pathname };
|
|
243
|
+
setNavigationState(navState);
|
|
244
|
+
return navState;
|
|
212
245
|
}
|
|
213
246
|
|
|
214
247
|
/**
|
|
215
248
|
* Render a payload via navigateTransition (production) or renderRoot (tests).
|
|
216
|
-
* The perform callback should fetch data, update state, and return the
|
|
217
|
-
*
|
|
218
|
-
*
|
|
249
|
+
* The perform callback should fetch data, update state, and return the
|
|
250
|
+
* FetchResult plus the NavigationState (so it can be passed explicitly
|
|
251
|
+
* to wrapPayload/renderRoot without temporal coupling).
|
|
219
252
|
*/
|
|
220
253
|
async function renderViaTransition(
|
|
221
254
|
url: string,
|
|
222
|
-
perform: () => Promise<FetchResult>
|
|
255
|
+
perform: () => Promise<FetchResult & { navState: NavigationState }>
|
|
223
256
|
): Promise<HeadElement[] | null> {
|
|
224
257
|
if (deps.navigateTransition) {
|
|
225
258
|
let headElements: HeadElement[] | null = null;
|
|
@@ -235,7 +268,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
235
268
|
headElements: result.headElements,
|
|
236
269
|
params: result.params,
|
|
237
270
|
});
|
|
238
|
-
|
|
271
|
+
// Pass navState explicitly — wrapPayload wraps element in
|
|
272
|
+
// NavigationProvider with this state, no getNavigationState() needed.
|
|
273
|
+
return wrapPayload(merged, result.navState);
|
|
239
274
|
});
|
|
240
275
|
return headElements;
|
|
241
276
|
}
|
|
@@ -249,7 +284,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
249
284
|
headElements: result.headElements,
|
|
250
285
|
params: result.params,
|
|
251
286
|
});
|
|
252
|
-
renderPayload(merged);
|
|
287
|
+
renderPayload(merged, result.navState);
|
|
253
288
|
return result.headElements;
|
|
254
289
|
}
|
|
255
290
|
|
|
@@ -269,6 +304,17 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
269
304
|
}
|
|
270
305
|
}
|
|
271
306
|
|
|
307
|
+
/**
|
|
308
|
+
* Schedule scroll restoration after the next paint and fire the
|
|
309
|
+
* scroll-restored event. Used by navigate, popstate, and refresh.
|
|
310
|
+
*/
|
|
311
|
+
function restoreScrollAfterPaint(scrollY: number): void {
|
|
312
|
+
afterPaint(() => {
|
|
313
|
+
deps.scrollTo(0, scrollY);
|
|
314
|
+
window.dispatchEvent(new Event('timber:scroll-restored'));
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
|
|
272
318
|
/**
|
|
273
319
|
* Core navigation logic shared between the transition and fallback paths.
|
|
274
320
|
* Fetches the RSC payload, updates all state, and returns the result.
|
|
@@ -276,7 +322,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
276
322
|
async function performNavigationFetch(
|
|
277
323
|
url: string,
|
|
278
324
|
options: { replace: boolean }
|
|
279
|
-
): Promise<FetchResult> {
|
|
325
|
+
): Promise<FetchResult & { navState: NavigationState }> {
|
|
280
326
|
// Check prefetch cache first. PrefetchResult has optional segmentInfo/params
|
|
281
327
|
// fields — normalize to null for FetchResult compatibility.
|
|
282
328
|
const prefetched = prefetchCache.consume(url);
|
|
@@ -316,10 +362,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
316
362
|
// Update the segment cache with the new route's segment tree.
|
|
317
363
|
updateSegmentCache(result.segmentInfo);
|
|
318
364
|
|
|
319
|
-
// Update navigation state
|
|
320
|
-
updateNavigationState(result.params, url);
|
|
365
|
+
// Update navigation state and capture it for explicit passing.
|
|
366
|
+
const navState = updateNavigationState(result.params, url);
|
|
321
367
|
|
|
322
|
-
return result;
|
|
368
|
+
return { ...result, navState };
|
|
323
369
|
}
|
|
324
370
|
|
|
325
371
|
async function navigate(url: string, options: NavigationOptions = {}): Promise<void> {
|
|
@@ -350,15 +396,18 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
350
396
|
// Scroll-to-top on forward navigation, or restore captured position
|
|
351
397
|
// for scroll={false}. React's render() on the document root can reset
|
|
352
398
|
// scroll during DOM reconciliation, so all scroll must be actively managed.
|
|
353
|
-
|
|
354
|
-
if (scroll) {
|
|
355
|
-
deps.scrollTo(0, 0);
|
|
356
|
-
} else {
|
|
357
|
-
deps.scrollTo(0, currentScrollY);
|
|
358
|
-
}
|
|
359
|
-
window.dispatchEvent(new Event('timber:scroll-restored'));
|
|
360
|
-
});
|
|
399
|
+
restoreScrollAfterPaint(scroll ? 0 : currentScrollY);
|
|
361
400
|
} catch (error) {
|
|
401
|
+
// Version skew — server has been redeployed. Trigger full page reload
|
|
402
|
+
// so the browser fetches the new bundle. See TIM-446.
|
|
403
|
+
if (error instanceof VersionSkewError) {
|
|
404
|
+
// Import triggerStaleReload dynamically to avoid circular deps
|
|
405
|
+
// and keep the reload logic centralized with its loop guard.
|
|
406
|
+
const { triggerStaleReload } = await import('./stale-reload.js');
|
|
407
|
+
triggerStaleReload();
|
|
408
|
+
// Return a never-resolving promise — page is reloading.
|
|
409
|
+
return new Promise(() => {}) as never;
|
|
410
|
+
}
|
|
362
411
|
// Server-side redirect during RSC fetch → soft router navigation.
|
|
363
412
|
if (error instanceof RedirectError) {
|
|
364
413
|
setPending(false);
|
|
@@ -384,8 +433,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
384
433
|
const result = await fetchRscPayload(currentUrl, deps);
|
|
385
434
|
// History push handled by renderViaTransition (stores merged payload)
|
|
386
435
|
updateSegmentCache(result.segmentInfo);
|
|
387
|
-
updateNavigationState(result.params, currentUrl);
|
|
388
|
-
return result;
|
|
436
|
+
const navState = updateNavigationState(result.params, currentUrl);
|
|
437
|
+
return { ...result, navState };
|
|
389
438
|
});
|
|
390
439
|
|
|
391
440
|
applyHead(headElements);
|
|
@@ -402,13 +451,10 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
402
451
|
|
|
403
452
|
if (entry && entry.payload !== null) {
|
|
404
453
|
// Replay cached payload — no server roundtrip
|
|
405
|
-
updateNavigationState(entry.params, url);
|
|
406
|
-
renderPayload(entry.payload);
|
|
454
|
+
const navState = updateNavigationState(entry.params, url);
|
|
455
|
+
renderPayload(entry.payload, navState);
|
|
407
456
|
applyHead(entry.headElements);
|
|
408
|
-
|
|
409
|
-
deps.scrollTo(0, scrollY);
|
|
410
|
-
window.dispatchEvent(new Event('timber:scroll-restored'));
|
|
411
|
-
});
|
|
457
|
+
restoreScrollAfterPaint(scrollY);
|
|
412
458
|
} else {
|
|
413
459
|
// No cached payload — fetch from server.
|
|
414
460
|
// This happens when navigating back to the initial SSR'd page
|
|
@@ -422,16 +468,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
422
468
|
);
|
|
423
469
|
const result = await fetchRscPayload(url, deps, stateTree);
|
|
424
470
|
updateSegmentCache(result.segmentInfo);
|
|
425
|
-
updateNavigationState(result.params, url);
|
|
471
|
+
const navState = updateNavigationState(result.params, url);
|
|
426
472
|
// History push handled by renderViaTransition (stores merged payload)
|
|
427
|
-
return result;
|
|
473
|
+
return { ...result, navState };
|
|
428
474
|
});
|
|
429
475
|
|
|
430
476
|
applyHead(headElements);
|
|
431
|
-
|
|
432
|
-
deps.scrollTo(0, scrollY);
|
|
433
|
-
window.dispatchEvent(new Event('timber:scroll-restored'));
|
|
434
|
-
});
|
|
477
|
+
restoreScrollAfterPaint(scrollY);
|
|
435
478
|
} finally {
|
|
436
479
|
setPending(false);
|
|
437
480
|
}
|
|
@@ -463,8 +506,8 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
463
506
|
navigate,
|
|
464
507
|
refresh,
|
|
465
508
|
handlePopState,
|
|
466
|
-
isPending: () =>
|
|
467
|
-
getPendingUrl: () =>
|
|
509
|
+
isPending: () => routerPhase.phase === 'navigating',
|
|
510
|
+
getPendingUrl: () => (routerPhase.phase === 'navigating' ? routerPhase.targetUrl : null),
|
|
468
511
|
onPendingChange(listener) {
|
|
469
512
|
pendingListeners.add(listener);
|
|
470
513
|
return () => pendingListeners.delete(listener);
|
|
@@ -481,7 +524,11 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
481
524
|
payload: merged,
|
|
482
525
|
headElements,
|
|
483
526
|
});
|
|
484
|
-
|
|
527
|
+
// Revalidation doesn't change params/pathname — preserve current state.
|
|
528
|
+
// DO NOT call updateNavigationState(null, ...) here: that normalizes
|
|
529
|
+
// params to {}, clearing dynamic route params on every action response.
|
|
530
|
+
const navState = getNavigationState();
|
|
531
|
+
renderPayload(merged, navState);
|
|
485
532
|
applyHead(headElements);
|
|
486
533
|
},
|
|
487
534
|
initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
|