@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
package/src/client/router.ts
CHANGED
|
@@ -219,6 +219,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
219
219
|
params,
|
|
220
220
|
segmentInfo: result.segmentInfo,
|
|
221
221
|
status: result.status,
|
|
222
|
+
skippedSegments: result.skippedSegments,
|
|
222
223
|
});
|
|
223
224
|
return { ...result, payload, params, navState, commit };
|
|
224
225
|
});
|
|
@@ -294,18 +295,20 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
294
295
|
// metadata (e.g., initial SSR page) clears the cache so the next
|
|
295
296
|
// forward navigation gets a full render.
|
|
296
297
|
//
|
|
297
|
-
//
|
|
298
|
-
//
|
|
299
|
-
//
|
|
300
|
-
//
|
|
298
|
+
// No fetch to be superseded — `runNavigation` has already superseded
|
|
299
|
+
// whatever was in flight before it (TIM-1022) — but the render still
|
|
300
|
+
// goes out through a transition React may hold (a replayed tree can
|
|
301
|
+
// suspend), so the publish rides that commit rather than running
|
|
302
|
+
// here. Publishing first would advertise the replayed route's slot
|
|
303
|
+
// keys while the slot content cache still describes the departing
|
|
304
|
+
// one (TIM-1423, codex on #1108).
|
|
301
305
|
const { navState, commit } = prepareNavigation(url, {
|
|
302
306
|
payload: entry.payload,
|
|
303
307
|
params: entry.params,
|
|
304
308
|
segmentInfo: entry.segmentInfo,
|
|
305
309
|
clearSegmentCacheOnEmpty: true,
|
|
306
310
|
});
|
|
307
|
-
commit
|
|
308
|
-
renderPayload(entry.payload, navState, entry.params);
|
|
311
|
+
renderPayload(entry.payload, navState, entry.params, commit);
|
|
309
312
|
restoreScrollAfterPaint(scrollY);
|
|
310
313
|
},
|
|
311
314
|
externalSignal
|
|
@@ -326,6 +329,9 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
326
329
|
/**
|
|
327
330
|
* Prefetch an RSC payload for a URL and store it in the prefetch cache.
|
|
328
331
|
* Called on hover of <Link prefetch> elements.
|
|
332
|
+
*
|
|
333
|
+
* Concurrent hovers and a click during the round-trip coalesce via the
|
|
334
|
+
* singleflight inside PrefetchCache (TIM-1438).
|
|
329
335
|
*/
|
|
330
336
|
function prefetch(url: string): void {
|
|
331
337
|
// A stale client hard-navigates on every navigation, so a prefetched RSC
|
|
@@ -348,35 +354,41 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
348
354
|
const cacheKey = prefetchKeyFor(fetchUrl, deps.getCurrentUrl(), stateTree);
|
|
349
355
|
const from = cacheKey.from;
|
|
350
356
|
|
|
351
|
-
// Don't prefetch if already cached
|
|
352
|
-
if (prefetchCache.
|
|
357
|
+
// Don't prefetch if already cached (ready or negative)
|
|
358
|
+
if (prefetchCache.has(cacheKey)) return;
|
|
353
359
|
if (historyStack.has(fetchUrl)) return;
|
|
354
360
|
|
|
355
|
-
// Fire-and-forget
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
(
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
361
|
+
// Fire-and-forget. Concurrent hovers coalesce in the singleflight;
|
|
362
|
+
// a click during the round-trip joins the same flight via
|
|
363
|
+
// fetchOrCoalesce in performNavigationFetch (TIM-1438).
|
|
364
|
+
void prefetchCache
|
|
365
|
+
.fetchOrCoalesce(
|
|
366
|
+
cacheKey,
|
|
367
|
+
(signal) => fetchRscPayload(fetchUrl, deps, stateTree, from, signal),
|
|
368
|
+
(err) => err instanceof NonRscResponse
|
|
369
|
+
)
|
|
370
|
+
.then(
|
|
371
|
+
(outcome) => {
|
|
372
|
+
if (outcome.kind === 'ready') {
|
|
373
|
+
// The decode rejects separately from the fetch, so the stale
|
|
374
|
+
// shapes reach us here rather than in the rejection handler
|
|
375
|
+
// below — swallowing it wholesale threw away the same evidence
|
|
376
|
+
// (TIM-1275).
|
|
377
|
+
outcome.result.decodePromise?.catch((error: unknown) => void recordSkew(error));
|
|
378
|
+
}
|
|
379
|
+
},
|
|
380
|
+
(error) => {
|
|
381
|
+
// A prefetch can be the first thing to learn we are stale —
|
|
382
|
+
// during a rollout it may reach the new deployment while the
|
|
383
|
+
// click that follows reaches the old one and completes as an
|
|
384
|
+
// ordinary SPA navigation, throwing the evidence away. Record
|
|
385
|
+
// it, but never recover from here: nobody navigated, and a page
|
|
386
|
+
// load triggered by a hover is exactly the unasked-for reload
|
|
387
|
+
// this design removed. The next click takes the document-load
|
|
388
|
+
// path (TIM-1275).
|
|
389
|
+
recordSkew(error);
|
|
368
390
|
}
|
|
369
|
-
|
|
370
|
-
// rollout it may reach the new deployment while the click that
|
|
371
|
-
// follows reaches the old one and completes as an ordinary SPA
|
|
372
|
-
// navigation, throwing the evidence away. Record it, but never
|
|
373
|
-
// recover from here: nobody navigated, and a page load triggered by
|
|
374
|
-
// a hover is exactly the unasked-for reload this design removed. The
|
|
375
|
-
// next click takes the document-load path (TIM-1275).
|
|
376
|
-
recordSkew(error);
|
|
377
|
-
// Any other prefetch failure is non-fatal — navigation fetches fresh.
|
|
378
|
-
}
|
|
379
|
-
);
|
|
391
|
+
);
|
|
380
392
|
}
|
|
381
393
|
|
|
382
394
|
return {
|
|
@@ -404,15 +416,15 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
404
416
|
// Preserve existing segmentInfo so away-and-back navigation replays
|
|
405
417
|
// with a correct segment cache (TIM-1037).
|
|
406
418
|
const existingEntry = historyStack.get(currentUrl);
|
|
407
|
-
//
|
|
408
|
-
//
|
|
419
|
+
// Like the popstate replay: there is no fetch to be superseded and the
|
|
420
|
+
// payload is already decoded, but the publish still rides React's
|
|
421
|
+
// commit of the tree, not this call (TIM-1423).
|
|
409
422
|
const { navState, commit } = prepareNavigation(currentUrl, {
|
|
410
423
|
payload: tree,
|
|
411
424
|
params,
|
|
412
425
|
segmentInfo: existingEntry?.segmentInfo,
|
|
413
426
|
});
|
|
414
|
-
commit
|
|
415
|
-
renderPayload(tree, navState, params);
|
|
427
|
+
renderPayload(tree, navState, params, commit);
|
|
416
428
|
},
|
|
417
429
|
initSegmentCache: (segments: SegmentInfo[]) => updateSegmentCache(segments),
|
|
418
430
|
segmentCache,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import type { PublishedParams } from '../shared/payload-root.ts';
|
|
5
5
|
import type { SegmentInfo, StateTree } from '../shared/segment-info.ts';
|
|
6
|
+
import { createSingleflight } from '../cache/singleflight.ts';
|
|
6
7
|
|
|
7
8
|
// ─── Types ───────────────────────────────────────────────────────
|
|
8
9
|
|
|
@@ -273,7 +274,7 @@ export function buildSegmentTree(segments: SegmentInfo[]): SegmentNode | undefin
|
|
|
273
274
|
|
|
274
275
|
// ─── Prefetch Cache ──────────────────────────────────────────────
|
|
275
276
|
|
|
276
|
-
interface
|
|
277
|
+
interface ReadyEntry {
|
|
277
278
|
result: PrefetchResult;
|
|
278
279
|
expiresAt: number;
|
|
279
280
|
}
|
|
@@ -281,6 +282,13 @@ interface PrefetchEntry {
|
|
|
281
282
|
/** Sentinel value for negative cache entries (URL is not a route). */
|
|
282
283
|
const NEGATIVE_ENTRY: PrefetchResult = Object.freeze({ payload: null });
|
|
283
284
|
|
|
285
|
+
/**
|
|
286
|
+
* Timeout for the in-flight singleflight (TIM-1438). Per CLAUDE.md's
|
|
287
|
+
* singleflight rule: "Never write a coalescing Map without a timeout."
|
|
288
|
+
* A hung prefetch cannot block clicks or suppress hovers forever.
|
|
289
|
+
*/
|
|
290
|
+
const PREFETCH_SINGLEFLIGHT_TIMEOUT_MS = 5_000;
|
|
291
|
+
|
|
284
292
|
/**
|
|
285
293
|
* Identifies a prefetched payload. A payload is addressed by *every* input that
|
|
286
294
|
* changes the server's answer, not by the destination alone.
|
|
@@ -333,17 +341,28 @@ export function prefetchScopeOf(stateTree: StateTree | undefined): string {
|
|
|
333
341
|
return stateTree?.treePaths?.join('\u0000') ?? '';
|
|
334
342
|
}
|
|
335
343
|
|
|
344
|
+
/** Outcome of a prefetch flight — discriminated so NonRscResponse is a
|
|
345
|
+
* success (stores a negative entry) rather than a rejection. */
|
|
346
|
+
export type FlightOutcome = { kind: 'ready'; result: PrefetchResult } | { kind: 'non-route' };
|
|
347
|
+
|
|
336
348
|
/**
|
|
337
349
|
* Short-lived cache for hover-triggered prefetches. Entries expire after
|
|
338
350
|
* 30 seconds. When a link is clicked, the prefetched payload is consumed
|
|
339
351
|
* (moved to the history stack) and removed from this cache.
|
|
340
352
|
*
|
|
353
|
+
* In-flight dedup (TIM-1438): concurrent fetches for the same key are
|
|
354
|
+
* coalesced by a `createSingleflight` instance. A hover starts a flight;
|
|
355
|
+
* a click for the same key joins it instead of issuing a duplicate. The
|
|
356
|
+
* singleflight enforces a 5-second timeout and cleans up automatically
|
|
357
|
+
* on settlement.
|
|
358
|
+
*
|
|
341
359
|
* timber.js does NOT prefetch on viewport intersection — only explicit
|
|
342
360
|
* hover on <Link prefetch> triggers a prefetch.
|
|
343
361
|
*/
|
|
344
362
|
export class PrefetchCache {
|
|
345
363
|
private static readonly TTL_MS = 30_000;
|
|
346
|
-
private entries = new Map<string,
|
|
364
|
+
private entries = new Map<string, ReadyEntry>();
|
|
365
|
+
private flights = createSingleflight({ timeoutMs: PREFETCH_SINGLEFLIGHT_TIMEOUT_MS });
|
|
347
366
|
|
|
348
367
|
set(key: PrefetchKey, result: PrefetchResult): void {
|
|
349
368
|
this.entries.set(prefetchMapKey(key), {
|
|
@@ -363,6 +382,11 @@ export class PrefetchCache {
|
|
|
363
382
|
return entry.result;
|
|
364
383
|
}
|
|
365
384
|
|
|
385
|
+
/** True if a ready or negative entry exists for this key. */
|
|
386
|
+
has(key: PrefetchKey): boolean {
|
|
387
|
+
return this.get(key) !== undefined;
|
|
388
|
+
}
|
|
389
|
+
|
|
366
390
|
/** Get and remove the entry (used when navigation consumes a prefetch) */
|
|
367
391
|
consume(key: PrefetchKey): PrefetchResult | undefined {
|
|
368
392
|
const result = this.get(key);
|
|
@@ -372,6 +396,50 @@ export class PrefetchCache {
|
|
|
372
396
|
return result;
|
|
373
397
|
}
|
|
374
398
|
|
|
399
|
+
/**
|
|
400
|
+
* Fetch or coalesce with an in-flight fetch for this key (TIM-1438).
|
|
401
|
+
*
|
|
402
|
+
* Concurrent callers (hover + click, repeated hovers) get the same
|
|
403
|
+
* promise. On success the result is stored as a ready entry. On
|
|
404
|
+
* NonRscResponse a negative entry is stored and the outcome is
|
|
405
|
+
* `{ kind: 'non-route' }`. Other errors (network, version skew)
|
|
406
|
+
* reject — the singleflight cleans up the key and subsequent callers
|
|
407
|
+
* retry.
|
|
408
|
+
*
|
|
409
|
+
* The singleflight signal is passed to `doFetch` so a timed-out flight
|
|
410
|
+
* aborts the underlying fetch rather than leaving it running.
|
|
411
|
+
*/
|
|
412
|
+
fetchOrCoalesce(
|
|
413
|
+
key: PrefetchKey,
|
|
414
|
+
doFetch: (signal: AbortSignal) => Promise<PrefetchResult>,
|
|
415
|
+
isNonRoute: (error: unknown) => boolean
|
|
416
|
+
): Promise<FlightOutcome> {
|
|
417
|
+
return this.flights.do(prefetchMapKey(key), async (signal) => {
|
|
418
|
+
try {
|
|
419
|
+
const result = await doFetch(signal);
|
|
420
|
+
if (!signal.aborted) this.set(key, result);
|
|
421
|
+
return { kind: 'ready' as const, result };
|
|
422
|
+
} catch (err) {
|
|
423
|
+
if (isNonRoute(err)) {
|
|
424
|
+
if (!signal.aborted) this.setNegative(key);
|
|
425
|
+
return { kind: 'non-route' as const };
|
|
426
|
+
}
|
|
427
|
+
throw err;
|
|
428
|
+
}
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Join an in-flight singleflight fetch if one exists for this key.
|
|
434
|
+
* Returns the in-flight promise or undefined. Used by the click path to
|
|
435
|
+
* coalesce with a hover prefetch without starting a new flight — when no
|
|
436
|
+
* flight exists, the click issues its own fetch with the navigation's
|
|
437
|
+
* abort signal so superseded navigations abort immediately (TIM-1438).
|
|
438
|
+
*/
|
|
439
|
+
joinInflight(key: PrefetchKey): Promise<FlightOutcome> | undefined {
|
|
440
|
+
return this.flights.get(prefetchMapKey(key)) as Promise<FlightOutcome> | undefined;
|
|
441
|
+
}
|
|
442
|
+
|
|
375
443
|
/**
|
|
376
444
|
* Store a negative entry — the URL is not a route (non-RSC Content-Type).
|
|
377
445
|
*
|
|
@@ -60,10 +60,13 @@ export function SegmentProvider({
|
|
|
60
60
|
}: SegmentProviderProps) {
|
|
61
61
|
const value = useMemo(
|
|
62
62
|
() => ({ segments, parallelRouteKeys }),
|
|
63
|
-
// segments and parallelRouteKeys are static per layout —
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
|
|
63
|
+
// segments and parallelRouteKeys are static per layout — the layout's
|
|
64
|
+
// position in the tree is fixed. The arrays are used directly as deps
|
|
65
|
+
// (React Compiler requires simple expressions here). Referential stability
|
|
66
|
+
// of the context value doesn't matter: every consumer
|
|
67
|
+
// (useSelectedLayoutSegment/useSelectedLayoutSegments) also calls
|
|
68
|
+
// usePathname(), so it re-renders on every navigation regardless.
|
|
69
|
+
[segments, parallelRouteKeys]
|
|
67
70
|
);
|
|
68
71
|
return createElement(SegmentContext.Provider, { value }, children);
|
|
69
72
|
}
|
|
@@ -4,19 +4,29 @@
|
|
|
4
4
|
* Each layout in the segment tree is wrapped with a SegmentOutlet that:
|
|
5
5
|
* 1. Knows its segment path (prop from the server)
|
|
6
6
|
* 2. Reads from SegmentUpdateContext for partial navigation updates
|
|
7
|
-
* 3.
|
|
7
|
+
* 3. Reads from — and, at commit, writes to — the router's slot content
|
|
8
|
+
* cache, so a skipped parallel slot keeps showing what it last committed
|
|
8
9
|
*
|
|
9
|
-
* On full navigation: receives new children via props
|
|
10
|
+
* On full navigation: receives new children via props and renders them.
|
|
10
11
|
* On partial navigation (this segment skipped): the context map has no entry
|
|
11
|
-
* for this path, so the outlet
|
|
12
|
+
* for this path, so the outlet renders its children — which, because the
|
|
13
|
+
* router re-wraps the *retained* tree, are the same elements as before,
|
|
14
|
+
* preserving layout state.
|
|
12
15
|
* On partial navigation (this segment updated): the context map has content
|
|
13
16
|
* for this path, so the outlet renders the update.
|
|
17
|
+
* On a skipped parallel slot (`skip=true`, children null): the outlet renders
|
|
18
|
+
* the content it last committed for this path.
|
|
14
19
|
*
|
|
15
20
|
* Uses React context instead of useSyncExternalStore to stay compatible
|
|
16
21
|
* with concurrent rendering (transitions). All outlets re-render when the
|
|
17
22
|
* context value changes, but each bails out quickly if its segment has
|
|
18
23
|
* no update — same approach as Next.js LayoutRouter.
|
|
19
24
|
*
|
|
25
|
+
* Render is a pure read. The only write — recording committed slot content —
|
|
26
|
+
* happens in a layout effect, so a render React throws away (a superseded
|
|
27
|
+
* transition, a Suspense retry) cannot poison what a later `skip=true`
|
|
28
|
+
* resolves to (TIM-1423). See client/slot-content-cache-context.ts.
|
|
29
|
+
*
|
|
20
30
|
* Security: performance optimization only. The server always runs all
|
|
21
31
|
* access.ts files regardless of segment skipping.
|
|
22
32
|
* See design/13-security.md §"State tree manipulation".
|
|
@@ -24,8 +34,9 @@
|
|
|
24
34
|
|
|
25
35
|
'use client';
|
|
26
36
|
|
|
27
|
-
import { useContext,
|
|
37
|
+
import { useContext, useLayoutEffect, type ReactNode } from 'react';
|
|
28
38
|
import { SegmentUpdateContext } from './segment-update-context.ts';
|
|
39
|
+
import { SlotContentCacheContext } from './slot-content-cache-context.ts';
|
|
29
40
|
|
|
30
41
|
export interface SegmentOutletProps {
|
|
31
42
|
/**
|
|
@@ -41,101 +52,45 @@ export interface SegmentOutletProps {
|
|
|
41
52
|
children: ReactNode;
|
|
42
53
|
|
|
43
54
|
/**
|
|
44
|
-
* When true, the outlet
|
|
45
|
-
*
|
|
46
|
-
* sends an empty SegmentOutlet with skip=true, and the client keeps
|
|
47
|
-
* the previously
|
|
55
|
+
* When true, the outlet renders the content it last committed for this
|
|
56
|
+
* path instead of `children`. Used for skipped parallel route slots — the
|
|
57
|
+
* server sends an empty SegmentOutlet with skip=true, and the client keeps
|
|
58
|
+
* the previously committed slot content.
|
|
48
59
|
*/
|
|
49
60
|
skip?: boolean;
|
|
50
61
|
}
|
|
51
62
|
|
|
52
|
-
// ─── Slot Content Cache ──────────────────────────────────────────
|
|
53
|
-
// Persists slot content across SegmentOutlet fiber lifecycles.
|
|
54
|
-
// When React creates new fibers during full navigation (e.g., the
|
|
55
|
-
// server sends a complete new tree and React remounts the subtree),
|
|
56
|
-
// useRef starts fresh with null. Skipped slots (skip=true, children=null)
|
|
57
|
-
// would lose their cached content because contentRef.current is null.
|
|
58
|
-
//
|
|
59
|
-
// This globalThis-backed Map provides a fallback: SegmentOutlet stores
|
|
60
|
-
// rendered content here whenever it renders non-null slot content, and
|
|
61
|
-
// reads from it when skip=true but contentRef.current is null.
|
|
62
|
-
//
|
|
63
|
-
// Uses globalThis + Symbol.for for singleton guarantee across chunks —
|
|
64
|
-
// same pattern as NavigationContext and SegmentUpdateContext.
|
|
65
|
-
//
|
|
66
|
-
// SSR safety: SegmentOutlet is 'use client' — during RSC rendering it's
|
|
67
|
-
// serialized as a client reference and never executed. During SSR
|
|
68
|
-
// (renderToReadableStream), the component IS executed but skip=true is
|
|
69
|
-
// never sent (the server always renders full content). The cache may
|
|
70
|
-
// accumulate entries during SSR but they're harmless — the skip=true
|
|
71
|
-
// fallback path only activates on the client during SPA navigation.
|
|
72
|
-
|
|
73
|
-
const SLOT_CACHE_KEY = Symbol.for('__timber_slot_content_cache');
|
|
74
|
-
|
|
75
|
-
function getSlotContentCache(): Map<string, ReactNode> {
|
|
76
|
-
const g = globalThis as Record<symbol, unknown>;
|
|
77
|
-
if (!g[SLOT_CACHE_KEY]) {
|
|
78
|
-
g[SLOT_CACHE_KEY] = new Map<string, ReactNode>();
|
|
79
|
-
}
|
|
80
|
-
return g[SLOT_CACHE_KEY] as Map<string, ReactNode>;
|
|
81
|
-
}
|
|
82
|
-
|
|
83
63
|
/**
|
|
84
64
|
* Client component boundary at each layout segment in the element tree.
|
|
85
65
|
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
66
|
+
* Resolution order, all reads:
|
|
67
|
+
* 1. An update in SegmentUpdateContext for this path → render it.
|
|
68
|
+
* 2. `skip` → render the committed content for this path (null if none —
|
|
69
|
+
* a fresh outlet with nothing committed yet renders empty and is fully
|
|
70
|
+
* rendered on the next navigation).
|
|
71
|
+
* 3. Otherwise → render `children`.
|
|
90
72
|
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* across navigations because SegmentOutlet is reconciled, not remounted.
|
|
73
|
+
* Whatever a non-skip render resolves to is recorded once React commits it.
|
|
74
|
+
* A skip render records nothing: it displayed the cache, it did not add to it.
|
|
94
75
|
*/
|
|
95
76
|
export function SegmentOutlet({ segmentPath, children, skip }: SegmentOutletProps) {
|
|
96
77
|
const updates = useContext(SegmentUpdateContext);
|
|
97
|
-
const
|
|
98
|
-
const slotCache = getSlotContentCache();
|
|
78
|
+
const cache = useContext(SlotContentCacheContext);
|
|
99
79
|
|
|
100
80
|
const update = updates.get(segmentPath);
|
|
81
|
+
const hasUpdate = update !== undefined;
|
|
82
|
+
const content: ReactNode = hasUpdate
|
|
83
|
+
? update
|
|
84
|
+
: skip
|
|
85
|
+
? (cache?.get(segmentPath) ?? null)
|
|
86
|
+
: children;
|
|
101
87
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
// Skipped slot: keep cached content. The server sends skip=true for
|
|
109
|
-
// parallel route slots whose matched content hasn't changed. React
|
|
110
|
-
// reconciles this SegmentOutlet with the previous one at the same
|
|
111
|
-
// tree position, so the ref (cached content) is preserved.
|
|
112
|
-
if (skip) {
|
|
113
|
-
if (contentRef.current !== null) {
|
|
114
|
-
return contentRef.current;
|
|
115
|
-
}
|
|
116
|
-
// Fiber was remounted (contentRef is fresh null). Fall back to the
|
|
117
|
-
// globalThis-backed cache which persists across fiber lifecycles.
|
|
118
|
-
if (slotCache.has(segmentPath)) {
|
|
119
|
-
const cached = slotCache.get(segmentPath)!;
|
|
120
|
-
contentRef.current = cached;
|
|
121
|
-
return cached;
|
|
122
|
-
}
|
|
123
|
-
// No cached content anywhere — the SegmentOutlet instance is fresh
|
|
124
|
-
// and no prior render has populated the cache. Fall through to render
|
|
125
|
-
// children (null). The slot will be fully rendered next navigation.
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
if (contentRef.current === null) {
|
|
129
|
-
contentRef.current = children;
|
|
130
|
-
slotCache.set(segmentPath, children);
|
|
131
|
-
return children;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
if (children !== contentRef.current) {
|
|
135
|
-
contentRef.current = children;
|
|
136
|
-
slotCache.set(segmentPath, children);
|
|
137
|
-
return children;
|
|
138
|
-
}
|
|
88
|
+
// Commit-time write. `hasUpdate || !skip` is "this render brought its own
|
|
89
|
+
// content" — the one case where the cache learns something new.
|
|
90
|
+
const record = hasUpdate || !skip;
|
|
91
|
+
useLayoutEffect(() => {
|
|
92
|
+
if (record && cache !== null) cache.set(segmentPath, content);
|
|
93
|
+
}, [record, cache, segmentPath, content]);
|
|
139
94
|
|
|
140
|
-
return
|
|
95
|
+
return content;
|
|
141
96
|
}
|
|
@@ -6,37 +6,18 @@
|
|
|
6
6
|
* as the context value. Mounted SegmentOutlet components read from this
|
|
7
7
|
* context to decide whether to render the update or their cached content.
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
* NavigationContext. The RSC client bundler can duplicate this module
|
|
11
|
-
* across chunks (browser-entry graph + client-reference graph). With
|
|
12
|
-
* ESM output, each chunk gets its own module scope — a bare createContext
|
|
13
|
-
* at module level would create separate instances per chunk. globalThis
|
|
14
|
-
* guarantees a single instance regardless of duplication.
|
|
15
|
-
*
|
|
9
|
+
* Singleton across bundler-duplicated chunks via `createGlobalContext`.
|
|
16
10
|
* See design/19-client-navigation.md §"Singleton Guarantee via globalThis"
|
|
17
11
|
*/
|
|
18
12
|
|
|
19
13
|
'use client';
|
|
20
14
|
|
|
21
|
-
import
|
|
15
|
+
import type { ReactNode } from 'react';
|
|
16
|
+
import { createGlobalContext } from './global-context.ts';
|
|
22
17
|
|
|
23
18
|
export const EMPTY_SEGMENT_UPDATES = new Map<string, ReactNode>();
|
|
24
19
|
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
| React.Context<Map<string, ReactNode>>
|
|
30
|
-
| undefined;
|
|
31
|
-
if (existing !== undefined) return existing;
|
|
32
|
-
if (typeof React.createContext === 'function') {
|
|
33
|
-
const ctx = React.createContext<Map<string, ReactNode>>(EMPTY_SEGMENT_UPDATES);
|
|
34
|
-
(globalThis as Record<symbol, unknown>)[CTX_KEY] = ctx;
|
|
35
|
-
return ctx;
|
|
36
|
-
}
|
|
37
|
-
// RSC environment — createContext not available. Return a dummy that
|
|
38
|
-
// won't be used (outlets only render on the client).
|
|
39
|
-
return undefined as unknown as React.Context<Map<string, ReactNode>>;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export const SegmentUpdateContext = getOrCreateContext();
|
|
20
|
+
export const SegmentUpdateContext = createGlobalContext<Map<string, ReactNode>>(
|
|
21
|
+
'__timber_segment_update_ctx',
|
|
22
|
+
EMPTY_SEGMENT_UPDATES
|
|
23
|
+
);
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SlotContentCacheContext — the router-owned record of slot content the
|
|
3
|
+
* client has *committed*.
|
|
4
|
+
*
|
|
5
|
+
* The server omits a parallel slot's content when the client advertised a
|
|
6
|
+
* matching content key (`skip=true`, TIM-1370), and the outlet at that
|
|
7
|
+
* position must render what it displayed before. This Map, keyed by
|
|
8
|
+
* segmentPath, is where that content lives.
|
|
9
|
+
*
|
|
10
|
+
* Ownership and write discipline (TIM-1423):
|
|
11
|
+
*
|
|
12
|
+
* - **One instance per router.** `createTimberRouter` creates the Map and
|
|
13
|
+
* provides it above every tree it hands to React — hydration included,
|
|
14
|
+
* which builds its chain through the same `renderTree`. It is not a
|
|
15
|
+
* module or `globalThis` singleton: a process-wide Map executed during
|
|
16
|
+
* SSR would be shared by concurrent requests.
|
|
17
|
+
* - **Written only at commit.** `SegmentOutlet` writes from a layout effect,
|
|
18
|
+
* never during render. A render React discards — a transition superseded
|
|
19
|
+
* before it commits, a Suspense retry — leaves the Map untouched, so a
|
|
20
|
+
* later `skip=true` can only ever resolve to content the user actually saw.
|
|
21
|
+
* The content-key record the client advertises (`SegmentCache`, published
|
|
22
|
+
* by `NavigationRoot`'s layout effect on the same commit) and this Map
|
|
23
|
+
* are therefore written by the same React commit and cannot describe
|
|
24
|
+
* different trees.
|
|
25
|
+
* - **`null` outside a router.** SSR renders no provider and the outlet then
|
|
26
|
+
* neither reads nor writes: the server never emits `skip` during SSR, and
|
|
27
|
+
* layout effects do not run there. The default is `null` rather than a
|
|
28
|
+
* shared Map so that "no provider" cannot silently become "shared cache".
|
|
29
|
+
*
|
|
30
|
+
* Singleton across bundler-duplicated chunks via `createGlobalContext`.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
'use client';
|
|
34
|
+
|
|
35
|
+
import type { ReactNode } from 'react';
|
|
36
|
+
import { createGlobalContext } from './global-context.ts';
|
|
37
|
+
|
|
38
|
+
export type SlotContentCache = Map<string, ReactNode>;
|
|
39
|
+
|
|
40
|
+
export const SlotContentCacheContext = createGlobalContext<SlotContentCache | null>(
|
|
41
|
+
'__timber_slot_content_cache_ctx',
|
|
42
|
+
null
|
|
43
|
+
);
|
package/src/client/ssr-data.ts
CHANGED
|
@@ -36,8 +36,14 @@ import type { SlotParamsRecord } from '../shared/slot-params.ts';
|
|
|
36
36
|
export interface SsrData {
|
|
37
37
|
/** The request's URL pathname (e.g. '/dashboard/settings') */
|
|
38
38
|
pathname: string;
|
|
39
|
-
/**
|
|
40
|
-
|
|
39
|
+
/**
|
|
40
|
+
* The request's raw app-visible query string (`''` or `?…`), matching what
|
|
41
|
+
* `window.location.search` will hold after hydration. The raw string is
|
|
42
|
+
* the only representation carried here: it preserves repeated keys
|
|
43
|
+
* (`?tag=a&tag=b`), which a collapsed `Record<string, string>` cannot
|
|
44
|
+
* (TIM-1428). Parse with `new URLSearchParams(search)` where needed.
|
|
45
|
+
*/
|
|
46
|
+
search: string;
|
|
41
47
|
/** The request's cookies as name→value pairs */
|
|
42
48
|
cookies: Map<string, string>;
|
|
43
49
|
/** The request's route params (e.g. { id: '123' }) */
|
package/src/client/state.ts
CHANGED
|
@@ -19,8 +19,6 @@
|
|
|
19
19
|
* §"Singleton State Registry".
|
|
20
20
|
*/
|
|
21
21
|
|
|
22
|
-
import type { CoercedParams } from '../shared/param-value.ts';
|
|
23
|
-
import type { SlotParamsRecord } from '../shared/slot-params.ts';
|
|
24
22
|
import type { RouterInstance } from './router-types.ts';
|
|
25
23
|
import type { SsrData } from './ssr-data.ts';
|
|
26
24
|
|
|
@@ -52,30 +50,6 @@ export function _setCurrentSsrData(data: SsrData | undefined): void {
|
|
|
52
50
|
currentSsrData = data;
|
|
53
51
|
}
|
|
54
52
|
|
|
55
|
-
// ─── Route Params (from use-segment-params.ts) ──────────────────────────────────────
|
|
56
|
-
|
|
57
|
-
/** Current route params snapshot — replaced (not mutated) on each navigation. */
|
|
58
|
-
export let currentParams: CoercedParams = {};
|
|
59
|
-
|
|
60
|
-
export function _setCurrentParams(params: CoercedParams): void {
|
|
61
|
-
currentParams = params;
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
/**
|
|
65
|
-
* Per-slot params snapshot, keyed by slot tree path — the module-level
|
|
66
|
-
* fallback behind `useSegmentParams(slotPath)` when no NavigationContext is
|
|
67
|
-
* mounted (tests, calls outside a component). Replaced, never mutated, on
|
|
68
|
-
* each navigation. See TIM-1285.
|
|
69
|
-
*/
|
|
70
|
-
export let currentSlotParams: SlotParamsRecord | null = null;
|
|
71
|
-
|
|
72
|
-
export function _setCurrentSlotParams(slotParams: SlotParamsRecord | null): void {
|
|
73
|
-
currentSlotParams = slotParams;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
/** Listeners notified when currentParams changes. */
|
|
77
|
-
export const paramsListeners = new Set<() => void>();
|
|
78
|
-
|
|
79
53
|
// ─── Search Params Cache (from use-search-params.ts) ────────────────────────
|
|
80
54
|
|
|
81
55
|
/** Cached search string — avoids reparsing when URL hasn't changed. */
|