@timber-js/app 0.2.0-alpha.186 → 0.2.0-alpha.188
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-C-Rw9vPc.js → actions-35jnMdeJ.js} +4 -3
- package/dist/_chunks/{actions-C-Rw9vPc.js.map → actions-35jnMdeJ.js.map} +1 -1
- package/dist/_chunks/als-registry-C6kcfprT.js +41 -0
- package/dist/_chunks/als-registry-C6kcfprT.js.map +1 -0
- package/dist/_chunks/{als-slots-mFweg276.js → als-slots-BEEIPKYm.js} +3 -4
- package/dist/_chunks/{als-slots-mFweg276.js.map → als-slots-BEEIPKYm.js.map} +1 -1
- package/dist/_chunks/{cache-api-Cd0VZ_Pd.js → cache-api-DjNrIWRR.js} +7 -13
- package/dist/_chunks/cache-api-DjNrIWRR.js.map +1 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js +256 -0
- package/dist/_chunks/cli-check-CpmN7Nh-.js.map +1 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js +298 -0
- package/dist/_chunks/cli-schema-sync-CGMp_Psg.js.map +1 -0
- package/dist/_chunks/{cloudflare-Cs0uZXea.js → cloudflare-CGP6BZKO.js} +4 -3
- package/dist/_chunks/{cloudflare-Cs0uZXea.js.map → cloudflare-CGP6BZKO.js.map} +1 -1
- package/dist/_chunks/convention-lint-kXsgc_-7.js +784 -0
- package/dist/_chunks/convention-lint-kXsgc_-7.js.map +1 -0
- package/dist/_chunks/{error-boundary-DpYRI_I1.js → error-boundary-D-ODYX41.js} +25 -2
- package/dist/_chunks/error-boundary-D-ODYX41.js.map +1 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js +454 -0
- package/dist/_chunks/file-cache-DmX7OqZP.js.map +1 -0
- package/dist/_chunks/{logger-N7e5auP0.js → logger-D8xJZXIN.js} +27 -40
- package/dist/_chunks/logger-D8xJZXIN.js.map +1 -0
- package/dist/_chunks/mdx-file-C005ay-P.js +25 -0
- package/dist/_chunks/mdx-file-C005ay-P.js.map +1 -0
- package/dist/_chunks/{plugin-context-DEGLSJs3.js → plugin-context-rCinWLiE.js} +7 -2
- package/dist/_chunks/{plugin-context-DEGLSJs3.js.map → plugin-context-rCinWLiE.js.map} +1 -1
- package/dist/_chunks/purge-store-Byr8XOjU.js +14 -0
- package/dist/_chunks/purge-store-Byr8XOjU.js.map +1 -0
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js → resolve-schema-5ma5pp1b.js} +2 -2
- package/dist/_chunks/{resolve-schema-Dz3fcFUo.js.map → resolve-schema-5ma5pp1b.js.map} +1 -1
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js +46 -0
- package/dist/_chunks/rsc-media-type-DRqE_lD_.js.map +1 -0
- package/dist/_chunks/{cli-schema-sync-CKgHC2MB.js → scanner-C8b0Gcw3.js} +5 -298
- package/dist/_chunks/scanner-C8b0Gcw3.js.map +1 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js +199 -0
- package/dist/_chunks/schema-bridge-Cc2Gngu1.js.map +1 -0
- package/dist/_chunks/segment-classify-C539Pa2O.js.map +1 -1
- package/dist/_chunks/{param-value-C8TNYchQ.js → segment-context-CjOlyB8Y.js} +33 -2
- package/dist/_chunks/segment-context-CjOlyB8Y.js.map +1 -0
- package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
- package/dist/_chunks/{use-query-states-DFvWd-EA.js → use-query-states-BbU5Ge1V.js} +74 -26
- package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +1 -0
- package/dist/_chunks/{navigation-root-B29qg0_T.js → use-segment-params-C4r4BD9T.js} +129 -5
- package/dist/_chunks/use-segment-params-C4r4BD9T.js.map +1 -0
- package/dist/_chunks/walkers-RzN6AFjr.js +141 -0
- package/dist/_chunks/walkers-RzN6AFjr.js.map +1 -0
- package/dist/adapters/cloudflare-dev.js +1 -1
- package/dist/adapters/cloudflare-kv-cache.js +1 -1
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +1 -1
- package/dist/adapters/compress-module.d.ts +12 -0
- package/dist/adapters/compress-module.d.ts.map +1 -1
- package/dist/adapters/nitro.js +54 -2
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cdn/cloudflare-purge.js +30 -0
- package/dist/cdn/cloudflare-purge.js.map +1 -0
- package/dist/cdn/fastly-purge.js +33 -0
- package/dist/cdn/fastly-purge.js.map +1 -0
- package/dist/cdn/index.js +94 -0
- package/dist/cdn/index.js.map +1 -0
- package/dist/cdn/workers-cache-purge.js +35 -0
- package/dist/cdn/workers-cache-purge.js.map +1 -0
- package/dist/cli-check.d.ts +153 -0
- package/dist/cli-check.d.ts.map +1 -0
- package/dist/cli.d.ts +34 -8
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +46 -21
- package/dist/cli.js.map +1 -1
- package/dist/client/browser-entry/action-dispatch.d.ts.map +1 -1
- package/dist/client/browser-entry/index.d.ts +1 -1
- package/dist/client/browser-entry/index.d.ts.map +1 -1
- package/dist/client/error-boundary.d.ts +6 -0
- package/dist/client/error-boundary.d.ts.map +1 -1
- package/dist/client/error-boundary.js +1 -1
- package/dist/client/index.d.ts +1 -0
- package/dist/client/index.d.ts.map +1 -1
- package/dist/client/index.js +27 -33
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +33 -17
- package/dist/client/internal.js.map +1 -1
- package/dist/client/link.d.ts +1 -7
- package/dist/client/link.d.ts.map +1 -1
- package/dist/client/navigation-commit.d.ts +16 -0
- package/dist/client/navigation-commit.d.ts.map +1 -1
- package/dist/client/router-pipeline.d.ts.map +1 -1
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/rsc-fetch.d.ts +9 -1
- package/dist/client/rsc-fetch.d.ts.map +1 -1
- package/dist/client/segment-cache.d.ts +14 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts +9 -3
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/define-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +2 -2
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +142 -477
- package/dist/index.js.map +1 -1
- package/dist/params/index.js +1 -1
- package/dist/plugin-context.d.ts +15 -0
- package/dist/plugin-context.d.ts.map +1 -1
- package/dist/plugins/routing.d.ts +0 -9
- package/dist/plugins/routing.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/plugins/static-build.d.ts +24 -0
- package/dist/plugins/static-build.d.ts.map +1 -1
- package/dist/routing/codegen-shared.d.ts +3 -44
- package/dist/routing/codegen-shared.d.ts.map +1 -1
- package/dist/routing/codegen-types.d.ts +10 -31
- package/dist/routing/codegen-types.d.ts.map +1 -1
- package/dist/routing/codegen-write.d.ts +51 -0
- package/dist/routing/codegen-write.d.ts.map +1 -0
- package/dist/routing/codegen.d.ts.map +1 -1
- package/dist/routing/convention-lint.d.ts +18 -4
- package/dist/routing/convention-lint.d.ts.map +1 -1
- package/dist/routing/export-detect.d.ts +16 -0
- package/dist/routing/export-detect.d.ts.map +1 -1
- package/dist/routing/index.js +3 -2
- package/dist/routing/link-codegen.d.ts +19 -4
- package/dist/routing/link-codegen.d.ts.map +1 -1
- package/dist/routing/manifest-codegen.d.ts +1 -7
- package/dist/routing/manifest-codegen.d.ts.map +1 -1
- package/dist/routing/segment-keys.d.ts +22 -0
- package/dist/routing/segment-keys.d.ts.map +1 -1
- package/dist/routing/types.d.ts +0 -6
- package/dist/routing/types.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +60 -9
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +62 -8
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.d.ts +0 -1
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +66 -29
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/parse-total.d.ts +70 -0
- package/dist/search-params/parse-total.d.ts.map +1 -0
- package/dist/search-params/wrappers.d.ts +26 -3
- package/dist/search-params/wrappers.d.ts.map +1 -1
- package/dist/server/access-gate.d.ts +19 -8
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/action-handler.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +16 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/chain-url-parts.d.ts +2 -3
- package/dist/server/chain-url-parts.d.ts.map +1 -1
- package/dist/server/compress.d.ts.map +1 -1
- package/dist/server/deny-boundary.d.ts +148 -15
- package/dist/server/deny-boundary.d.ts.map +1 -1
- package/dist/server/deny-renderer.d.ts +2 -2
- package/dist/server/deny-renderer.d.ts.map +1 -1
- package/dist/server/error-boundary-wrapper.d.ts +85 -15
- package/dist/server/error-boundary-wrapper.d.ts.map +1 -1
- package/dist/server/index.d.ts +0 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -2
- package/dist/server/index.js.map +1 -1
- package/dist/server/internal.d.ts +3 -1
- package/dist/server/internal.d.ts.map +1 -1
- package/dist/server/internal.js +344 -237
- package/dist/server/internal.js.map +1 -1
- package/dist/server/metadata-collector.d.ts +52 -0
- package/dist/server/metadata-collector.d.ts.map +1 -0
- package/dist/server/param-coercion.d.ts +12 -5
- package/dist/server/param-coercion.d.ts.map +1 -1
- package/dist/server/pipeline-helpers.d.ts.map +1 -1
- package/dist/server/pipeline-outcome.d.ts.map +1 -1
- package/dist/server/pipeline-phases.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +23 -0
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +1 -12
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-cache-key-guard.d.ts.map +1 -1
- package/dist/server/rsc-entry/deny-fallback.d.ts.map +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts +1 -1
- package/dist/server/rsc-entry/error-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts +0 -7
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +0 -1
- package/dist/server/rsc-entry/index.d.ts.map +1 -1
- package/dist/server/rsc-entry/render-route.d.ts.map +1 -1
- package/dist/server/rsc-entry/revalidate-renderer.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-payload.d.ts +1 -3
- package/dist/server/rsc-entry/rsc-payload.d.ts.map +1 -1
- package/dist/server/rsc-entry/rsc-stream.d.ts +12 -0
- package/dist/server/rsc-entry/rsc-stream.d.ts.map +1 -1
- package/dist/server/rsc-entry/ssr-renderer.d.ts +0 -2
- package/dist/server/rsc-entry/ssr-renderer.d.ts.map +1 -1
- package/dist/server/skippable-prefix.d.ts +3 -0
- package/dist/server/skippable-prefix.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +18 -19
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/ssr-bridge-types.d.ts +11 -0
- package/dist/server/ssr-bridge-types.d.ts.map +1 -1
- package/dist/server/ssr-entry.d.ts +0 -1
- package/dist/server/ssr-entry.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +9 -16
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/static-generator.d.ts.map +1 -1
- package/dist/server/status-code-resolver.d.ts +8 -1
- package/dist/server/status-code-resolver.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +28 -43
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/server/types.d.ts +12 -7
- package/dist/server/types.d.ts.map +1 -1
- package/dist/server/utils/element-type.d.ts +40 -0
- package/dist/server/utils/element-type.d.ts.map +1 -0
- package/dist/shared/rsc-media-type.d.ts +40 -0
- package/dist/shared/rsc-media-type.d.ts.map +1 -0
- package/dist/shared/segment-info.d.ts +7 -0
- package/dist/shared/segment-info.d.ts.map +1 -1
- package/docs/api/30-api-server.mdx +1 -1
- package/docs/api/33-api-search-params.mdx +38 -16
- package/docs/api/35-api-typescript.mdx +3 -3
- package/docs/api/36-cli.mdx +34 -7
- package/docs/learn/00-introduction.mdx +1 -1
- package/docs/learn/02-pages-and-layouts.mdx +1 -1
- package/docs/learn/05-typed-params.mdx +8 -8
- package/docs/learn/07-typed-routes.mdx +12 -7
- package/docs/learn/11-error-handling.mdx +16 -0
- package/docs/more/01-advanced-routing.mdx +1 -1
- package/docs/more/03-coming-from-nextjs.mdx +2 -2
- package/docs/more/50-ai-agent-instructions.mdx +6 -4
- package/package.json +8 -5
- package/src/adapters/cloudflare.ts +4 -1
- package/src/adapters/compress-module.ts +79 -1
- package/src/cli-check.ts +458 -0
- package/src/cli.ts +59 -24
- package/src/client/browser-entry/action-dispatch.ts +2 -1
- package/src/client/browser-entry/index.ts +0 -5
- package/src/client/error-boundary.tsx +65 -1
- package/src/client/index.ts +14 -3
- package/src/client/link.tsx +65 -64
- package/src/client/navigation-commit.ts +27 -4
- package/src/client/params-context.ts +4 -4
- package/src/client/router-pipeline.ts +4 -0
- package/src/client/router.ts +1 -0
- package/src/client/rsc-fetch.ts +14 -4
- package/src/client/segment-cache.ts +26 -5
- package/src/client/use-query-states.ts +102 -39
- package/src/cookies/define-cookie.ts +6 -1
- package/src/index.ts +20 -3
- package/src/plugin-context.ts +26 -0
- package/src/plugins/routing.ts +84 -146
- package/src/plugins/shims.ts +0 -1
- package/src/plugins/static-build.ts +78 -24
- package/src/routing/codegen-shared.ts +3 -79
- package/src/routing/codegen-types.ts +10 -31
- package/src/routing/codegen-write.ts +139 -0
- package/src/routing/codegen.ts +56 -182
- package/src/routing/convention-lint.ts +139 -40
- package/src/routing/export-detect.ts +151 -7
- package/src/routing/link-codegen.ts +32 -65
- package/src/routing/manifest-codegen.ts +1 -59
- package/src/routing/scanner.ts +3 -3
- package/src/routing/segment-keys.ts +37 -0
- package/src/routing/types.ts +0 -6
- package/src/schema-bridge.ts +180 -58
- package/src/search-params/define.ts +102 -37
- package/src/search-params/index.ts +0 -1
- package/src/search-params/parse-total.ts +78 -0
- package/src/search-params/wrappers.ts +60 -11
- package/src/server/access-gate.tsx +61 -68
- package/src/server/action-handler.ts +1 -4
- package/src/server/als-registry.ts +16 -0
- package/src/server/chain-url-parts.ts +2 -3
- package/src/server/compress.ts +9 -1
- package/src/server/deny-boundary.ts +269 -41
- package/src/server/deny-renderer.ts +32 -21
- package/src/server/error-boundary-wrapper.ts +166 -79
- package/src/server/index.ts +1 -3
- package/src/server/internal.ts +2 -2
- package/src/server/metadata-collector.ts +115 -0
- package/src/server/param-coercion.ts +13 -61
- package/src/server/pipeline-helpers.ts +2 -2
- package/src/server/pipeline-outcome.ts +2 -1
- package/src/server/pipeline-phases.ts +9 -9
- package/src/server/primitives.ts +25 -0
- package/src/server/route-element-builder.ts +169 -194
- package/src/server/rsc-cache-key-guard.ts +2 -8
- package/src/server/rsc-entry/deny-fallback.ts +3 -2
- package/src/server/rsc-entry/error-renderer.ts +31 -11
- package/src/server/rsc-entry/helpers.ts +3 -12
- package/src/server/rsc-entry/index.ts +0 -5
- package/src/server/rsc-entry/render-route.ts +14 -11
- package/src/server/rsc-entry/revalidate-renderer.ts +2 -1
- package/src/server/rsc-entry/rsc-payload.ts +104 -20
- package/src/server/rsc-entry/rsc-stream.ts +25 -2
- package/src/server/rsc-entry/ssr-renderer.ts +28 -16
- package/src/server/skippable-prefix.ts +21 -39
- package/src/server/slot-resolver.ts +69 -189
- package/src/server/ssr-bridge-types.ts +12 -0
- package/src/server/ssr-entry.ts +8 -6
- package/src/server/state-tree-diff.ts +11 -62
- package/src/server/static-generator.ts +3 -2
- package/src/server/status-code-resolver.ts +28 -11
- package/src/server/tree-builder.ts +35 -228
- package/src/server/types.ts +12 -7
- package/src/server/utils/element-type.ts +72 -0
- package/src/shared/rsc-media-type.ts +43 -0
- package/src/shared/segment-info.ts +7 -0
- package/dist/_chunks/cache-api-Cd0VZ_Pd.js.map +0 -1
- package/dist/_chunks/cli-schema-sync-CKgHC2MB.js.map +0 -1
- package/dist/_chunks/error-boundary-DpYRI_I1.js.map +0 -1
- package/dist/_chunks/logger-N7e5auP0.js.map +0 -1
- package/dist/_chunks/navigation-root-B29qg0_T.js.map +0 -1
- package/dist/_chunks/param-value-C8TNYchQ.js.map +0 -1
- package/dist/_chunks/registry-DbJPKoBp.js +0 -20
- package/dist/_chunks/registry-DbJPKoBp.js.map +0 -1
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js +0 -119
- package/dist/_chunks/schema-bridge-DT_Tn0Xf.js.map +0 -1
- package/dist/_chunks/segment-context-ZDnXDkbz.js +0 -34
- package/dist/_chunks/segment-context-ZDnXDkbz.js.map +0 -1
- package/dist/_chunks/use-query-states-DFvWd-EA.js.map +0 -1
- package/dist/_chunks/use-segment-params-ClyUNq4d.js +0 -128
- package/dist/_chunks/use-segment-params-ClyUNq4d.js.map +0 -1
- package/dist/_chunks/walkers-BhhwI9TD.js +0 -936
- package/dist/_chunks/walkers-BhhwI9TD.js.map +0 -1
- package/dist/search-params/registry.d.ts +0 -20
- package/dist/search-params/registry.d.ts.map +0 -1
- package/dist/segment-params/define.d.ts +0 -83
- package/dist/segment-params/define.d.ts.map +0 -1
- package/dist/segment-params/index.d.ts +0 -3
- package/dist/segment-params/index.d.ts.map +0 -1
- package/dist/segment-params/index.js +0 -70
- package/dist/segment-params/index.js.map +0 -1
- package/src/search-params/registry.ts +0 -31
- package/src/segment-params/define.ts +0 -226
- package/src/segment-params/index.ts +0 -9
|
@@ -11,7 +11,9 @@ import type { ManifestSegmentNode } from './route-matcher.js';
|
|
|
11
11
|
import type { RouteMatch } from './pipeline.js';
|
|
12
12
|
import { shouldSkipSlot, type ClientStateTree } from './state-tree-diff.js';
|
|
13
13
|
import { consumedUrlParts } from './chain-url-parts.js';
|
|
14
|
-
import { computeSlotKey } from '../routing/segment-keys.js';
|
|
14
|
+
import { computeSlotContentKey, computeSlotKey } from '../routing/segment-keys.js';
|
|
15
|
+
import { matchUrlParts } from './tree-match.js';
|
|
16
|
+
import { slotUrlParts } from './slot-resolver.js';
|
|
15
17
|
|
|
16
18
|
/**
|
|
17
19
|
* Truncate the skippable prefix when a skippable layout has slots whose
|
|
@@ -22,6 +24,9 @@ import { computeSlotKey } from '../routing/segment-keys.js';
|
|
|
22
24
|
* Removing the segment from the skippable set forces it to render with
|
|
23
25
|
* its slots. The prefix must stay contiguous, so all segments after the
|
|
24
26
|
* first changed-slot segment are also removed.
|
|
27
|
+
*
|
|
28
|
+
* Uses key-based diffing (TIM-1370): computes the destination content key
|
|
29
|
+
* per slot and checks if the client's advertised set contains it.
|
|
25
30
|
*/
|
|
26
31
|
export function truncateSkippableForChangedSlots(
|
|
27
32
|
skippableSet: Set<number>,
|
|
@@ -31,23 +36,8 @@ export function truncateSkippableForChangedSlots(
|
|
|
31
36
|
clientStateTree: ClientStateTree,
|
|
32
37
|
req: Request
|
|
33
38
|
): void {
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
if (departingUrl) {
|
|
37
|
-
try {
|
|
38
|
-
depParsed = new URL(departingUrl, 'http://localhost');
|
|
39
|
-
} catch {
|
|
40
|
-
skippableSet.clear();
|
|
41
|
-
return;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
const destPathname = new URL(req.url, 'http://localhost').pathname;
|
|
45
|
-
const depPathname = depParsed?.pathname ?? null;
|
|
46
|
-
const split = (p: string) => (p === '/' ? [] : p.slice(1).split('/'));
|
|
47
|
-
const destAll = split(destPathname);
|
|
48
|
-
const depAll = depPathname ? split(depPathname) : null;
|
|
49
|
-
const clientSlots = clientStateTree.slots ?? null;
|
|
50
|
-
const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
|
|
39
|
+
const clientSlotKeys = clientStateTree.slots ?? null;
|
|
40
|
+
const destUrl = req.url;
|
|
51
41
|
|
|
52
42
|
for (let i = 0; i < segments.length; i++) {
|
|
53
43
|
if (!skippableSet.has(i)) continue;
|
|
@@ -56,33 +46,25 @@ export function truncateSkippableForChangedSlots(
|
|
|
56
46
|
if (slotEntries.length === 0) continue;
|
|
57
47
|
|
|
58
48
|
const segIdx = match.segments.indexOf(segment);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
// and is the same derivation slot matching and layout depth use.
|
|
62
|
-
const sliceAt = segIdx >= 0 ? consumedUrlParts(match.segments, segIdx, match).length : 0;
|
|
63
|
-
const destParts = destAll.slice(sliceAt);
|
|
64
|
-
const depParts = depAll ? depAll.slice(sliceAt) : null;
|
|
65
|
-
let parentChanged = false;
|
|
66
|
-
if (depAll) {
|
|
67
|
-
const dp = depAll.slice(0, sliceAt),
|
|
68
|
-
dd = destAll.slice(0, sliceAt);
|
|
69
|
-
parentChanged = dp.length !== dd.length || dp.some((p, j) => p !== dd[j]);
|
|
70
|
-
}
|
|
49
|
+
const ownerParts = segIdx >= 0 ? consumedUrlParts(match.segments, segIdx, match) : [];
|
|
50
|
+
const destParts = slotUrlParts(segment, match, destUrl);
|
|
71
51
|
|
|
72
52
|
for (const [slotName, slotNode] of slotEntries) {
|
|
73
53
|
const m = slotNode as ManifestSegmentNode;
|
|
74
54
|
const k = computeSlotKey(segmentKeys[i], `@${slotName}`);
|
|
55
|
+
|
|
56
|
+
// Slots with access.ts are never skip candidates (TIM-1364).
|
|
57
|
+
const destMatch = matchUrlParts(m, destParts);
|
|
58
|
+
const hasAccessInChain = !!m.access || (destMatch?.chain.some((seg) => seg.access) ?? false);
|
|
59
|
+
|
|
60
|
+
const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
|
|
61
|
+
const entryFile = destLeaf?.page?.filePath ?? null;
|
|
62
|
+
const contentKey = computeSlotContentKey(k, ownerParts, entryFile, destMatch?.params ?? {});
|
|
63
|
+
|
|
75
64
|
if (
|
|
65
|
+
hasAccessInChain ||
|
|
76
66
|
m.children.some((c) => c.segmentType === 'intercepting') ||
|
|
77
|
-
|
|
78
|
-
depParts === null ||
|
|
79
|
-
!shouldSkipSlot({
|
|
80
|
-
slotKey: k,
|
|
81
|
-
clientSlots,
|
|
82
|
-
slotNode: m,
|
|
83
|
-
departingUrlParts: depParts,
|
|
84
|
-
destinationUrlParts: destParts,
|
|
85
|
-
})
|
|
67
|
+
!shouldSkipSlot(contentKey, clientSlotKeys)
|
|
86
68
|
) {
|
|
87
69
|
for (let j = i; j < segments.length; j++) skippableSet.delete(j);
|
|
88
70
|
return;
|
|
@@ -33,7 +33,7 @@ import { extractUrlParts, consumedPartsThroughSegment } from './chain-url-parts.
|
|
|
33
33
|
import { findInterceptingMatch } from './slot-interception.js';
|
|
34
34
|
import { SegmentOutlet } from '../client/segment-outlet.js';
|
|
35
35
|
import { shouldSkipSlot, type ClientStateTree } from './state-tree-diff.js';
|
|
36
|
-
import { computeSlotKey } from '../routing/segment-keys.js';
|
|
36
|
+
import { computeSlotKey, computeSlotContentKey } from '../routing/segment-keys.js';
|
|
37
37
|
import { isStaticRequestDependent } from './request-dep.js';
|
|
38
38
|
|
|
39
39
|
type CreateElementFn = (...args: unknown[]) => React.ReactElement;
|
|
@@ -86,8 +86,7 @@ export async function resolveSlotElement(
|
|
|
86
86
|
match: RouteMatch,
|
|
87
87
|
h: CreateElementFn,
|
|
88
88
|
interception?: InterceptionContext,
|
|
89
|
-
parentTreePath?: string
|
|
90
|
-
accessVerdicts?: SlotAccessVerdict[]
|
|
89
|
+
parentTreePath?: string
|
|
91
90
|
): Promise<React.ReactElement | null> {
|
|
92
91
|
// When interception is active, try to match intercepting children in this
|
|
93
92
|
// slot against the target pathname. If an intercepting child matches, render
|
|
@@ -176,13 +175,7 @@ export async function resolveSlotElement(
|
|
|
176
175
|
// intermediate slot segments (everything between slot root and leaf).
|
|
177
176
|
// Process innermost-first, same order as route-element-builder.ts
|
|
178
177
|
// handles main segments. The slot root (index 0) is handled below.
|
|
179
|
-
element = await wrapWithIntermediateSegments(
|
|
180
|
-
slotMatch.chain,
|
|
181
|
-
element,
|
|
182
|
-
h,
|
|
183
|
-
accessVerdicts,
|
|
184
|
-
scope
|
|
185
|
-
);
|
|
178
|
+
element = await wrapWithIntermediateSegments(slotMatch.chain, element, h, scope);
|
|
186
179
|
|
|
187
180
|
// Wrap with slot root's layout — INSIDE the access gate, so the layout
|
|
188
181
|
// server component never executes when access.ts denies. See TIM-1074.
|
|
@@ -195,10 +188,14 @@ export async function resolveSlotElement(
|
|
|
195
188
|
// On denial: denied.tsx → default.tsx → null (graceful degradation),
|
|
196
189
|
// rendered WITHOUT the denied slot's own layout.
|
|
197
190
|
// See design/04-authorization.md §"Slot-Level Auth".
|
|
198
|
-
element = await wrapWithAccessGate(slotNode, element, h,
|
|
191
|
+
element = await wrapWithAccessGate(slotNode, element, h, scope);
|
|
199
192
|
|
|
200
193
|
// Wrap with slot root's error boundaries (outermost)
|
|
201
|
-
element = await wrapSegmentWithErrorBoundaries(slotNode, element,
|
|
194
|
+
element = await wrapSegmentWithErrorBoundaries(slotNode, element, {
|
|
195
|
+
loadModule,
|
|
196
|
+
createElement: h,
|
|
197
|
+
errorBoundaryComponent: TimberErrorBoundary,
|
|
198
|
+
});
|
|
202
199
|
|
|
203
200
|
// Catch-all error boundary: ensures slot errors NEVER propagate to the
|
|
204
201
|
// parent layout. Without this, a slot without error.tsx that throws
|
|
@@ -273,14 +270,17 @@ async function wrapWithIntermediateSegments(
|
|
|
273
270
|
chain: ManifestSegmentNode[],
|
|
274
271
|
element: React.ReactElement,
|
|
275
272
|
h: CreateElementFn,
|
|
276
|
-
accessVerdicts?: SlotAccessVerdict[],
|
|
277
273
|
scope?: SlotParamScope
|
|
278
274
|
): Promise<React.ReactElement> {
|
|
279
275
|
for (let i = chain.length - 1; i > 0; i--) {
|
|
280
276
|
const seg = chain[i];
|
|
281
|
-
element = await wrapSegmentWithErrorBoundaries(seg, element,
|
|
277
|
+
element = await wrapSegmentWithErrorBoundaries(seg, element, {
|
|
278
|
+
loadModule,
|
|
279
|
+
createElement: h,
|
|
280
|
+
errorBoundaryComponent: TimberErrorBoundary,
|
|
281
|
+
});
|
|
282
282
|
element = await wrapWithLayout(seg, element, h, scope);
|
|
283
|
-
element = await wrapWithAccessGate(seg, element, h,
|
|
283
|
+
element = await wrapWithAccessGate(seg, element, h, scope);
|
|
284
284
|
}
|
|
285
285
|
return element;
|
|
286
286
|
}
|
|
@@ -353,7 +353,6 @@ async function wrapWithAccessGate(
|
|
|
353
353
|
slotNode: ManifestSegmentNode,
|
|
354
354
|
element: React.ReactElement,
|
|
355
355
|
h: CreateElementFn,
|
|
356
|
-
accessVerdicts?: SlotAccessVerdict[],
|
|
357
356
|
scope?: SlotParamScope
|
|
358
357
|
): Promise<React.ReactElement> {
|
|
359
358
|
if (!slotNode.access) return element;
|
|
@@ -374,16 +373,6 @@ async function wrapWithAccessGate(
|
|
|
374
373
|
|
|
375
374
|
const defaultFallback = await renderDefaultFallback(slotNode, h);
|
|
376
375
|
|
|
377
|
-
// Look up pre-computed verdict from eager evaluation. Only replay
|
|
378
|
-
// denial/redirect verdicts — 'pass' verdicts must NOT be replayed
|
|
379
|
-
// because the access function may warm React.cache (e.g., requireUser())
|
|
380
|
-
// for layout/page dedup, and replaying 'pass' skips the cache-warming
|
|
381
|
-
// call. 'error' verdicts are also excluded — the gate must re-run
|
|
382
|
-
// accessFn so the error reaches the slot's error boundary.
|
|
383
|
-
const preVerdict = accessVerdicts?.find(
|
|
384
|
-
(v) => v.node === slotNode && v.verdict !== 'pass' && v.verdict !== 'error'
|
|
385
|
-
);
|
|
386
|
-
|
|
387
376
|
return h(SlotAccessGate, {
|
|
388
377
|
accessFn,
|
|
389
378
|
DeniedComponent,
|
|
@@ -391,7 +380,6 @@ async function wrapWithAccessGate(
|
|
|
391
380
|
createElement: h,
|
|
392
381
|
defaultFallback,
|
|
393
382
|
children: element,
|
|
394
|
-
verdict: preVerdict?.verdict,
|
|
395
383
|
});
|
|
396
384
|
}
|
|
397
385
|
|
|
@@ -488,74 +476,6 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
|
|
|
488
476
|
return { page: leaf.page, chain: result.chain, slotParams: result.params };
|
|
489
477
|
}
|
|
490
478
|
|
|
491
|
-
// ─── Slot Access Evaluation ─────────────────────────────────────────────────
|
|
492
|
-
|
|
493
|
-
/** Result of eagerly evaluating one segment's access.ts. */
|
|
494
|
-
export interface SlotAccessVerdict {
|
|
495
|
-
node: ManifestSegmentNode;
|
|
496
|
-
/**
|
|
497
|
-
* 'pass' — access allowed.
|
|
498
|
-
* DenySignal/RedirectSignal — access denied, replayed in SlotAccessGate.
|
|
499
|
-
* 'error' — unknown error. Forces full render. No verdict passed to
|
|
500
|
-
* SlotAccessGate, so it calls accessFn during render and the error
|
|
501
|
-
* reaches the slot's error boundary.
|
|
502
|
-
*/
|
|
503
|
-
verdict: 'pass' | 'error' | DenySignal | RedirectSignal;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
/**
|
|
507
|
-
* Eagerly evaluate the access chain for a slot (root + intermediate segments).
|
|
508
|
-
*
|
|
509
|
-
* Runs each access.ts top-down (outermost first). If any denies, the chain
|
|
510
|
-
* stops (shallowest failure wins, matching segment access semantics).
|
|
511
|
-
*
|
|
512
|
-
* Verdicts are stored for replay in SlotAccessGate / wrapWithIntermediateSegments
|
|
513
|
-
* so access.ts is called exactly once per request.
|
|
514
|
-
*/
|
|
515
|
-
async function evaluateSlotAccessChain(
|
|
516
|
-
slotRoot: ManifestSegmentNode,
|
|
517
|
-
chain: ManifestSegmentNode[],
|
|
518
|
-
scope?: SlotParamScope
|
|
519
|
-
): Promise<SlotAccessVerdict[]> {
|
|
520
|
-
// Collect all nodes with access.ts: slot root + intermediate chain segments
|
|
521
|
-
const nodesWithAccess: ManifestSegmentNode[] = [];
|
|
522
|
-
if (slotRoot.access) nodesWithAccess.push(slotRoot);
|
|
523
|
-
for (let i = 1; i < chain.length; i++) {
|
|
524
|
-
if (chain[i].access) nodesWithAccess.push(chain[i]);
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
if (nodesWithAccess.length === 0) return [];
|
|
528
|
-
|
|
529
|
-
const results: SlotAccessVerdict[] = [];
|
|
530
|
-
for (const node of nodesWithAccess) {
|
|
531
|
-
const accessFn = await loadComponent(node.access!);
|
|
532
|
-
if (!accessFn) {
|
|
533
|
-
results.push({ node, verdict: 'pass' });
|
|
534
|
-
continue;
|
|
535
|
-
}
|
|
536
|
-
try {
|
|
537
|
-
await (scope ? scope(() => accessFn()) : accessFn());
|
|
538
|
-
results.push({ node, verdict: 'pass' });
|
|
539
|
-
} catch (e) {
|
|
540
|
-
if (e instanceof DenySignal) {
|
|
541
|
-
results.push({ node, verdict: e });
|
|
542
|
-
break; // shallowest failure wins
|
|
543
|
-
}
|
|
544
|
-
if (e instanceof RedirectSignal) {
|
|
545
|
-
results.push({ node, verdict: e });
|
|
546
|
-
break;
|
|
547
|
-
}
|
|
548
|
-
// Unknown error — record as 'error' to force full render (blocks
|
|
549
|
-
// canSkip via accessBlocked). No verdict is passed to SlotAccessGate
|
|
550
|
-
// (wrapWithAccessGate filters 'error' out), so the gate calls
|
|
551
|
-
// accessFn during render and the error reaches the error boundary.
|
|
552
|
-
results.push({ node, verdict: 'error' });
|
|
553
|
-
break;
|
|
554
|
-
}
|
|
555
|
-
}
|
|
556
|
-
return results;
|
|
557
|
-
}
|
|
558
|
-
|
|
559
479
|
// ─── Slot Caching ──────────────────────────────────────────────────────────
|
|
560
480
|
|
|
561
481
|
export interface SlotSkipEntry {
|
|
@@ -571,6 +491,12 @@ export interface SlotSkipEntry {
|
|
|
571
491
|
denied: boolean;
|
|
572
492
|
/** Whether the slot was skipped (content not rendered, client keeps cached). */
|
|
573
493
|
skipped?: boolean;
|
|
494
|
+
/**
|
|
495
|
+
* Content key encoding owner parts + slot name + entry file + slot params.
|
|
496
|
+
* Sent to the client via X-Timber-Segments; the client advertises it back
|
|
497
|
+
* so the server can decide skips by key membership (TIM-1370).
|
|
498
|
+
*/
|
|
499
|
+
contentKey: string;
|
|
574
500
|
}
|
|
575
501
|
|
|
576
502
|
/**
|
|
@@ -642,7 +568,6 @@ interface ResolveSlotPropsArgs {
|
|
|
642
568
|
h: CreateElementFn;
|
|
643
569
|
interception?: InterceptionContext;
|
|
644
570
|
parentTreePath: string;
|
|
645
|
-
departingUrl: string | null;
|
|
646
571
|
destinationUrl: string;
|
|
647
572
|
clientStateTree: ClientStateTree | null;
|
|
648
573
|
slotSkipInfo: SlotSkipEntry[];
|
|
@@ -650,9 +575,16 @@ interface ResolveSlotPropsArgs {
|
|
|
650
575
|
|
|
651
576
|
/**
|
|
652
577
|
* Resolve all parallel route slots for a layout, wrapping each in a
|
|
653
|
-
* SegmentOutlet for client-side caching. Slots whose
|
|
654
|
-
*
|
|
655
|
-
*
|
|
578
|
+
* SegmentOutlet for client-side caching. Slots whose content key matches
|
|
579
|
+
* one the client advertised are rendered with `skip=true` so the client
|
|
580
|
+
* keeps its cached content.
|
|
581
|
+
*
|
|
582
|
+
* The skip decision is key-based (TIM-1370): the server computes a content
|
|
583
|
+
* key per slot from the owning segment's URL parts, the slot name, the
|
|
584
|
+
* matched entry file, and the slot's own params. The client advertises
|
|
585
|
+
* the keys it has cached; the server checks membership. This replaces
|
|
586
|
+
* the previous approach of reconstructing the client's state from a
|
|
587
|
+
* departing URL.
|
|
656
588
|
*/
|
|
657
589
|
export async function resolveSlotProps({
|
|
658
590
|
segment,
|
|
@@ -661,7 +593,6 @@ export async function resolveSlotProps({
|
|
|
661
593
|
h,
|
|
662
594
|
interception,
|
|
663
595
|
parentTreePath,
|
|
664
|
-
departingUrl,
|
|
665
596
|
destinationUrl,
|
|
666
597
|
clientStateTree,
|
|
667
598
|
slotSkipInfo,
|
|
@@ -670,106 +601,51 @@ export async function resolveSlotProps({
|
|
|
670
601
|
const slotEntries = Object.entries(segment.slots ?? {});
|
|
671
602
|
if (slotEntries.length === 0) return slotProps;
|
|
672
603
|
|
|
673
|
-
// Parse URLs to extract pathnames for slot skip comparison.
|
|
674
|
-
// The departing URL (X-Timber-URL) is an untrusted request header —
|
|
675
|
-
// catch parse failures and fall back to no-cache (full render).
|
|
676
|
-
const destParsed = new URL(destinationUrl, 'http://localhost');
|
|
677
|
-
let depParsed: URL | null = null;
|
|
678
|
-
if (departingUrl) {
|
|
679
|
-
try {
|
|
680
|
-
depParsed = new URL(departingUrl, 'http://localhost');
|
|
681
|
-
} catch {
|
|
682
|
-
// Malformed departing URL — disable slot skipping for this request
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
const destinationPathname = destParsed.pathname;
|
|
686
|
-
const departingPathname = depParsed?.pathname ?? null;
|
|
687
|
-
|
|
688
|
-
const sliceAt = consumedPartsThroughSegment(segment, match).length;
|
|
689
|
-
|
|
690
|
-
function splitPathname(pathname: string): string[] {
|
|
691
|
-
return pathname === '/' ? [] : pathname.slice(1).split('/');
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const destinationAll = splitPathname(destinationPathname);
|
|
695
|
-
const departingAll = departingPathname ? splitPathname(departingPathname) : null;
|
|
696
604
|
const destinationParts = slotUrlParts(segment, match, destinationUrl);
|
|
697
|
-
const
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
//
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
const depParent = departingAll.slice(0, sliceAt);
|
|
704
|
-
const destParent = destinationAll.slice(0, sliceAt);
|
|
705
|
-
parentParamsChanged =
|
|
706
|
-
depParent.length !== destParent.length || depParent.some((p, i) => p !== destParent[i]);
|
|
707
|
-
}
|
|
605
|
+
const clientSlotKeys = clientStateTree?.slots ?? null;
|
|
606
|
+
|
|
607
|
+
// Owner URL parts — the URL values consumed through this segment.
|
|
608
|
+
// These are part of the content key because the slot's page may read
|
|
609
|
+
// parent params (e.g., via getSegmentParams).
|
|
610
|
+
const ownerParts = consumedPartsThroughSegment(segment, match);
|
|
708
611
|
|
|
709
612
|
for (const [slotName, slotNode] of slotEntries) {
|
|
710
613
|
const slotManifest = slotNode as ManifestSegmentNode;
|
|
711
614
|
const slotKey = computeSlotKey(segmentId, `@${slotName}`);
|
|
712
615
|
|
|
713
616
|
// Match the slot's sub-tree against the destination URL parts.
|
|
714
|
-
// Used for both the skip decision and eager access evaluation.
|
|
715
617
|
const destMatch = matchUrlParts(slotManifest, destinationParts);
|
|
716
618
|
|
|
717
|
-
//
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
setSlotParams(fullSlotPath, coerced);
|
|
727
|
-
}
|
|
728
|
-
// The eager path calls access.ts outside the React tree, so the render
|
|
729
|
-
// scope in resolveSlotElement cannot cover it. Without this, the same
|
|
730
|
-
// access.ts would read the main route's params when the slot is a skip
|
|
731
|
-
// candidate and the slot's own params when it is not.
|
|
732
|
-
eagerScope = (fn) => runWithSlotSegmentParams(fullSlotPath, coerced, fn);
|
|
733
|
-
}
|
|
619
|
+
// Compute the content key for this slot at the destination URL.
|
|
620
|
+
const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
|
|
621
|
+
const entryFile = destLeaf?.page?.filePath ?? null;
|
|
622
|
+
const contentKey = computeSlotContentKey(
|
|
623
|
+
slotKey,
|
|
624
|
+
ownerParts,
|
|
625
|
+
entryFile,
|
|
626
|
+
destMatch?.params ?? {}
|
|
627
|
+
);
|
|
734
628
|
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
629
|
+
// Auth-bearing slots are inherently request-dependent — their content
|
|
630
|
+
// changes based on user state. Skipping them would require eager
|
|
631
|
+
// evaluation of access.ts outside React.cache scope, causing double
|
|
632
|
+
// execution (TIM-1364). Instead, exclude them from skip candidates
|
|
633
|
+
// entirely and let SlotAccessGate handle access during render.
|
|
634
|
+
// The client filters these out via isRequestDependent, so their keys
|
|
635
|
+
// are never advertised — but checking here is defense-in-depth.
|
|
636
|
+
const hasAccessInChain =
|
|
637
|
+
!!slotManifest.access || (destMatch?.chain.some((seg) => seg.access) ?? false);
|
|
739
638
|
const hasInterceptingChildren = slotManifest.children.some(
|
|
740
639
|
(c) => c.segmentType === 'intercepting'
|
|
741
640
|
);
|
|
742
|
-
const
|
|
641
|
+
const canSkip =
|
|
743
642
|
!interception &&
|
|
744
643
|
!hasInterceptingChildren &&
|
|
745
|
-
!
|
|
746
|
-
|
|
747
|
-
shouldSkipSlot({
|
|
748
|
-
slotKey,
|
|
749
|
-
clientSlots,
|
|
750
|
-
slotNode: slotManifest,
|
|
751
|
-
departingUrlParts: departingParts,
|
|
752
|
-
destinationUrlParts: destinationParts,
|
|
753
|
-
});
|
|
754
|
-
|
|
755
|
-
// Eagerly evaluate the slot's access chain only for skip candidates.
|
|
756
|
-
// This serves two purposes:
|
|
757
|
-
// 1. Satisfies security principle #3 (auth always runs) for skipped slots
|
|
758
|
-
// 2. Determines whether access denied (denied slots must not be skipped)
|
|
759
|
-
//
|
|
760
|
-
// Non-skip-candidate slots skip eager evaluation — their access.ts
|
|
761
|
-
// runs during render via SlotAccessGate (normal path). Per design doc:
|
|
762
|
-
// "No access check for default.tsx" (destMatch null = unmatched slot).
|
|
763
|
-
const chainVerdicts =
|
|
764
|
-
isSkipCandidate && destMatch
|
|
765
|
-
? await evaluateSlotAccessChain(slotManifest, destMatch.chain, eagerScope)
|
|
766
|
-
: [];
|
|
767
|
-
const accessBlocked = chainVerdicts.some((v) => v.verdict !== 'pass');
|
|
768
|
-
const canSkip = isSkipCandidate && !accessBlocked;
|
|
644
|
+
!hasAccessInChain &&
|
|
645
|
+
shouldSkipSlot(contentKey, clientSlotKeys);
|
|
769
646
|
|
|
770
647
|
if (canSkip) {
|
|
771
|
-
//
|
|
772
|
-
// No gate wrapper needed.
|
|
648
|
+
// No access.ts in this slot's chain (excluded above), so no gate needed.
|
|
773
649
|
slotProps[slotName] = h(SegmentOutlet, {
|
|
774
650
|
segmentPath: slotKey,
|
|
775
651
|
skip: true,
|
|
@@ -781,6 +657,7 @@ export async function resolveSlotProps({
|
|
|
781
657
|
isRequestDependent: false,
|
|
782
658
|
denied: false,
|
|
783
659
|
skipped: true,
|
|
660
|
+
contentKey,
|
|
784
661
|
});
|
|
785
662
|
} else {
|
|
786
663
|
const resolvedElement = await resolveSlotElement(
|
|
@@ -788,8 +665,7 @@ export async function resolveSlotProps({
|
|
|
788
665
|
match,
|
|
789
666
|
h,
|
|
790
667
|
interception,
|
|
791
|
-
parentTreePath
|
|
792
|
-
chainVerdicts
|
|
668
|
+
parentTreePath
|
|
793
669
|
);
|
|
794
670
|
slotProps[slotName] = h(SegmentOutlet, {
|
|
795
671
|
segmentPath: slotKey,
|
|
@@ -798,10 +674,13 @@ export async function resolveSlotProps({
|
|
|
798
674
|
// Static analysis: check if any file in the slot's RENDERED tree
|
|
799
675
|
// is request-dependent. Only check layouts when destMatch exists —
|
|
800
676
|
// unmatched slots render default.tsx directly without layouts.
|
|
801
|
-
|
|
802
|
-
|
|
677
|
+
// Slots with access.ts are inherently request-dependent — their
|
|
678
|
+
// content varies with user state (TIM-1364).
|
|
679
|
+
let slotRequestDep = hasAccessInChain;
|
|
803
680
|
const hasRenderedPage = destMatch && destLeaf?.page;
|
|
804
|
-
if (
|
|
681
|
+
if (slotRequestDep) {
|
|
682
|
+
// Already marked — skip static analysis
|
|
683
|
+
} else if (interception) {
|
|
805
684
|
// `destMatch` walks the slot's ordinary children against the
|
|
806
685
|
// destination URL; the interception resolver renders an intercepting
|
|
807
686
|
// child instead, which that walk never sees. So the files analyzed
|
|
@@ -846,7 +725,8 @@ export async function resolveSlotProps({
|
|
|
846
725
|
slotKey,
|
|
847
726
|
parentSegmentId: segmentId,
|
|
848
727
|
isRequestDependent: slotRequestDep,
|
|
849
|
-
denied:
|
|
728
|
+
denied: false,
|
|
729
|
+
contentKey,
|
|
850
730
|
});
|
|
851
731
|
}
|
|
852
732
|
}
|
|
@@ -70,6 +70,18 @@ export interface NavContext {
|
|
|
70
70
|
/** Request cookies as name→value pairs. Used by useCookie() during SSR
|
|
71
71
|
* to return correct cookie values before hydration. */
|
|
72
72
|
cookies?: Map<string, string>;
|
|
73
|
+
/**
|
|
74
|
+
* Lazy thunk that produces the `<script>self.__timber_segments=…</script>`
|
|
75
|
+
* tag. Invoked by the SSR entry AFTER `renderSsrNodeStream` /
|
|
76
|
+
* `renderSsrStream` returns (shell ready ⇒ AccessGates settled), so the
|
|
77
|
+
* `getFirstDeniedSegmentIndex()` ALS value is authoritative. The result
|
|
78
|
+
* is appended to headHtml before the head injector runs.
|
|
79
|
+
*
|
|
80
|
+
* Undefined when client JS is disabled (no client to consume it).
|
|
81
|
+
* See TIM-1365, TIM-1359.
|
|
82
|
+
*/
|
|
83
|
+
getHeadSegmentScript?: () => string;
|
|
84
|
+
|
|
73
85
|
/**
|
|
74
86
|
* Mutable flag: set by TimberErrorBoundary during SSR when it catches
|
|
75
87
|
* a DenySignal (via digest). This tells the RSC entry that the denial
|
package/src/server/ssr-entry.ts
CHANGED
|
@@ -14,10 +14,6 @@
|
|
|
14
14
|
|
|
15
15
|
// @ts-expect-error — virtual module provided by timber-entries plugin
|
|
16
16
|
import config from 'virtual:timber-config';
|
|
17
|
-
// TIM-830: Populate the search-params registry eagerly so <Link> rendered
|
|
18
|
-
// during SSR can serialize flat `Partial<T>` values via registry lookup.
|
|
19
|
-
// @ts-expect-error — virtual module provided by timber-routing plugin
|
|
20
|
-
import 'virtual:timber-search-params-registry';
|
|
21
17
|
// TIM-949: Populate the codec registry so server-rendered <Link> components
|
|
22
18
|
// use codec.serialize() instead of String() fallback for URL interpolation.
|
|
23
19
|
// @ts-expect-error — virtual module provided by timber-routing plugin
|
|
@@ -236,8 +232,11 @@ export async function handleSsr(
|
|
|
236
232
|
// stage: pipeline() destroys every stage on failure, so only an
|
|
237
233
|
// output kept outside the pipeline can append the noindex script
|
|
238
234
|
// and close cleanly (TIM-1019).
|
|
235
|
+
// Resolve the lazy segment script now that the shell is ready
|
|
236
|
+
// (AccessGates have settled, ALS deny index is authoritative).
|
|
237
|
+
const resolvedHeadHtml = navContext.headHtml + (navContext.getHeadSegmentScript?.() ?? '');
|
|
239
238
|
const bufferedTransform = createNodeBufferedTransform();
|
|
240
|
-
const headInjector = createNodeHeadInjector(
|
|
239
|
+
const headInjector = createNodeHeadInjector(resolvedHeadHtml);
|
|
241
240
|
const flightInjector = createNodeFlightInjector(navContext.rscStream, {
|
|
242
241
|
renderTimeoutMs,
|
|
243
242
|
errorSignal: navContext._errorSignal,
|
|
@@ -296,8 +295,11 @@ export async function handleSsr(
|
|
|
296
295
|
// HTML fragments so downstream transforms never see chunk boundaries
|
|
297
296
|
// mid-tag. This prevents RSC script injection from breaking HTML
|
|
298
297
|
// structure (TIM-527).
|
|
298
|
+
// Resolve the lazy segment script now that the shell is ready
|
|
299
|
+
// (AccessGates have settled, ALS deny index is authoritative).
|
|
300
|
+
const resolvedHeadHtml = navContext.headHtml + (navContext.getHeadSegmentScript?.() ?? '');
|
|
299
301
|
let outputStream = htmlStream.pipeThrough(createBufferedTransformStream());
|
|
300
|
-
outputStream = injectHead(outputStream,
|
|
302
|
+
outputStream = injectHead(outputStream, resolvedHeadHtml);
|
|
301
303
|
outputStream = injectRscPayload(
|
|
302
304
|
outputStream,
|
|
303
305
|
navContext.rscStream,
|
|
@@ -15,8 +15,6 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { swallow } from './logger.js';
|
|
18
|
-
import { matchUrlParts } from './tree-match.js';
|
|
19
|
-
import type { ManifestSegmentNode } from './route-matcher.js';
|
|
20
18
|
|
|
21
19
|
// ─── State Tree Parsing ──────────────────────────────────────────
|
|
22
20
|
|
|
@@ -112,72 +110,23 @@ export function shouldSkipSegment(
|
|
|
112
110
|
|
|
113
111
|
// ─── Slot Skip Decision ─────────────────────────────────────────
|
|
114
112
|
|
|
115
|
-
interface ShouldSkipSlotArgs {
|
|
116
|
-
slotKey: string;
|
|
117
|
-
clientSlots: Set<string> | null;
|
|
118
|
-
slotNode: ManifestSegmentNode;
|
|
119
|
-
departingUrlParts: string[];
|
|
120
|
-
destinationUrlParts: string[];
|
|
121
|
-
}
|
|
122
|
-
|
|
123
113
|
/**
|
|
124
114
|
* Determine whether a parallel route slot can be skipped.
|
|
125
115
|
*
|
|
126
|
-
* A slot is skipped when
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
* destination URLs (same page file + same extracted params)
|
|
116
|
+
* A slot is skipped when the destination content key — computed from the
|
|
117
|
+
* owning segment's URL parts, the slot name, the matched entry file, and
|
|
118
|
+
* the slot's extracted params — is in the set of keys the client advertised.
|
|
130
119
|
*
|
|
131
|
-
*
|
|
132
|
-
*
|
|
120
|
+
* This replaced URL-based departing/destination diffing in TIM-1370.
|
|
121
|
+
* The previous approach reconstructed the client's state by re-matching a
|
|
122
|
+
* departing URL against each slot's sub-tree, which was a recurring bug
|
|
123
|
+
* source because the reconstruction could drift from the client's actual
|
|
124
|
+
* mounted state. The key-based approach is a single set-membership check.
|
|
133
125
|
*
|
|
134
126
|
* This is a performance optimization only, NOT a security boundary.
|
|
135
127
|
* Slot access.ts always runs via SlotAccessGate regardless.
|
|
136
128
|
*/
|
|
137
|
-
export function shouldSkipSlot({
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
slotNode,
|
|
141
|
-
departingUrlParts,
|
|
142
|
-
destinationUrlParts,
|
|
143
|
-
}: ShouldSkipSlotArgs): boolean {
|
|
144
|
-
if (!clientSlots) return false;
|
|
145
|
-
if (!clientSlots.has(slotKey)) return false;
|
|
146
|
-
|
|
147
|
-
const departingMatch = matchUrlParts(slotNode, departingUrlParts);
|
|
148
|
-
const destinationMatch = matchUrlParts(slotNode, destinationUrlParts);
|
|
149
|
-
|
|
150
|
-
// Both null (no match) — slot shows default.tsx in both cases
|
|
151
|
-
if (!departingMatch && !destinationMatch) return true;
|
|
152
|
-
// One null, one not — match changed
|
|
153
|
-
if (!departingMatch || !destinationMatch) return false;
|
|
154
|
-
|
|
155
|
-
const departingLeaf = departingMatch.chain[departingMatch.chain.length - 1];
|
|
156
|
-
const destinationLeaf = destinationMatch.chain[destinationMatch.chain.length - 1];
|
|
157
|
-
|
|
158
|
-
// Both must have a page, and the page file must be the same
|
|
159
|
-
if (!departingLeaf.page || !destinationLeaf.page) {
|
|
160
|
-
return !departingLeaf.page && !destinationLeaf.page;
|
|
161
|
-
}
|
|
162
|
-
if (departingLeaf.page.filePath !== destinationLeaf.page.filePath) return false;
|
|
163
|
-
|
|
164
|
-
// Compare extracted params — if any differ, the slot content may change
|
|
165
|
-
return paramsEqual(departingMatch.params, destinationMatch.params);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
function paramsEqual(
|
|
169
|
-
a: Record<string, string | string[]>,
|
|
170
|
-
b: Record<string, string | string[]>
|
|
171
|
-
): boolean {
|
|
172
|
-
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
173
|
-
for (const key of keys) {
|
|
174
|
-
const va = a[key];
|
|
175
|
-
const vb = b[key];
|
|
176
|
-
if (Array.isArray(va) && Array.isArray(vb)) {
|
|
177
|
-
if (va.length !== vb.length || va.some((v, i) => v !== vb[i])) return false;
|
|
178
|
-
} else if (va !== vb) {
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
return true;
|
|
129
|
+
export function shouldSkipSlot(contentKey: string, clientSlotKeys: Set<string> | null): boolean {
|
|
130
|
+
if (!clientSlotKeys) return false;
|
|
131
|
+
return clientSlotKeys.has(contentKey);
|
|
183
132
|
}
|