@timber-js/app 0.2.0-alpha.196 → 0.2.0-alpha.198
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/{actions-CWYtq6ii.js → actions-BS-m5SLv.js} +3 -3
- package/dist/_chunks/{actions-CWYtq6ii.js.map → actions-BS-m5SLv.js.map} +1 -1
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -1
- package/dist/_chunks/{build-manifest-DWppEdLB.js → build-manifest-DTmSGLRz.js} +51 -2
- package/dist/_chunks/build-manifest-DTmSGLRz.js.map +1 -0
- package/dist/_chunks/{cache-api-CQeYzA5g.js → cache-api-DqzgTEqk.js} +4 -49
- package/dist/_chunks/cache-api-DqzgTEqk.js.map +1 -0
- package/dist/_chunks/{chains-h7EO-u3n.js → chains-CZG7E5zg.js} +2 -2
- package/dist/_chunks/{chains-h7EO-u3n.js.map → chains-CZG7E5zg.js.map} +1 -1
- package/dist/_chunks/{cli-check-BVthpfLS.js → cli-check-dVDi1GQz.js} +3 -3
- package/dist/_chunks/{cli-check-BVthpfLS.js.map → cli-check-dVDi1GQz.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js → cli-schema-sync-DTy_-Msq.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-3Wutm8pH.js.map → cli-schema-sync-DTy_-Msq.js.map} +1 -1
- package/dist/_chunks/{cloudflare-BKJC3SC_.js → cloudflare-BFb__LYG.js} +2 -2
- package/dist/_chunks/{cloudflare-BKJC3SC_.js.map → cloudflare-BFb__LYG.js.map} +1 -1
- package/dist/_chunks/{convention-lint-DO10_pVl.js → convention-lint-Ph6luW4c.js} +4 -2
- package/dist/_chunks/convention-lint-Ph6luW4c.js.map +1 -0
- package/dist/_chunks/{error-boundary-D-lkwyaD.js → error-boundary-BvRCCmbN.js} +3 -3
- package/dist/_chunks/{error-boundary-D-lkwyaD.js.map → error-boundary-BvRCCmbN.js.map} +1 -1
- package/dist/_chunks/{href-validation-CMc5JRls.js → href-validation-BIrxavIy.js} +74 -2
- package/dist/_chunks/href-validation-BIrxavIy.js.map +1 -0
- package/dist/_chunks/{live-graph-Bx4HodF1.js → live-graph-BXDsdzBv.js} +3 -3
- package/dist/_chunks/{live-graph-Bx4HodF1.js.map → live-graph-BXDsdzBv.js.map} +1 -1
- package/dist/_chunks/{logger-pumCm3Il.js → logger-DDirEsn7.js} +3 -4
- package/dist/_chunks/{logger-pumCm3Il.js.map → logger-DDirEsn7.js.map} +1 -1
- package/dist/_chunks/navigation-root-B00jjGd5.js +233 -0
- package/dist/_chunks/navigation-root-B00jjGd5.js.map +1 -0
- package/dist/_chunks/{segment-context-CjOlyB8Y.js → param-value-C8TNYchQ.js} +2 -33
- package/dist/_chunks/param-value-C8TNYchQ.js.map +1 -0
- package/dist/_chunks/{poison-scan-BAxfTT5L.js → poison-scan-BoDLgbix.js} +2 -2
- package/dist/_chunks/{poison-scan-BAxfTT5L.js.map → poison-scan-BoDLgbix.js.map} +1 -1
- package/dist/_chunks/{router-ref-BzqbPwYC.js → router-ref-8gr8qsxN.js} +2 -2
- package/dist/_chunks/{router-ref-BzqbPwYC.js.map → router-ref-8gr8qsxN.js.map} +1 -1
- package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js → rsc-cache-key-ClUiXQnK.js} +2 -2
- package/dist/_chunks/{rsc-cache-key-DD0fl_-s.js.map → rsc-cache-key-ClUiXQnK.js.map} +1 -1
- package/dist/_chunks/{scanner-BRIOmHE2.js → scanner-tdFPvDYi.js} +174 -7
- package/dist/_chunks/scanner-tdFPvDYi.js.map +1 -0
- package/dist/_chunks/segment-context-D9_89u34.js +34 -0
- package/dist/_chunks/segment-context-D9_89u34.js.map +1 -0
- package/dist/_chunks/singleflight-2lUWfcAk.js +54 -0
- package/dist/_chunks/singleflight-2lUWfcAk.js.map +1 -0
- package/dist/_chunks/{ssr-data-Ya2HJPFp.js → ssr-data-BQGhTPAK.js} +2 -17
- package/dist/_chunks/ssr-data-BQGhTPAK.js.map +1 -0
- package/dist/_chunks/{walkers-BU6z9xRV.js → walkers-DNX05dC0.js} +2 -2
- package/dist/_chunks/{walkers-BU6z9xRV.js.map → walkers-DNX05dC0.js.map} +1 -1
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/nitro.d.ts +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/analyze/crawl-entry.js +2 -2
- package/dist/analyze/graph-command.js +2 -2
- package/dist/cache/index.js +1 -1
- package/dist/cache/singleflight.d.ts +2 -0
- package/dist/cache/singleflight.d.ts.map +1 -1
- package/dist/cli.js +2 -2
- package/dist/client/browser-entry/hydrate.d.ts +21 -15
- package/dist/client/browser-entry/hydrate.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +4 -3
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/browser-entry/post-hydration.d.ts.map +1 -1
- package/dist/client/browser-entry/router-init.d.ts +17 -1
- package/dist/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/global-context.d.ts +15 -0
- package/dist/client/global-context.d.ts.map +1 -0
- package/dist/client/index.js +138 -35
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.d.ts +0 -1
- package/dist/client/internal.d.ts.map +1 -1
- package/dist/client/internal.js +206 -55
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/location-search.d.ts +12 -0
- package/dist/client/location-search.d.ts.map +1 -0
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/navigation-commit.d.ts +18 -0
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/navigation-context.d.ts +13 -11
- package/dist/client/navigation-context.d.ts.map +1 -1
- package/dist/client/navigation-root.d.ts +47 -108
- package/dist/client/navigation-root.d.ts.map +1 -1
- package/dist/client/navigation-transition.d.ts +136 -0
- package/dist/client/navigation-transition.d.ts.map +1 -0
- package/dist/client/nuqs-adapter.d.ts.map +1 -1
- package/dist/client/params-context.d.ts +4 -5
- package/dist/client/params-context.d.ts.map +1 -1
- package/dist/client/react-root.d.ts +44 -0
- package/dist/client/react-root.d.ts.map +1 -0
- package/dist/client/router-pipeline.d.ts +2 -2
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router-types.d.ts +12 -2
- package/dist/client/router-types.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +39 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/segment-context.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts +25 -14
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/client/segment-update-context.d.ts +3 -9
- package/dist/client/segment-update-context.d.ts.map +1 -1
- package/dist/client/slot-content-cache-context.d.ts +35 -0
- package/dist/client/slot-content-cache-context.d.ts.map +1 -0
- package/dist/client/ssr-data.d.ts +8 -2
- package/dist/client/ssr-data.d.ts.map +1 -1
- package/dist/client/state.d.ts +0 -15
- package/dist/client/state.d.ts.map +1 -1
- package/dist/client/use-pathname.d.ts +13 -11
- package/dist/client/use-pathname.d.ts.map +1 -1
- package/dist/client/use-search-params.d.ts +13 -13
- package/dist/client/use-search-params.d.ts.map +1 -1
- package/dist/client/use-segment-params.d.ts +18 -68
- package/dist/client/use-segment-params.d.ts.map +1 -1
- package/dist/config-types.d.ts +17 -0
- package/dist/config-types.d.ts.map +1 -1
- package/dist/config-validation.d.ts.map +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/dev-tools/holding-server.d.ts +15 -10
- package/dist/dev-tools/holding-server.d.ts.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +44 -43
- package/dist/index.js.map +1 -1
- package/dist/plugins/dev-server.d.ts.map +1 -1
- package/dist/plugins/entries.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +2 -2
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-write.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/routing/interception-overlap.d.ts +35 -0
- package/dist/routing/interception-overlap.d.ts.map +1 -0
- package/dist/routing/interception.d.ts.map +1 -1
- package/dist/rsc-runtime/ssr.d.ts +3 -1
- package/dist/rsc-runtime/ssr.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +6 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/csp-nonce.d.ts +45 -0
- package/dist/server/csp-nonce.d.ts.map +1 -0
- package/dist/server/default-status-page.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/flight-scripts.d.ts +5 -2
- package/dist/server/flight-scripts.d.ts.map +1 -1
- package/dist/server/html-injector-core.d.ts +17 -2
- package/dist/server/html-injector-core.d.ts.map +1 -1
- package/dist/server/html-injectors.d.ts +3 -2
- package/dist/server/html-injectors.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +86 -37
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-render.d.ts.map +1 -1
- package/dist/server/node-stream-transforms.d.ts +3 -17
- package/dist/server/node-stream-transforms.d.ts.map +1 -1
- package/dist/server/nuqs-ssr-provider.d.ts +7 -3
- package/dist/server/nuqs-ssr-provider.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/prebuilt/key-discipline.d.ts +32 -3
- package/dist/server/prebuilt/key-discipline.d.ts.map +1 -1
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/render-utils.d.ts +4 -3
- package/dist/server/render-utils.d.ts.map +1 -1
- package/dist/server/rsc-entry/action-middleware-runner.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/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +22 -2
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/ssr-render.d.ts +5 -1
- package/dist/server/ssr-render.d.ts.map +1 -1
- package/dist/server/ssr-wrappers.d.ts +59 -27
- package/dist/server/ssr-wrappers.d.ts.map +1 -1
- package/dist/server/types.d.ts +10 -0
- package/dist/server/types.d.ts.map +1 -1
- package/dist/shims/navigation-rsc.d.ts +21 -0
- package/dist/shims/navigation-rsc.d.ts.map +1 -0
- package/docs/api/30-api-server.mdx +1 -0
- package/docs/api/35-api-typescript.mdx +4 -83
- package/docs/learn/03-fetching-data.mdx +1 -1
- package/docs/learn/{03b-access-control.mdx → 04-access-control.mdx} +2 -17
- package/docs/learn/05-the-flush-point.mdx +175 -0
- package/docs/learn/{05-typed-params.mdx → 06-typed-params.mdx} +1 -1
- package/docs/learn/07-typed-routes.mdx +25 -49
- package/docs/learn/{08-streaming.mdx → 09-streaming.mdx} +1 -7
- package/docs/learn/{10-middleware.mdx → 11-middleware.mdx} +1 -0
- package/package.json +3 -3
- package/src/adapters/nitro.ts +7 -7
- package/src/cache/singleflight.ts +5 -0
- package/src/client/browser-entry/hydrate.ts +54 -104
- package/src/client/browser-entry/index.ts +16 -6
- package/src/client/browser-entry/post-hydration.ts +3 -2
- package/src/client/browser-entry/router-init.ts +84 -33
- package/src/client/global-context.ts +31 -0
- package/src/client/internal.ts +1 -2
- package/src/client/link.tsx +18 -18
- package/src/client/location-search.ts +15 -0
- package/src/client/navigation-api.ts +4 -2
- package/src/client/navigation-commit.ts +48 -2
- package/src/client/navigation-context.ts +25 -37
- package/src/client/navigation-root.tsx +55 -411
- package/src/client/navigation-transition.ts +278 -0
- package/src/client/nuqs-adapter.tsx +4 -5
- package/src/client/params-context.ts +13 -18
- package/src/client/react-root.ts +72 -0
- package/src/client/router-lifecycle.ts +1 -1
- package/src/client/router-pipeline.ts +96 -22
- package/src/client/router-types.ts +12 -2
- package/src/client/router.ts +48 -36
- package/src/client/segment-cache.ts +70 -2
- package/src/client/segment-context.ts +7 -4
- package/src/client/segment-outlet.tsx +41 -86
- package/src/client/segment-update-context.ts +7 -26
- package/src/client/slot-content-cache-context.ts +43 -0
- package/src/client/ssr-data.ts +8 -2
- package/src/client/state.ts +0 -26
- package/src/client/use-pathname.ts +21 -31
- package/src/client/use-search-params.ts +31 -29
- package/src/client/use-segment-params.ts +27 -126
- package/src/config-types.ts +17 -0
- package/src/config-validation.ts +17 -0
- package/src/dev-tools/holding-server.ts +23 -12
- package/src/index.ts +26 -11
- package/src/plugins/dev-server.ts +9 -12
- package/src/plugins/entries.ts +3 -0
- package/src/plugins/shims.ts +8 -7
- package/src/plugins/static-build.ts +9 -5
- package/src/react-canary.d.ts +2 -0
- package/src/routing/codegen-write.ts +2 -0
- package/src/routing/interception-overlap.ts +141 -0
- package/src/routing/interception.ts +118 -5
- package/src/rsc-runtime/ssr.ts +3 -2
- package/src/server/als-registry.ts +6 -0
- package/src/server/csp-nonce.ts +70 -0
- package/src/server/default-status-page.ts +1 -0
- package/src/server/deny-renderer.ts +7 -3
- package/src/server/flight-scripts.ts +9 -4
- package/src/server/html-injector-core.ts +26 -9
- package/src/server/html-injectors.ts +8 -8
- package/src/server/metadata-render.ts +26 -4
- package/src/server/node-stream-transforms.ts +7 -20
- package/src/server/nuqs-ssr-provider.tsx +8 -7
- package/src/server/pipeline-phases.ts +5 -0
- package/src/server/prebuilt/key-discipline.ts +82 -13
- package/src/server/prebuilt-runtime.ts +2 -2
- package/src/server/primitives.ts +4 -4
- package/src/server/render-utils.ts +8 -4
- package/src/server/rsc-entry/action-middleware-runner.ts +7 -0
- package/src/server/rsc-entry/error-renderer.ts +5 -2
- package/src/server/rsc-entry/index.ts +8 -0
- package/src/server/rsc-entry/ssr-renderer.ts +11 -4
- package/src/server/ssr-bridge-types.ts +22 -2
- package/src/server/ssr-entry.ts +35 -28
- package/src/server/ssr-render.ts +13 -4
- package/src/server/ssr-wrappers.tsx +81 -61
- package/src/server/types.ts +10 -0
- package/src/shared/slot-params.ts +3 -4
- package/src/shims/navigation-rsc.ts +47 -0
- package/dist/_chunks/build-manifest-DWppEdLB.js.map +0 -1
- package/dist/_chunks/cache-api-CQeYzA5g.js.map +0 -1
- package/dist/_chunks/convention-lint-DO10_pVl.js.map +0 -1
- package/dist/_chunks/href-validation-CMc5JRls.js.map +0 -1
- package/dist/_chunks/scanner-BRIOmHE2.js.map +0 -1
- package/dist/_chunks/segment-context-CjOlyB8Y.js.map +0 -1
- package/dist/_chunks/slot-params-BCTmZkQB.js +0 -76
- package/dist/_chunks/slot-params-BCTmZkQB.js.map +0 -1
- package/dist/_chunks/ssr-data-Ya2HJPFp.js.map +0 -1
- package/dist/_chunks/use-segment-params-DzTBpkvj.js +0 -398
- package/dist/_chunks/use-segment-params-DzTBpkvj.js.map +0 -1
- package/docs/learn/04-loading-states.mdx +0 -67
- package/docs/learn/04b-the-flush-point.mdx +0 -115
- package/docs/learn/12-client-navigation.mdx +0 -176
- package/docs/learn/13-configuration.mdx +0 -166
- package/docs/more/01-advanced-routing.mdx +0 -344
- package/docs/more/02-advanced-forms.mdx +0 -137
- package/docs/more/03-coming-from-nextjs.mdx +0 -186
- package/docs/more/04-metadata-and-fonts.mdx +0 -193
- package/docs/more/04b-mdx.mdx +0 -229
- package/docs/more/05-content-collections.mdx +0 -90
- package/docs/more/06-instrumentation.mdx +0 -214
- package/docs/more/07-security.mdx +0 -129
- package/docs/more/08-developer-experience.mdx +0 -134
- package/docs/more/40-why-timber.mdx +0 -50
- package/docs/more/41-timber-vs-nextjs.mdx +0 -81
- package/docs/more/42-timber-vs-others.mdx +0 -68
- package/docs/more/50-ai-agent-instructions.mdx +0 -171
- /package/docs/learn/{06-forms-and-actions.mdx → 08-forms-and-actions.mdx} +0 -0
- /package/docs/learn/{09-caching.mdx → 10-caching.mdx} +0 -0
- /package/docs/learn/{11-error-handling.mdx → 12-error-handling.mdx} +0 -0
- /package/docs/learn/{14-deploying.mdx → 13-deploying.mdx} +0 -0
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Navigation transition — the router-level hand-off of a navigation's tree to
|
|
3
|
+
* React, and the supersession that governs it.
|
|
4
|
+
*
|
|
5
|
+
* `navigateTransition` runs a navigation's fetch OUTSIDE any transition scope
|
|
6
|
+
* and hands the resulting tree to React through a `NavigationRender`, which
|
|
7
|
+
* is a synchronous `startTransition` around `root.render` (client/react-root.ts).
|
|
8
|
+
* Two React behaviours force that shape; both were measured on React 19.2.7
|
|
9
|
+
* (tests/navigation-transition-suspense.test.ts pins the observable half).
|
|
10
|
+
*
|
|
11
|
+
* 1. A transition scope does not survive an `await`. `startTransition`
|
|
12
|
+
* restores the previous scope in its `finally`, which for an async
|
|
13
|
+
* callback runs when that callback RETURNS — i.e. at its first `await`.
|
|
14
|
+
* So an update scheduled past an await inside `startTransition(async …)`
|
|
15
|
+
* is an ordinary urgent update:
|
|
16
|
+
*
|
|
17
|
+
* startTransition(() => render(x)) -> 'HOME' held
|
|
18
|
+
* startTransition(async () => { await p; render(x) }) -> fallback shown
|
|
19
|
+
*
|
|
20
|
+
* Left uncorrected, every navigation whose new tree suspends replaces the
|
|
21
|
+
* visible page with a Suspense fallback — the exact thing NavigationRoot
|
|
22
|
+
* exists to prevent (TIM-1306). React documents the caveat under
|
|
23
|
+
* `startTransition`: updates after an await need their own transition.
|
|
24
|
+
*
|
|
25
|
+
* 2. Re-wrapping *inside* the async callback is not enough. Returning a
|
|
26
|
+
* thenable from `startTransition` hands it to `ReactSharedInternals.S`,
|
|
27
|
+
* which calls react-dom's `entangleAsyncAction`. That opens an action
|
|
28
|
+
* scope: `currentEntangledLane` collects EVERY transition update
|
|
29
|
+
* scheduled while the scope is open — including one from a nested,
|
|
30
|
+
* fully synchronous `startTransition` — and rendering that lane throws
|
|
31
|
+
* `currentEntangledActionThenable`, suspending until the action settles.
|
|
32
|
+
* Which is after the promise `navigateTransition` hands back, so a caller
|
|
33
|
+
* that awaits a navigation and then reads the DOM sees the departing
|
|
34
|
+
* page, and the TIM-1301 publish (a layout effect on the commit) is just
|
|
35
|
+
* as late. (Not `ReactSharedInternals.asyncTransitions` — that counter is
|
|
36
|
+
* write-only in 19.2.7.)
|
|
37
|
+
*
|
|
38
|
+
* No async action is created here, so every navigation commits as soon as
|
|
39
|
+
* React can render its destination — where TIM-1301 put it. That holds for
|
|
40
|
+
* ALL of them. It used not to hold for `<Link>`, which wrapped
|
|
41
|
+
* `router.navigate()` in its own `useTransition`: that action scope entangled
|
|
42
|
+
* the root's updates just the same, so a Link navigation committed only once
|
|
43
|
+
* the payload had finished decoding, and with it `pushState`, the segment
|
|
44
|
+
* cache and `timber:navigation-end`. Link now calls `router.navigate()`
|
|
45
|
+
* outside any transition and tracks its own `isPending` with a plain
|
|
46
|
+
* `useState` (TIM-1307).
|
|
47
|
+
*
|
|
48
|
+
* Nothing here may reopen an action scope: no `startTransition` callback in
|
|
49
|
+
* this path may return a thenable, and no caller may invoke this from inside
|
|
50
|
+
* a React action scope. That is a rule about `startTransition`, not about
|
|
51
|
+
* `perform` — `perform` is async by contract and is awaited OUTSIDE any
|
|
52
|
+
* transition scope, which is exactly why it is safe.
|
|
53
|
+
*
|
|
54
|
+
* Standalone `startTransition` rather than `useTransition`'s is deliberate:
|
|
55
|
+
* the hook's is tied to a single fiber and tracks one async callback at a
|
|
56
|
+
* time, and two overlapping navigations through it corrupted React's hook
|
|
57
|
+
* tracking ("Rendered more hooks than during the previous render"). Each
|
|
58
|
+
* navigation needs an independent lane. This matches the Next.js pattern
|
|
59
|
+
* (TIM-625): "No useTransition in the router at all — only standalone
|
|
60
|
+
* startTransition."
|
|
61
|
+
*
|
|
62
|
+
* See design/19-client-navigation.md §"How Pending State Works"
|
|
63
|
+
*/
|
|
64
|
+
|
|
65
|
+
import type { ReactNode } from 'react';
|
|
66
|
+
import type { NavigationRender } from './navigation-root.tsx';
|
|
67
|
+
|
|
68
|
+
// ─── Transition Result ──────────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a navigation's `perform()` hands back to the transition.
|
|
72
|
+
*
|
|
73
|
+
* Declared once and shared by every layer that passes it along — the router's
|
|
74
|
+
* `RouterDeps.navigateTransition` imports it too — so a field cannot be added
|
|
75
|
+
* to the producer and dropped by the adapter in between. `E` is the element
|
|
76
|
+
* type: `ReactNode` here, `unknown` in the router, which never touches it.
|
|
77
|
+
*/
|
|
78
|
+
export interface TransitionResult<E = ReactNode> {
|
|
79
|
+
/** The wrapped tree to render. */
|
|
80
|
+
element: E;
|
|
81
|
+
/** Resolves when the Flight stream finishes decoding, or null. */
|
|
82
|
+
decodePromise: Promise<void> | null;
|
|
83
|
+
/**
|
|
84
|
+
* Publishes the navigation's state — segment cache, pathname, address bar,
|
|
85
|
+
* history stack, and the client's record of the mounted tree — and
|
|
86
|
+
* announces it to listeners outside React.
|
|
87
|
+
*
|
|
88
|
+
* Run when React commits this tree, not when the tree is handed over: a
|
|
89
|
+
* tree React is still waiting on, or one a later navigation replaces first,
|
|
90
|
+
* has been given to React without being on screen. A superseded navigation
|
|
91
|
+
* never runs it and leaves every one of those consumers describing the
|
|
92
|
+
* route still on screen (TIM-1301).
|
|
93
|
+
*/
|
|
94
|
+
commit: () => void;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ─── Navigation Transition Counter ──────────────────────────────
|
|
98
|
+
// Monotonically increasing counter that increments each time
|
|
99
|
+
// navigateTransition() is called. Used to detect stale transitions:
|
|
100
|
+
// if a newer transition started while the current one's perform()
|
|
101
|
+
// was in flight, the current transition is stale and should reject.
|
|
102
|
+
//
|
|
103
|
+
// Separate from the link-pending navId (which only increments on
|
|
104
|
+
// link clicks). This counter covers all navigation types: link clicks,
|
|
105
|
+
// programmatic navigate(), refresh(), and handlePopState().
|
|
106
|
+
//
|
|
107
|
+
// Uses globalThis for singleton guarantee across chunks — same pattern
|
|
108
|
+
// as NavigationContext and the link pending store.
|
|
109
|
+
|
|
110
|
+
const NAV_TRANSITION_KEY = Symbol.for('__timber_nav_transition_counter');
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* `waiters` are woken on every bump so an in-flight navigation can stop
|
|
114
|
+
* waiting on its own payload the moment it is superseded — see
|
|
115
|
+
* `settleOnDecodeOrSupersession`.
|
|
116
|
+
*/
|
|
117
|
+
interface TransitionCounter {
|
|
118
|
+
id: number;
|
|
119
|
+
waiters: Set<() => void>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function getTransitionCounter(): TransitionCounter {
|
|
123
|
+
const g = globalThis as Record<symbol, unknown>;
|
|
124
|
+
const existing = g[NAV_TRANSITION_KEY] as Partial<TransitionCounter> | undefined;
|
|
125
|
+
if (!existing) {
|
|
126
|
+
const created: TransitionCounter = { id: 0, waiters: new Set() };
|
|
127
|
+
g[NAV_TRANSITION_KEY] = created;
|
|
128
|
+
return created;
|
|
129
|
+
}
|
|
130
|
+
// A duplicated copy of this module may have created the singleton before
|
|
131
|
+
// `waiters` existed. The object is shared across chunks, so fill it in
|
|
132
|
+
// rather than replacing it — replacing would strand the other copy's id.
|
|
133
|
+
existing.waiters ??= new Set();
|
|
134
|
+
return existing as TransitionCounter;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Bump the counter and wake everything waiting on an older transition. */
|
|
138
|
+
function bumpTransitionCounter(): number {
|
|
139
|
+
const counter = getTransitionCounter();
|
|
140
|
+
counter.id += 1;
|
|
141
|
+
for (const wake of [...counter.waiters]) wake();
|
|
142
|
+
return counter.id;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Invalidate all in-flight navigation transitions. Any navigateTransition()
|
|
147
|
+
* call whose perform() has not yet committed will reject with AbortError
|
|
148
|
+
* instead of committing its element.
|
|
149
|
+
*
|
|
150
|
+
* Called by the router when a render supersedes in-flight navigations
|
|
151
|
+
* WITHOUT going through navigateTransition() — the cached popstate replay
|
|
152
|
+
* renders directly, which doesn't bump the counter, so a stale forward
|
|
153
|
+
* navigation's render would otherwise pass the `counter.id !== transId`
|
|
154
|
+
* guard and commit the forward page over the replayed back page (TIM-1022).
|
|
155
|
+
*/
|
|
156
|
+
export function supersedeNavigationTransitions(): void {
|
|
157
|
+
bumpTransitionCounter();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Wait for the payload to finish decoding, OR for this transition to be
|
|
162
|
+
* superseded — whichever happens first.
|
|
163
|
+
*
|
|
164
|
+
* A superseded navigation must stop waiting on its own stream. The stream is
|
|
165
|
+
* deliberately NOT aborted once its tree has been handed to React (the tree
|
|
166
|
+
* may be on screen with boundaries still feeding from it — see
|
|
167
|
+
* `handedOffNavAbort` in `client/router-lifecycle.ts`), so there is nothing
|
|
168
|
+
* left to make `decodePromise` settle promptly. Awaiting it bare would keep
|
|
169
|
+
* the loser's `router.navigate()` promise pending for the rest of the stream
|
|
170
|
+
* — and forever if it stalls — which is what `<Link>`'s `isPending` is timed
|
|
171
|
+
* against, so the losing link would sit spinning while the winner loaded
|
|
172
|
+
* (codex on #1004).
|
|
173
|
+
*
|
|
174
|
+
* A decode *failure* still propagates: it is a real error for this
|
|
175
|
+
* navigation, and the caller's recovery is timed against it.
|
|
176
|
+
*/
|
|
177
|
+
function settleOnDecodeOrSupersession(
|
|
178
|
+
decodePromise: Promise<void>,
|
|
179
|
+
counter: TransitionCounter,
|
|
180
|
+
transId: number
|
|
181
|
+
): Promise<void> {
|
|
182
|
+
if (counter.id !== transId) return Promise.resolve();
|
|
183
|
+
return new Promise<void>((resolve, reject) => {
|
|
184
|
+
const stopWaiting = (): void => {
|
|
185
|
+
counter.waiters.delete(wake);
|
|
186
|
+
};
|
|
187
|
+
const wake = (): void => {
|
|
188
|
+
if (counter.id !== transId) {
|
|
189
|
+
stopWaiting();
|
|
190
|
+
resolve();
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
counter.waiters.add(wake);
|
|
194
|
+
decodePromise.then(
|
|
195
|
+
() => {
|
|
196
|
+
stopWaiting();
|
|
197
|
+
resolve();
|
|
198
|
+
},
|
|
199
|
+
(error: unknown) => {
|
|
200
|
+
stopWaiting();
|
|
201
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
202
|
+
}
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ─── navigateTransition ─────────────────────────────────────────
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Run a full navigation, handing its tree to React in a transition.
|
|
211
|
+
*
|
|
212
|
+
* The `perform` callback fetches the RSC payload, updates router state, and
|
|
213
|
+
* returns the wrapped React element. `perform` is async by contract and runs
|
|
214
|
+
* OUTSIDE any transition scope — only the `render` that hands its result over
|
|
215
|
+
* is wrapped, in a synchronous `startTransition` (see the module comment).
|
|
216
|
+
*
|
|
217
|
+
* Do not call this from inside a React action scope, and never let a
|
|
218
|
+
* `startTransition` callback in this path return a thenable: either opens an
|
|
219
|
+
* action scope that entangles the update and defers the commit until the
|
|
220
|
+
* action settles (TIM-1306, TIM-1307).
|
|
221
|
+
*
|
|
222
|
+
* Returns a Promise that resolves when the async work completes — the payload
|
|
223
|
+
* is fetched, decoded, and handed to React. It does **not** wait for React to
|
|
224
|
+
* commit the tree, so the state that publishes on that commit (address bar,
|
|
225
|
+
* segment cache, history entry, pathname) may still be a beat behind when it
|
|
226
|
+
* resolves. Anything that needs the destination to be current should listen
|
|
227
|
+
* for `timber:navigation-end`, which is dispatched by the publish itself.
|
|
228
|
+
*
|
|
229
|
+
* Awaiting the publish here was tried and rejected: it makes the promise
|
|
230
|
+
* depend on a React commit, which never arrives if the root unmounts
|
|
231
|
+
* mid-navigation, and deadlocks any caller that awaits a navigation inside
|
|
232
|
+
* `act()`.
|
|
233
|
+
*
|
|
234
|
+
* Rejects with an `AbortError` when a newer transition started while this
|
|
235
|
+
* one's `perform()` was in flight — the stale tree is never handed to React
|
|
236
|
+
* and its `commit` never runs (TIM-629, TIM-1301).
|
|
237
|
+
*
|
|
238
|
+
* Used for: navigate(), refresh(), popstate with fetch.
|
|
239
|
+
*/
|
|
240
|
+
export function navigateTransition(
|
|
241
|
+
perform: () => Promise<TransitionResult>,
|
|
242
|
+
render: NavigationRender
|
|
243
|
+
): Promise<void> {
|
|
244
|
+
// Increment the transition counter SYNCHRONOUSLY (before any await). Each
|
|
245
|
+
// call gets a unique transId; the counter is the same globalThis
|
|
246
|
+
// singleton, so a newer call always has a higher id.
|
|
247
|
+
const counter = getTransitionCounter();
|
|
248
|
+
const transId = bumpTransitionCounter();
|
|
249
|
+
|
|
250
|
+
const superseded = () => new DOMException('Navigation superseded', 'AbortError');
|
|
251
|
+
|
|
252
|
+
return (async () => {
|
|
253
|
+
const { element, decodePromise, commit } = await perform();
|
|
254
|
+
if (counter.id !== transId) {
|
|
255
|
+
decodePromise?.catch(() => {});
|
|
256
|
+
throw superseded();
|
|
257
|
+
}
|
|
258
|
+
// Hand the tree over with its commit attached. NavigationRoot runs it if
|
|
259
|
+
// and when React commits this tree, so a navigation that is superseded
|
|
260
|
+
// while its payload streams publishes nothing (TIM-1301).
|
|
261
|
+
//
|
|
262
|
+
// This is THE update that must not hide the departing page (TIM-1306);
|
|
263
|
+
// `render` is the synchronous transition around `root.render`.
|
|
264
|
+
render(element, commit);
|
|
265
|
+
// React may commit the tree before this settles — that is the point:
|
|
266
|
+
// the destination reveals as React is able to render it
|
|
267
|
+
// rather than waiting for the whole Flight stream. The await is here so
|
|
268
|
+
// the promise this function hands back still means "the payload is
|
|
269
|
+
// decoded", which is what the router's scroll restoration, the
|
|
270
|
+
// Navigation API deferred and `<Link>`'s `isPending` are timed against.
|
|
271
|
+
//
|
|
272
|
+
// ...unless this navigation loses first, in which case it stops waiting
|
|
273
|
+
// on a stream that is no longer its business. See
|
|
274
|
+
// `settleOnDecodeOrSupersession`.
|
|
275
|
+
if (decodePromise) await settleOnDecodeOrSupersession(decodePromise, counter, transId);
|
|
276
|
+
if (counter.id !== transId) throw superseded();
|
|
277
|
+
})();
|
|
278
|
+
}
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
} from 'nuqs/adapters/custom';
|
|
22
22
|
import { getRouter } from './router-ref.ts';
|
|
23
23
|
import { getNavigationApi } from './navigation-api.ts';
|
|
24
|
+
import { locationSearch } from './location-search.ts';
|
|
24
25
|
|
|
25
26
|
// ─── Adapter Hook ─────────────────────────────────────────────────
|
|
26
27
|
|
|
@@ -32,15 +33,13 @@ import { getNavigationApi } from './navigation-api.ts';
|
|
|
32
33
|
* (used by nuqs for selective re-rendering)
|
|
33
34
|
*/
|
|
34
35
|
function useTimberAdapter(_watchKeys: string[]): AdapterInterface {
|
|
35
|
-
const [searchParams, setSearchParams] = useState(
|
|
36
|
-
() => new URLSearchParams(window.location.search)
|
|
37
|
-
);
|
|
36
|
+
const [searchParams, setSearchParams] = useState(() => new URLSearchParams(locationSearch()));
|
|
38
37
|
|
|
39
38
|
// Sync search params on popstate (back/forward) and after
|
|
40
39
|
// timber navigations that change the URL.
|
|
41
40
|
useEffect(() => {
|
|
42
41
|
function sync() {
|
|
43
|
-
setSearchParams(new URLSearchParams(
|
|
42
|
+
setSearchParams(new URLSearchParams(locationSearch()));
|
|
44
43
|
}
|
|
45
44
|
|
|
46
45
|
window.addEventListener('popstate', sync);
|
|
@@ -97,7 +96,7 @@ function useTimberAdapter(_watchKeys: string[]): AdapterInterface {
|
|
|
97
96
|
return {
|
|
98
97
|
searchParams,
|
|
99
98
|
updateUrl,
|
|
100
|
-
getSearchParamsSnapshot: () => new URLSearchParams(
|
|
99
|
+
getSearchParamsSnapshot: () => new URLSearchParams(locationSearch()),
|
|
101
100
|
};
|
|
102
101
|
}
|
|
103
102
|
|
|
@@ -31,7 +31,6 @@
|
|
|
31
31
|
'use client';
|
|
32
32
|
|
|
33
33
|
import React, { createElement, useMemo, use } from 'react';
|
|
34
|
-
import { _setCurrentParams, _setCurrentSlotParams } from './state.ts';
|
|
35
34
|
import { toNullProtoRecord, type CoercedParams } from '../shared/param-value.ts';
|
|
36
35
|
import { readPublishedParams, type PublishedParams } from '../shared/payload-root.ts';
|
|
37
36
|
import type { SlotParamsRecord } from '../shared/slot-params.ts';
|
|
@@ -53,7 +52,9 @@ export type ParamsContextValue = PublishedParams;
|
|
|
53
52
|
* `useParamsContext()` arrives through the client-reference graph with the
|
|
54
53
|
* app's own components. A duplicate would put the provider on instance A and
|
|
55
54
|
* every reader on instance B, so `useContext` returns `null` and every
|
|
56
|
-
* `useSegmentParams()` call
|
|
55
|
+
* `useSegmentParams()` call throws the outside-the-timber-app-tree error
|
|
56
|
+
* even though the provider is mounted (the module-snapshot fallback it once
|
|
57
|
+
* silently landed on was deleted in TIM-1425).
|
|
57
58
|
*
|
|
58
59
|
* This module was the one client context without the guard — harmless while
|
|
59
60
|
* the provider travelled inside the payload, in the same graph as its readers,
|
|
@@ -90,11 +91,10 @@ function getOrCreateContext(): React.Context<ParamsContextValue | null> {
|
|
|
90
91
|
const ParamsContext = getOrCreateContext();
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
|
-
* Read the params provided by the tree. Returns null when no provider
|
|
94
|
-
* above the caller — a component rendered outside a timber route
|
|
95
|
-
*
|
|
96
|
-
*
|
|
97
|
-
* context instead, and there is no client-owned tree to hold a provider).
|
|
94
|
+
* Read the params provided by the tree. Returns null only when no provider
|
|
95
|
+
* is above the caller — a component rendered outside a timber route. During
|
|
96
|
+
* SSR the wrapper chain mounts `PayloadRoot` too (TIM-1424), so both sides
|
|
97
|
+
* resolve through this context.
|
|
98
98
|
*/
|
|
99
99
|
export function useParamsContext(): ParamsContextValue | null {
|
|
100
100
|
return React.useContext(ParamsContext);
|
|
@@ -115,13 +115,12 @@ interface ParamsProviderProps {
|
|
|
115
115
|
* the tree would shadow this one for the region below it, which is precisely
|
|
116
116
|
* the defect TIM-1297 fixed.
|
|
117
117
|
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* double-invoked render in StrictMode writes the same record twice.
|
|
118
|
+
* This used to also write a module-level snapshot during render, as the
|
|
119
|
+
* fallback for `useSegmentParams()` called outside a component. That tier is
|
|
120
|
+
* gone (TIM-1425) — the provider is unconditional on every render path,
|
|
121
|
+
* browser and SSR alike, so the hook reads context or throws. Removing the
|
|
122
|
+
* write also removes render-phase shared mutation from the SSR environment,
|
|
123
|
+
* where concurrent requests rendered through this component.
|
|
125
124
|
*/
|
|
126
125
|
function ParamsProvider({ params, slotParams, children }: ParamsProviderProps) {
|
|
127
126
|
// Restore the null prototype the wire could not carry. Flight rejects a
|
|
@@ -139,10 +138,6 @@ function ParamsProvider({ params, slotParams, children }: ParamsProviderProps) {
|
|
|
139
138
|
[params, slotParams]
|
|
140
139
|
);
|
|
141
140
|
|
|
142
|
-
// Keep the out-of-component fallback in step with the tree being rendered.
|
|
143
|
-
_setCurrentParams(value.params);
|
|
144
|
-
_setCurrentSlotParams(value.slotParams);
|
|
145
|
-
|
|
146
141
|
return createElement(ParamsContext.Provider, { value }, children);
|
|
147
142
|
}
|
|
148
143
|
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* React root host — the one React root the router renders the page through.
|
|
3
|
+
*
|
|
4
|
+
* The router owns the displayed tree and drives React: every render is
|
|
5
|
+
* `startTransition(() => root.render(<NavigationRoot rendered={…} />))`,
|
|
6
|
+
* synchronous, with a fresh `rendered` object whose `publish` NavigationRoot
|
|
7
|
+
* runs when React commits that tree (TIM-1301, TIM-1431).
|
|
8
|
+
*
|
|
9
|
+
* The root itself is created lazily. On the RSC-payload path `hydrate()`
|
|
10
|
+
* creates it by hydrating the server HTML. On the JS-only path (no inlined
|
|
11
|
+
* payload, TIM-600) nothing may create it at bootstrap — `createRoot(document)`
|
|
12
|
+
* followed by a render would take React ownership of the document and blank
|
|
13
|
+
* the SSR HTML — so the first `render()` (the first navigation, or a
|
|
14
|
+
* revalidation after a server action) creates it with the tree it renders.
|
|
15
|
+
* That first render carries no placeholder, and a fresh root with nothing
|
|
16
|
+
* committed leaves the container alone until it can commit, so the SSR HTML
|
|
17
|
+
* stays until the destination is ready (TIM-580).
|
|
18
|
+
*
|
|
19
|
+
* `container` is `document` in production; tests pass an element.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { createElement, startTransition, type ReactNode } from 'react';
|
|
23
|
+
import { createRoot, hydrateRoot, type HydrationOptions, type Root } from 'react-dom/client';
|
|
24
|
+
import { NavigationRoot, type NavigationRender, type RenderedTree } from './navigation-root.tsx';
|
|
25
|
+
import type { TopLoaderConfig } from './top-loader.tsx';
|
|
26
|
+
|
|
27
|
+
export interface ReactRootHost {
|
|
28
|
+
/**
|
|
29
|
+
* Hydrate the server HTML with `element` as the initial tree. Nothing is
|
|
30
|
+
* published on that commit — the address bar, pathname and history already
|
|
31
|
+
* describe the page the server sent.
|
|
32
|
+
*/
|
|
33
|
+
hydrate(element: ReactNode, options?: HydrationOptions): void;
|
|
34
|
+
/**
|
|
35
|
+
* Hand a tree to React in a synchronous transition; `publish` runs when
|
|
36
|
+
* React commits it. Creates the root on first use.
|
|
37
|
+
*/
|
|
38
|
+
render: NavigationRender;
|
|
39
|
+
/** Tear the root down (tests). */
|
|
40
|
+
unmount(): void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function createReactRoot(options: {
|
|
44
|
+
container: Document | Element;
|
|
45
|
+
topLoaderConfig?: TopLoaderConfig;
|
|
46
|
+
}): ReactRootHost {
|
|
47
|
+
const { container, topLoaderConfig } = options;
|
|
48
|
+
let root: Root | null = null;
|
|
49
|
+
|
|
50
|
+
const rootElement = (rendered: RenderedTree) =>
|
|
51
|
+
createElement(NavigationRoot, { rendered, topLoaderConfig });
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
hydrate(element, hydrationOptions) {
|
|
55
|
+
root = hydrateRoot(container, rootElement({ element, publish: null }), hydrationOptions);
|
|
56
|
+
},
|
|
57
|
+
render(element, publish) {
|
|
58
|
+
const target = (root ??= createRoot(container));
|
|
59
|
+
// Synchronous, and returns nothing: an async callback would drop the
|
|
60
|
+
// transition scope at its first await, and a returned thenable would
|
|
61
|
+
// open an action scope that holds the commit until it settles
|
|
62
|
+
// (TIM-1306, TIM-1307 — see client/navigation-transition.ts).
|
|
63
|
+
startTransition(() => {
|
|
64
|
+
target.render(rootElement({ element, publish }));
|
|
65
|
+
});
|
|
66
|
+
},
|
|
67
|
+
unmount() {
|
|
68
|
+
root?.unmount();
|
|
69
|
+
root = null;
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* See design/19-client-navigation.md §"How Pending State Works".
|
|
17
17
|
*/
|
|
18
18
|
|
|
19
|
-
import { supersedeNavigationTransitions } from './navigation-
|
|
19
|
+
import { supersedeNavigationTransitions } from './navigation-transition.ts';
|
|
20
20
|
import type { RouterPhase } from './router-types.ts';
|
|
21
21
|
|
|
22
22
|
/** The subset of `RouterDeps` the navigation lifecycle needs. */
|
|
@@ -16,13 +16,31 @@
|
|
|
16
16
|
import { fetchRscPayload, NonRscResponse } from './rsc-fetch.ts';
|
|
17
17
|
import type { FetchResult } from './rsc-fetch.ts';
|
|
18
18
|
import { readPublishedParams, type ParamsSource } from '../shared/payload-root.ts';
|
|
19
|
-
import type { PrefetchCache, PrefetchKey } from './segment-cache.ts';
|
|
19
|
+
import type { PrefetchCache, PrefetchKey, FlightOutcome } from './segment-cache.ts';
|
|
20
20
|
import { prefetchScopeOf } from './segment-cache.ts';
|
|
21
21
|
import type { StateTree } from '../shared/segment-info.ts';
|
|
22
22
|
import type { NavigationState } from './navigation-context.ts';
|
|
23
|
-
import type
|
|
23
|
+
import { isPartialNavigation, type NavigationCommitter } from './navigation-commit.ts';
|
|
24
24
|
import type { RouterDeps } from './router-types.ts';
|
|
25
25
|
import { recordSkew } from './router-skew.ts';
|
|
26
|
+
import { SingleflightTimeoutError } from '../cache/singleflight.ts';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Race a promise against an abort signal. The promise continues regardless —
|
|
30
|
+
* only the *await* gives up. Used so a click superseded by a newer navigation
|
|
31
|
+
* stops waiting for a shared prefetch flight without aborting the flight
|
|
32
|
+
* itself (TIM-1438).
|
|
33
|
+
*/
|
|
34
|
+
function raceAbort<T>(promise: Promise<T>, signal?: AbortSignal): Promise<T> {
|
|
35
|
+
if (!signal) return promise;
|
|
36
|
+
if (signal.aborted) return Promise.reject(signal.reason);
|
|
37
|
+
return Promise.race([
|
|
38
|
+
promise,
|
|
39
|
+
new Promise<never>((_, reject) => {
|
|
40
|
+
signal.addEventListener('abort', () => reject(signal.reason), { once: true });
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
}
|
|
26
44
|
|
|
27
45
|
/** A fetched payload plus the state update that makes it the current page. */
|
|
28
46
|
export type NavigationPayload = FetchResult & {
|
|
@@ -64,7 +82,12 @@ export interface NavigationPipeline {
|
|
|
64
82
|
perform: () => Promise<NavigationPayload>
|
|
65
83
|
) => Promise<void>;
|
|
66
84
|
/** Render a decoded payload into the DOM if a renderer is available. */
|
|
67
|
-
renderPayload: (
|
|
85
|
+
renderPayload: (
|
|
86
|
+
payload: unknown,
|
|
87
|
+
navState: NavigationState,
|
|
88
|
+
params: ParamsSource,
|
|
89
|
+
commit: () => void
|
|
90
|
+
) => void;
|
|
68
91
|
/** Resolve thenable payloads on the fallback (test) path. */
|
|
69
92
|
resolveForFallback: (payload: unknown) => Promise<unknown>;
|
|
70
93
|
}
|
|
@@ -109,10 +132,6 @@ export function prefetchKeyFor(
|
|
|
109
132
|
return { url, from: departingPathname(departingUrl), scope: prefetchScopeOf(stateTree) };
|
|
110
133
|
}
|
|
111
134
|
|
|
112
|
-
function isPartialNavigation(skippedSegments: string[] | null | undefined): boolean {
|
|
113
|
-
return skippedSegments != null && skippedSegments.length > 0;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
135
|
/**
|
|
117
136
|
* Build a segment updates map for partial navigation. Identifies the
|
|
118
137
|
* first non-skipped segment and maps it to the payload content.
|
|
@@ -155,10 +174,21 @@ export function createNavigationPipeline({
|
|
|
155
174
|
markHandedOff,
|
|
156
175
|
forgetOlderHandoffs,
|
|
157
176
|
}: NavigationPipelineDeps): NavigationPipeline {
|
|
158
|
-
/**
|
|
159
|
-
|
|
177
|
+
/**
|
|
178
|
+
* Render a decoded RSC payload into the DOM, publishing `commit` when the
|
|
179
|
+
* tree is current. The renderer owns that moment (see `RootRenderer`);
|
|
180
|
+
* with no renderer there is no tree to wait for, so it publishes now.
|
|
181
|
+
*/
|
|
182
|
+
function renderPayload(
|
|
183
|
+
payload: unknown,
|
|
184
|
+
navState: NavigationState,
|
|
185
|
+
params: ParamsSource,
|
|
186
|
+
commit: () => void
|
|
187
|
+
): void {
|
|
160
188
|
if (deps.renderRoot) {
|
|
161
|
-
deps.renderRoot(payload, navState, params);
|
|
189
|
+
deps.renderRoot(payload, navState, params, commit);
|
|
190
|
+
} else {
|
|
191
|
+
commit();
|
|
162
192
|
}
|
|
163
193
|
}
|
|
164
194
|
|
|
@@ -260,9 +290,11 @@ export function createNavigationPipeline({
|
|
|
260
290
|
// Fallback: no transition (tests, no React tree)
|
|
261
291
|
const result = await perform();
|
|
262
292
|
handOff();
|
|
263
|
-
commitAndForget(result.commit)
|
|
264
|
-
if (
|
|
265
|
-
|
|
293
|
+
const commit = commitAndForget(result.commit);
|
|
294
|
+
if (isPartialNavigation(result.skippedSegments)) {
|
|
295
|
+
commit();
|
|
296
|
+
} else {
|
|
297
|
+
renderPayload(result.payload, result.navState, await result.params, commit);
|
|
266
298
|
}
|
|
267
299
|
}
|
|
268
300
|
|
|
@@ -320,9 +352,52 @@ export function createNavigationPipeline({
|
|
|
320
352
|
}
|
|
321
353
|
: undefined;
|
|
322
354
|
|
|
355
|
+
// If a hover prefetch is in-flight, join it instead of issuing a
|
|
356
|
+
// duplicate (TIM-1438). The click races its await against its own signal
|
|
357
|
+
// so a superseded navigation gives up immediately — the shared flight
|
|
358
|
+
// continues for other consumers. On flight failure (network error,
|
|
359
|
+
// singleflight timeout), fall through to a fresh fetch — the failed
|
|
360
|
+
// hover should not block a click that might succeed.
|
|
361
|
+
if (result === undefined) {
|
|
362
|
+
const inflight = prefetchCache.joinInflight(cacheKey);
|
|
363
|
+
if (inflight) {
|
|
364
|
+
try {
|
|
365
|
+
const outcome = await raceAbort(inflight, options.signal);
|
|
366
|
+
if (outcome.kind === 'non-route') {
|
|
367
|
+
throw new NonRscResponse(url);
|
|
368
|
+
}
|
|
369
|
+
// Consume the ready entry so a second click re-fetches
|
|
370
|
+
prefetchCache.consume(cacheKey);
|
|
371
|
+
result = {
|
|
372
|
+
payload: outcome.result.payload,
|
|
373
|
+
params: outcome.result.params ?? readPublishedParams(undefined),
|
|
374
|
+
decodePromise: outcome.result.decodePromise ?? null,
|
|
375
|
+
segmentInfo: outcome.result.segmentInfo ?? null,
|
|
376
|
+
skippedSegments: outcome.result.skippedSegments ?? null,
|
|
377
|
+
status: outcome.result.status ?? 200,
|
|
378
|
+
};
|
|
379
|
+
} catch (error) {
|
|
380
|
+
// Supersession aborts propagate — don't retry a cancelled navigation
|
|
381
|
+
if (error instanceof DOMException && error.name === 'AbortError') throw error;
|
|
382
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
383
|
+
// Retriable failures fall through to a fresh fetch: singleflight
|
|
384
|
+
// timeout (hover hung) and transport TypeError (transient network
|
|
385
|
+
// error). Framework control-flow errors — NonRscResponse,
|
|
386
|
+
// VersionSkewError, RedirectError, ServerErrorResponse — are
|
|
387
|
+
// definitive server answers and must reach navigate's recovery.
|
|
388
|
+
if (error instanceof SingleflightTimeoutError || error instanceof TypeError) {
|
|
389
|
+
// fall through to fresh fetch below
|
|
390
|
+
} else {
|
|
391
|
+
throw error;
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
323
397
|
if (result === undefined) {
|
|
324
|
-
//
|
|
325
|
-
//
|
|
398
|
+
// No in-flight hover prefetch, or the joined flight failed — fetch
|
|
399
|
+
// directly with the navigation's signal so superseded navigations
|
|
400
|
+
// abort immediately.
|
|
326
401
|
result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
|
|
327
402
|
}
|
|
328
403
|
|
|
@@ -357,17 +432,16 @@ export function createNavigationPipeline({
|
|
|
357
432
|
const params = await result.params;
|
|
358
433
|
|
|
359
434
|
// Prepare the atomic navigation-state update. It is published by the
|
|
360
|
-
// caller once this navigation is known to have won (TIM-1301).
|
|
361
|
-
//
|
|
362
|
-
//
|
|
363
|
-
//
|
|
364
|
-
const isPartial = isPartialNavigation(result.skippedSegments);
|
|
365
|
-
const hasSkippedSlots = result.segmentInfo?.some((s) => s.slot && s.skipped) ?? false;
|
|
435
|
+
// caller once this navigation is known to have won (TIM-1301). Whether
|
|
436
|
+
// the payload is stored for replay — not when it has skip holes — is
|
|
437
|
+
// `prepareNavigation`'s decision, made from `skippedSegments` and
|
|
438
|
+
// `segmentInfo` (TIM-1432).
|
|
366
439
|
const { navState, commit } = prepareNavigation(url, {
|
|
367
|
-
payload
|
|
440
|
+
payload,
|
|
368
441
|
params,
|
|
369
442
|
segmentInfo: result.segmentInfo,
|
|
370
443
|
status: result.status,
|
|
444
|
+
skippedSegments: result.skippedSegments,
|
|
371
445
|
});
|
|
372
446
|
|
|
373
447
|
return {
|
|
@@ -14,7 +14,7 @@ import type { ParamsSource } from '../shared/payload-root.ts';
|
|
|
14
14
|
import type { SegmentInfo } from '../shared/segment-info.ts';
|
|
15
15
|
import type { SegmentCache, PrefetchCache } from './segment-cache.ts';
|
|
16
16
|
import type { HistoryStack } from './history.ts';
|
|
17
|
-
import type { TransitionResult } from './navigation-
|
|
17
|
+
import type { TransitionResult } from './navigation-transition.ts';
|
|
18
18
|
|
|
19
19
|
export interface NavigationOptions {
|
|
20
20
|
/** Set to false to prevent scroll-to-top on forward navigation */
|
|
@@ -61,7 +61,17 @@ export type RscDecoder = (fetchPromise: Promise<Response>) => unknown;
|
|
|
61
61
|
export type RootRenderer = (
|
|
62
62
|
element: unknown,
|
|
63
63
|
navState: NavigationState,
|
|
64
|
-
params: ParamsSource
|
|
64
|
+
params: ParamsSource,
|
|
65
|
+
/**
|
|
66
|
+
* Publishes the state that makes this tree the current page (segment
|
|
67
|
+
* cache, pathname, history stack, address bar). The renderer runs it when
|
|
68
|
+
* the tree is on screen — in production from React's commit of the tree
|
|
69
|
+
* (`NavigationRoot`'s layout effect), a test double synchronously. It is not run before
|
|
70
|
+
* handing the tree over: a replay or revalidation whose tree suspends is
|
|
71
|
+
* given to React without being on screen, and publishing then advertises
|
|
72
|
+
* slot content keys the slot content cache has not recorded (TIM-1423).
|
|
73
|
+
*/
|
|
74
|
+
commit: () => void
|
|
65
75
|
) => void;
|
|
66
76
|
|
|
67
77
|
/**
|