@timber-js/app 0.2.0-alpha.160 → 0.2.0-alpha.163
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-C9yAuoX3.js → actions-cjklt63G.js} +7 -4
- package/dist/_chunks/{actions-C9yAuoX3.js.map → actions-cjklt63G.js.map} +1 -1
- package/dist/_chunks/{cache-api-swbefQ6F.js → cache-api-CzYUlgXA.js} +2 -2
- package/dist/_chunks/{cache-api-swbefQ6F.js.map → cache-api-CzYUlgXA.js.map} +1 -1
- package/dist/_chunks/{cli-schema-sync-BhYDz_-x.js → cli-schema-sync-NfLbLnDw.js} +2 -2
- package/dist/_chunks/{cli-schema-sync-BhYDz_-x.js.map → cli-schema-sync-NfLbLnDw.js.map} +1 -1
- package/dist/_chunks/{logger-CPoQkGK6.js → logger-B_O6-mdJ.js} +7 -45
- package/dist/_chunks/logger-B_O6-mdJ.js.map +1 -0
- package/dist/_chunks/{walkers-c8aReVgo.js → walkers-9mz9T7mb.js} +2 -2
- package/dist/_chunks/{walkers-c8aReVgo.js.map → walkers-9mz9T7mb.js.map} +1 -1
- package/dist/adapters/cloudflare.d.ts +13 -13
- package/dist/adapters/cloudflare.d.ts.map +1 -1
- package/dist/adapters/cloudflare.js +44 -46
- package/dist/adapters/cloudflare.js.map +1 -1
- package/dist/adapters/nitro.d.ts.map +1 -1
- package/dist/adapters/nitro.js +1 -8
- package/dist/adapters/nitro.js.map +1 -1
- package/dist/adapters/types.d.ts +0 -6
- package/dist/adapters/types.d.ts.map +1 -1
- package/dist/cache/index.js +1 -1
- package/dist/cli.js +1 -1
- package/dist/client/child-segment-context.d.ts +22 -0
- package/dist/client/child-segment-context.d.ts.map +1 -0
- package/dist/client/child-segment-outlet.d.ts +18 -0
- package/dist/client/child-segment-outlet.d.ts.map +1 -0
- package/dist/client/child-segment-provider.d.ts +21 -0
- package/dist/client/child-segment-provider.d.ts.map +1 -0
- package/dist/client/internal.js +1 -2
- package/dist/client/internal.js.map +1 -1
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-cookie.d.ts.map +1 -1
- package/dist/cookies/index.js +5 -1
- package/dist/cookies/index.js.map +1 -1
- package/dist/index.js +256 -9
- package/dist/index.js.map +1 -1
- package/dist/plugins/cache.d.ts.map +1 -1
- package/dist/plugins/callsite-ast.d.ts +27 -5
- package/dist/plugins/callsite-ast.d.ts.map +1 -1
- package/dist/plugins/fonts.d.ts.map +1 -1
- package/dist/plugins/prebuilt-capture.d.ts.map +1 -1
- package/dist/plugins/shims.d.ts.map +1 -1
- package/dist/routing/index.js +2 -2
- package/dist/server/actions.d.ts.map +1 -1
- package/dist/server/als-registry.d.ts +1 -0
- package/dist/server/als-registry.d.ts.map +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +2 -2
- package/dist/server/internal.js +85 -10
- package/dist/server/internal.js.map +1 -1
- package/dist/server/prebuilt-builder.d.ts.map +1 -1
- package/dist/server/prebuilt-runtime.d.ts.map +1 -1
- package/dist/server/primitives.d.ts +5 -12
- package/dist/server/primitives.d.ts.map +1 -1
- package/dist/server/route-element-builder.d.ts +7 -0
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/rsc-entry/index.d.ts +2 -2
- 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/state-tree-diff.d.ts +26 -3
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/package.json +3 -2
- package/src/adapters/cloudflare.ts +41 -63
- package/src/adapters/nitro.ts +0 -11
- package/src/adapters/types.ts +0 -7
- package/src/client/child-segment-context.ts +40 -0
- package/src/client/child-segment-outlet.tsx +25 -0
- package/src/client/child-segment-provider.tsx +27 -0
- package/src/client/segment-cache.ts +4 -5
- package/src/client/use-cookie.ts +11 -1
- package/src/plugins/cache.ts +44 -1
- package/src/plugins/callsite-ast.ts +310 -5
- package/src/plugins/fonts.ts +8 -0
- package/src/plugins/prebuilt-capture.ts +7 -0
- package/src/plugins/shims.ts +24 -0
- package/src/server/actions.ts +10 -1
- package/src/server/als-registry.ts +1 -0
- package/src/server/index.ts +1 -1
- package/src/server/prebuilt-builder.ts +120 -42
- package/src/server/prebuilt-runtime.ts +24 -1
- package/src/server/primitives.ts +5 -20
- package/src/server/route-element-builder.ts +113 -79
- package/src/server/rsc-entry/helpers.ts +12 -10
- package/src/server/rsc-entry/index.ts +40 -40
- package/src/server/rsc-entry/render-route.ts +6 -2
- package/src/server/rsc-entry/rsc-payload.ts +2 -2
- package/src/server/server-only-guard-noop.js +3 -0
- package/src/server/state-tree-diff.ts +49 -4
- package/dist/_chunks/logger-CPoQkGK6.js.map +0 -1
|
@@ -46,6 +46,7 @@ import { createElement } from 'react';
|
|
|
46
46
|
|
|
47
47
|
import { requestContextAls } from './als-registry.js';
|
|
48
48
|
import { ParamCoercionError } from './route-element-builder.js';
|
|
49
|
+
import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
|
|
49
50
|
import { coerceSegmentParams } from './param-coercion.js';
|
|
50
51
|
import { DenySignal, RedirectSignal } from './primitives.js';
|
|
51
52
|
import {
|
|
@@ -154,6 +155,8 @@ interface CandidateRoute {
|
|
|
154
155
|
layoutsToLoad: ManifestFile[];
|
|
155
156
|
/** Component IDs attributed to this route's page + layout chain. */
|
|
156
157
|
componentIds: string[];
|
|
158
|
+
/** Component IDs from layout files (receive ChildSegmentOutlet as children). */
|
|
159
|
+
layoutComponentIds: Set<string>;
|
|
157
160
|
}
|
|
158
161
|
|
|
159
162
|
/**
|
|
@@ -176,15 +179,20 @@ function collectCandidateRoutes(
|
|
|
176
179
|
|
|
177
180
|
if (node.page) {
|
|
178
181
|
const componentIds = new Set<string>();
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
+
const layoutIds = new Set<string>();
|
|
183
|
+
for (const f of chain)
|
|
184
|
+
for (const id of fileComponentMap[f.filePath] ?? []) {
|
|
185
|
+
componentIds.add(id);
|
|
186
|
+
layoutIds.add(id);
|
|
187
|
+
}
|
|
188
|
+
for (const id of fileComponentMap[node.page.filePath] ?? []) componentIds.add(id);
|
|
182
189
|
if (componentIds.size > 0) {
|
|
183
190
|
routes.push({
|
|
184
191
|
urlPath: node.urlPath,
|
|
185
192
|
page: node.page,
|
|
186
193
|
layoutsToLoad: chain.filter((l) => (fileComponentMap[l.filePath] ?? []).length > 0),
|
|
187
194
|
componentIds: Array.from(componentIds),
|
|
195
|
+
layoutComponentIds: layoutIds,
|
|
188
196
|
});
|
|
189
197
|
}
|
|
190
198
|
}
|
|
@@ -252,15 +260,14 @@ async function enumerateCombos(
|
|
|
252
260
|
|
|
253
261
|
// ─── Rendering ────────────────────────────────────────────────────────────
|
|
254
262
|
|
|
255
|
-
/** Concatenate drained chunks without Buffer (the bundle also runs on Workers). */
|
|
256
263
|
function concatChunks(chunks: Uint8Array[]): Uint8Array {
|
|
257
264
|
let total = 0;
|
|
258
|
-
for (const
|
|
265
|
+
for (const c of chunks) total += c.byteLength;
|
|
259
266
|
const out = new Uint8Array(total);
|
|
260
|
-
let
|
|
261
|
-
for (const
|
|
262
|
-
out.set(
|
|
263
|
-
|
|
267
|
+
let off = 0;
|
|
268
|
+
for (const c of chunks) {
|
|
269
|
+
out.set(c, off);
|
|
270
|
+
off += c.byteLength;
|
|
264
271
|
}
|
|
265
272
|
return out;
|
|
266
273
|
}
|
|
@@ -299,10 +306,10 @@ async function drainWithTimeout(
|
|
|
299
306
|
|
|
300
307
|
/** Wrap payload bytes as a single-chunk stream for the smoke revival. */
|
|
301
308
|
function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
|
|
302
|
-
return new ReadableStream
|
|
303
|
-
start(
|
|
304
|
-
|
|
305
|
-
|
|
309
|
+
return new ReadableStream({
|
|
310
|
+
start(c) {
|
|
311
|
+
c.enqueue(bytes);
|
|
312
|
+
c.close();
|
|
306
313
|
},
|
|
307
314
|
});
|
|
308
315
|
}
|
|
@@ -332,7 +339,8 @@ async function renderEntry(
|
|
|
332
339
|
registration: PrebuiltRegistration,
|
|
333
340
|
props: Record<string, unknown>,
|
|
334
341
|
coercedParams: Record<string, string | string[]>,
|
|
335
|
-
timeoutMs: number
|
|
342
|
+
timeoutMs: number,
|
|
343
|
+
isLayout: boolean
|
|
336
344
|
): Promise<EntryOutcome> {
|
|
337
345
|
const storeHandle = createBuildTimeStore(registration.id, coercedParams);
|
|
338
346
|
const { store, violations } = storeHandle;
|
|
@@ -348,9 +356,15 @@ async function renderEntry(
|
|
|
348
356
|
// synthetic store (segmentParams.get() inside the component).
|
|
349
357
|
// Tag resolution also runs inside this scope so function-form tags
|
|
350
358
|
// that read segment params see the synthetic store (codex P2 on PR #858).
|
|
359
|
+
// Layout captures get <ChildSegmentOutlet /> as children so the
|
|
360
|
+
// cached payload has a context-driven hole (design/45, TIM-1181).
|
|
361
|
+
const renderProps =
|
|
362
|
+
isLayout && !('children' in props)
|
|
363
|
+
? { ...props, children: createElement(ChildSegmentOutlet as React.FC) }
|
|
364
|
+
: props;
|
|
351
365
|
bytes = await requestContextAls.run(store, async () => {
|
|
352
366
|
const stream = renderToReadableStream(
|
|
353
|
-
createElement(registration.component as RenderableComponent,
|
|
367
|
+
createElement(registration.component as RenderableComponent, renderProps),
|
|
354
368
|
{
|
|
355
369
|
onError(error: unknown) {
|
|
356
370
|
failure ??= error ?? new Error('unknown render error');
|
|
@@ -422,7 +436,7 @@ function warnNestingClamps(
|
|
|
422
436
|
let count = 0;
|
|
423
437
|
for (const { innerId, options } of nested) {
|
|
424
438
|
if (options.ttl === undefined && options.tags === undefined) continue;
|
|
425
|
-
const pairKey = `${outerId}
|
|
439
|
+
const pairKey = `${outerId} ${innerId}`;
|
|
426
440
|
if (warnedPairs.has(pairKey)) continue;
|
|
427
441
|
warnedPairs.add(pairKey);
|
|
428
442
|
count++;
|
|
@@ -448,6 +462,15 @@ function resolveCapturedTags(
|
|
|
448
462
|
return resolved.length > 0 ? resolved : undefined;
|
|
449
463
|
}
|
|
450
464
|
|
|
465
|
+
/** Buffered entry pending the param-independence decision (TIM-1175). */
|
|
466
|
+
interface PendingEntry {
|
|
467
|
+
callSiteProps: Record<string, unknown>;
|
|
468
|
+
cacheKey: string;
|
|
469
|
+
combo: ParamCombo;
|
|
470
|
+
bytes: Uint8Array;
|
|
471
|
+
tags: string[] | undefined;
|
|
472
|
+
}
|
|
473
|
+
|
|
451
474
|
/**
|
|
452
475
|
* Capture flight payloads for every attributed, registered, prerender-tier
|
|
453
476
|
* component across the route manifest. See module docblock for the flow.
|
|
@@ -465,9 +488,18 @@ export async function capturePrebuiltPayloads(
|
|
|
465
488
|
const warnedPairs = new Set<string>();
|
|
466
489
|
const reportedMissingRegistrations = new Set<string>();
|
|
467
490
|
const eagerLoadedChunks = new Set<string>();
|
|
468
|
-
// Components
|
|
469
|
-
|
|
470
|
-
|
|
491
|
+
// Components already emitted as param-independent — skip on second route.
|
|
492
|
+
const emittedParamIndependent = new Set<string>();
|
|
493
|
+
// (componentId, propsKey) pairs that rendered at least once without
|
|
494
|
+
// reading segment params. Segment param access is structural (the
|
|
495
|
+
// component either calls getSegmentParams() or it doesn't), so if the
|
|
496
|
+
// first combo doesn't trigger it, subsequent combos won't either —
|
|
497
|
+
// skip them to avoid redundant renders. Tracked per props set so a
|
|
498
|
+
// failed variant doesn't suppress retries for a different variant.
|
|
499
|
+
const provenParamIndependent = new Set<string>();
|
|
500
|
+
// Per-component: whether ANY combo read segmentParams, plus buffered
|
|
501
|
+
// entries awaiting the param-independence decision after all combos.
|
|
502
|
+
const pendingByComponent = new Map<string, { anyParamRead: boolean; entries: PendingEntry[] }>();
|
|
471
503
|
|
|
472
504
|
const skip = (issue: CaptureIssue) => {
|
|
473
505
|
summary.skipped.push(issue);
|
|
@@ -594,8 +626,8 @@ export async function capturePrebuiltPayloads(
|
|
|
594
626
|
}
|
|
595
627
|
// Runtime tier (ttl/tags without prerender) does no build work.
|
|
596
628
|
if (registration.options.prerender !== true) continue;
|
|
597
|
-
//
|
|
598
|
-
if (
|
|
629
|
+
// Already emitted as param-independent from a prior route.
|
|
630
|
+
if (emittedParamIndependent.has(componentId)) continue;
|
|
599
631
|
|
|
600
632
|
// One entry per distinct call-site props object (design/44 §Props
|
|
601
633
|
// Derivation, TIM-1142). When no call-site props were captured,
|
|
@@ -608,7 +640,23 @@ export async function capturePrebuiltPayloads(
|
|
|
608
640
|
const propsSets: Record<string, unknown>[] =
|
|
609
641
|
capturedProps && capturedProps.length > 0 ? [...capturedProps] : [{}];
|
|
610
642
|
|
|
643
|
+
let pending = pendingByComponent.get(componentId);
|
|
644
|
+
if (!pending) {
|
|
645
|
+
pending = { anyParamRead: false, entries: [] };
|
|
646
|
+
pendingByComponent.set(componentId, pending);
|
|
647
|
+
}
|
|
648
|
+
|
|
611
649
|
for (const callSiteProps of propsSets) {
|
|
650
|
+
// Already rendered this (component, props) pair without reading
|
|
651
|
+
// params — additional combos produce identical payloads. Tracked
|
|
652
|
+
// per props set so a failed variant doesn't suppress retries.
|
|
653
|
+
// Guard on !anyParamRead: if another prop variant already proved
|
|
654
|
+
// param-dependent, this variant needs per-combo entries too since
|
|
655
|
+
// the emit phase uses a component-level paramIndependent flag.
|
|
656
|
+
const propsKey = JSON.stringify(callSiteProps);
|
|
657
|
+
const proofKey = `${componentId}\0${propsKey}`;
|
|
658
|
+
if (provenParamIndependent.has(proofKey) && !pending.anyParamRead) continue;
|
|
659
|
+
|
|
612
660
|
const cacheKey = computeComponentCacheKey(callSiteProps, coercedParams);
|
|
613
661
|
if (cacheKey === null) {
|
|
614
662
|
skip({
|
|
@@ -621,11 +669,27 @@ export async function capturePrebuiltPayloads(
|
|
|
621
669
|
});
|
|
622
670
|
continue;
|
|
623
671
|
}
|
|
624
|
-
const entryKey = `${componentId}
|
|
672
|
+
const entryKey = `${componentId} ${cacheKey}`;
|
|
625
673
|
if (seenEntries.has(entryKey)) continue;
|
|
626
674
|
seenEntries.add(entryKey);
|
|
627
675
|
|
|
628
|
-
const
|
|
676
|
+
const isLayout = route.layoutComponentIds.has(componentId);
|
|
677
|
+
const outcome = await renderEntry(
|
|
678
|
+
registration,
|
|
679
|
+
callSiteProps,
|
|
680
|
+
coercedParams,
|
|
681
|
+
timeoutMs,
|
|
682
|
+
isLayout
|
|
683
|
+
);
|
|
684
|
+
// Track param reads immediately — before any failure continue —
|
|
685
|
+
// so a combo that reads params then fails still prevents
|
|
686
|
+
// param-independent classification (codex P1 on PR #869).
|
|
687
|
+
if (outcome.segmentParamsRead) {
|
|
688
|
+
pending.anyParamRead = true;
|
|
689
|
+
provenParamIndependent.delete(proofKey);
|
|
690
|
+
} else if (outcome.failure === null && !pending.anyParamRead) {
|
|
691
|
+
provenParamIndependent.add(proofKey);
|
|
692
|
+
}
|
|
629
693
|
summary.nestingWarnings += warnNestingClamps(
|
|
630
694
|
componentId,
|
|
631
695
|
outcome.nested,
|
|
@@ -665,32 +729,46 @@ export async function capturePrebuiltPayloads(
|
|
|
665
729
|
continue;
|
|
666
730
|
}
|
|
667
731
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
if (isParamIndep) {
|
|
673
|
-
paramIndependentComponents.add(componentId);
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
const finalCacheKey = isParamIndep
|
|
677
|
-
? (computeComponentCacheKey(callSiteProps, {}) ?? cacheKey)
|
|
678
|
-
: cacheKey;
|
|
679
|
-
const hasProps = Object.keys(callSiteProps).length > 0;
|
|
680
|
-
await options.onEntry({
|
|
681
|
-
componentId,
|
|
682
|
-
cacheKey: finalCacheKey,
|
|
683
|
-
params: isParamIndep ? {} : combo,
|
|
684
|
-
...(hasProps ? { props: callSiteProps } : {}),
|
|
732
|
+
pending.entries.push({
|
|
733
|
+
callSiteProps,
|
|
734
|
+
cacheKey,
|
|
735
|
+
combo,
|
|
685
736
|
bytes: outcome.bytes!,
|
|
686
737
|
tags: outcome.resolvedTags,
|
|
687
|
-
...(isParamIndep ? { paramIndependent: true } : {}),
|
|
688
738
|
});
|
|
689
|
-
summary.captured++;
|
|
690
739
|
}
|
|
691
740
|
}
|
|
692
741
|
}
|
|
693
742
|
}
|
|
694
743
|
|
|
744
|
+
// Emit buffered entries. Param-independent = ALL combos rendered and
|
|
745
|
+
// NONE read segmentParams → collapse to one entry per props set (TIM-1175).
|
|
746
|
+
for (const [componentId, pending] of pendingByComponent) {
|
|
747
|
+
const isParamIndep = !pending.anyParamRead && pending.entries.length > 0;
|
|
748
|
+
if (isParamIndep) emittedParamIndependent.add(componentId);
|
|
749
|
+
const emittedProps = isParamIndep ? new Set<string>() : null;
|
|
750
|
+
for (const entry of pending.entries) {
|
|
751
|
+
if (emittedProps) {
|
|
752
|
+
const propsKey = JSON.stringify(entry.callSiteProps);
|
|
753
|
+
if (emittedProps.has(propsKey)) continue;
|
|
754
|
+
emittedProps.add(propsKey);
|
|
755
|
+
}
|
|
756
|
+
const hasProps = Object.keys(entry.callSiteProps).length > 0;
|
|
757
|
+
const finalCacheKey = isParamIndep
|
|
758
|
+
? (computeComponentCacheKey(entry.callSiteProps, {}) ?? entry.cacheKey)
|
|
759
|
+
: entry.cacheKey;
|
|
760
|
+
await options.onEntry({
|
|
761
|
+
componentId,
|
|
762
|
+
cacheKey: finalCacheKey,
|
|
763
|
+
params: isParamIndep ? {} : entry.combo,
|
|
764
|
+
...(hasProps ? { props: entry.callSiteProps } : {}),
|
|
765
|
+
bytes: entry.bytes,
|
|
766
|
+
tags: entry.tags,
|
|
767
|
+
...(isParamIndep ? { paramIndependent: true } : {}),
|
|
768
|
+
});
|
|
769
|
+
summary.captured++;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
|
|
695
773
|
return summary;
|
|
696
774
|
}
|
|
@@ -37,6 +37,7 @@ import { createElement } from 'react';
|
|
|
37
37
|
|
|
38
38
|
import { requestContextAls, type RequestContextStore } from './als-registry.js';
|
|
39
39
|
import { computeComponentCacheKey, tryCanonicalize } from './prebuilt/cache-key.js';
|
|
40
|
+
import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
|
|
40
41
|
import {
|
|
41
42
|
isCaptureActive,
|
|
42
43
|
noteWrapperRender,
|
|
@@ -510,7 +511,29 @@ async function tryRevivePayload(
|
|
|
510
511
|
const keyParams = paramIndep ? {} : resolveKeyParams(store);
|
|
511
512
|
if (keyParams === null) return null;
|
|
512
513
|
|
|
513
|
-
|
|
514
|
+
// Strip `children` from the cache key ONLY when it is the framework-
|
|
515
|
+
// injected ChildSegmentOutlet. Layout components receive
|
|
516
|
+
// <ChildSegmentOutlet /> as children — a deterministic client reference
|
|
517
|
+
// that is the same on every request, so it should not vary the key.
|
|
518
|
+
// User-provided React element children (e.g., <Cached><A /></Cached>)
|
|
519
|
+
// are NOT stripped — they remain unkeyable so the component renders
|
|
520
|
+
// live, which is correct: the cache cannot distinguish <A> from <B>.
|
|
521
|
+
// See design/45-cache-lifetimes.md §Layout Propagation, TIM-1181.
|
|
522
|
+
const allProps = (props ?? {}) as Record<string, unknown>;
|
|
523
|
+
let keyProps: Record<string, unknown>;
|
|
524
|
+
const childrenValue = allProps.children;
|
|
525
|
+
const isChildSegmentOutlet =
|
|
526
|
+
childrenValue != null &&
|
|
527
|
+
typeof childrenValue === 'object' &&
|
|
528
|
+
'type' in childrenValue &&
|
|
529
|
+
(childrenValue as { type: unknown }).type === ChildSegmentOutlet;
|
|
530
|
+
if (isChildSegmentOutlet) {
|
|
531
|
+
const { children: _children, ...rest } = allProps;
|
|
532
|
+
keyProps = rest;
|
|
533
|
+
} else {
|
|
534
|
+
keyProps = allProps;
|
|
535
|
+
}
|
|
536
|
+
const cacheKey = computeComponentCacheKey(keyProps, keyParams);
|
|
514
537
|
if (cacheKey === null) return null;
|
|
515
538
|
|
|
516
539
|
const outcome =
|
package/src/server/primitives.ts
CHANGED
|
@@ -416,11 +416,6 @@ export class RenderError<
|
|
|
416
416
|
|
|
417
417
|
// ─── waitUntil ──────────────────────────────────────────────────────────────
|
|
418
418
|
|
|
419
|
-
/** Minimal interface for adapters that support background work. */
|
|
420
|
-
export interface WaitUntilAdapter {
|
|
421
|
-
waitUntil?(promise: Promise<unknown>): void;
|
|
422
|
-
}
|
|
423
|
-
|
|
424
419
|
// Intentional per-app singleton — warn-once flag that persists for the
|
|
425
420
|
// lifetime of the process/isolate. Not per-request; do not migrate to ALS.
|
|
426
421
|
let _waitUntilWarned = false;
|
|
@@ -429,30 +424,20 @@ let _waitUntilWarned = false;
|
|
|
429
424
|
* Register a promise to be kept alive after the response is sent.
|
|
430
425
|
* Maps to `ctx.waitUntil()` on Cloudflare Workers and similar platforms.
|
|
431
426
|
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
*
|
|
435
|
-
*
|
|
436
|
-
* If neither is available, a warning is logged once and the promise is
|
|
437
|
-
* left to resolve (or reject) without being tracked.
|
|
427
|
+
* The platform adapter installs a per-request waitUntil function via ALS
|
|
428
|
+
* (see waituntil-bridge.ts). If no ALS handler is available, a warning
|
|
429
|
+
* is logged once and the promise is left to resolve (or reject) without
|
|
430
|
+
* being tracked.
|
|
438
431
|
*
|
|
439
432
|
* @param promise - The background work to keep alive.
|
|
440
|
-
* @param adapter - Optional legacy adapter (prefer ALS bridge in production).
|
|
441
433
|
*/
|
|
442
|
-
export function waitUntil(promise: Promise<unknown
|
|
443
|
-
// Check ALS bridge first (installed by generated entry points)
|
|
434
|
+
export function waitUntil(promise: Promise<unknown>): void {
|
|
444
435
|
const alsFn = _getWaitUntil();
|
|
445
436
|
if (alsFn) {
|
|
446
437
|
alsFn(promise);
|
|
447
438
|
return;
|
|
448
439
|
}
|
|
449
440
|
|
|
450
|
-
// Fall back to legacy adapter argument
|
|
451
|
-
if (adapter && typeof adapter.waitUntil === 'function') {
|
|
452
|
-
adapter.waitUntil(promise);
|
|
453
|
-
return;
|
|
454
|
-
}
|
|
455
|
-
|
|
456
441
|
if (!_waitUntilWarned) {
|
|
457
442
|
_waitUntilWarned = true;
|
|
458
443
|
console.warn(
|
|
@@ -44,10 +44,12 @@ import type { DenyPageEntry } from './deny-boundary.js';
|
|
|
44
44
|
import { resolveSlotElement } from './slot-resolver.js';
|
|
45
45
|
import { SegmentProvider } from '../client/segment-context.js';
|
|
46
46
|
import { SegmentOutlet } from '../client/segment-outlet.js';
|
|
47
|
+
import { ChildSegmentOutlet } from '../client/child-segment-outlet.js';
|
|
48
|
+
import { ChildSegmentProvider } from '../client/child-segment-provider.js';
|
|
47
49
|
|
|
48
50
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
49
51
|
import type { InterceptionContext } from './pipeline.js';
|
|
50
|
-
import { shouldSkipSegment } from './state-tree-diff.js';
|
|
52
|
+
import { shouldSkipSegment, computeSegmentKeys } from './state-tree-diff.js';
|
|
51
53
|
import type { HeadElement } from './metadata.js';
|
|
52
54
|
|
|
53
55
|
/**
|
|
@@ -126,7 +128,7 @@ export function isClientReference(component: unknown): boolean {
|
|
|
126
128
|
);
|
|
127
129
|
}
|
|
128
130
|
|
|
129
|
-
// ───
|
|
131
|
+
// ─── Typed Errors ────────────────────────────────────────────────────────
|
|
130
132
|
|
|
131
133
|
/**
|
|
132
134
|
* Thrown when a defineSegmentParams codec's parse() fails.
|
|
@@ -139,6 +141,17 @@ export class ParamCoercionError extends Error {
|
|
|
139
141
|
}
|
|
140
142
|
}
|
|
141
143
|
|
|
144
|
+
/**
|
|
145
|
+
* Thrown when a matched route has no page component in its leaf segment.
|
|
146
|
+
* The pipeline catches this and renders the custom 404 page.
|
|
147
|
+
*/
|
|
148
|
+
export class NoPageComponentError extends Error {
|
|
149
|
+
constructor(message: string) {
|
|
150
|
+
super(message);
|
|
151
|
+
this.name = 'NoPageComponentError';
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
142
155
|
// ─── Types ────────────────────────────────────────────────────────────────
|
|
143
156
|
|
|
144
157
|
/** Layout entry with component and segment. */
|
|
@@ -352,7 +365,7 @@ export async function buildRouteElement(
|
|
|
352
365
|
` [${i}] ${s.segmentName} (page: ${s.page ? 'yes' : 'no'}, layout: ${s.layout ? 'yes' : 'no'}, children: ${s.children?.length ?? 0})${i === segments.length - 1 ? ' ← leaf' : ''}`
|
|
353
366
|
)
|
|
354
367
|
.join('\n');
|
|
355
|
-
throw new
|
|
368
|
+
throw new NoPageComponentError(
|
|
356
369
|
`No page component found for route: ${new URL(req.url).pathname}\nMatched segments:\n${segmentInfo}`
|
|
357
370
|
);
|
|
358
371
|
}
|
|
@@ -502,61 +515,96 @@ export async function buildRouteElement(
|
|
|
502
515
|
// The client uses this to merge the partial payload with its cached segments.
|
|
503
516
|
const skippedSegments: string[] = [];
|
|
504
517
|
|
|
505
|
-
//
|
|
506
|
-
//
|
|
507
|
-
//
|
|
508
|
-
//
|
|
509
|
-
//
|
|
518
|
+
// Compute state-tree keys for all segments. Route groups accumulate
|
|
519
|
+
// ancestor group names for globally unique keys (e.g., "/(a)/(shared)"
|
|
520
|
+
// instead of just "/(shared)"). This is the single key computation —
|
|
521
|
+
// the same keys are used for skip decisions, SegmentOutlet props, and
|
|
522
|
+
// the X-Timber-Segments header (via buildSegmentInfo).
|
|
523
|
+
const segmentKeys = computeSegmentKeys(segments);
|
|
524
|
+
|
|
525
|
+
// Pre-compute the contiguous skippable prefix (top-down).
|
|
510
526
|
//
|
|
511
|
-
//
|
|
512
|
-
//
|
|
513
|
-
//
|
|
514
|
-
//
|
|
527
|
+
// Skipped segments must form a contiguous prefix from root to avoid
|
|
528
|
+
// non-contiguous skips that break the client merge. Walk from root
|
|
529
|
+
// toward leaf, marking segments as skippable. Stop at the first
|
|
530
|
+
// segment that can't be skipped (async, not in client tree, leaf).
|
|
531
|
+
// Layoutless segments are transparent (no outlet, no layout to skip).
|
|
515
532
|
//
|
|
516
|
-
//
|
|
517
|
-
//
|
|
518
|
-
// there
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
533
|
+
// After finding the prefix, validate: the first non-skipped layout
|
|
534
|
+
// must exist in the client's state tree (the client needs a mounted
|
|
535
|
+
// outlet there to receive the partial payload).
|
|
536
|
+
const skippableSet = new Set<number>();
|
|
537
|
+
if (clientStateTree) {
|
|
538
|
+
for (let i = 0; i < segments.length; i++) {
|
|
539
|
+
const isLeaf = i === segments.length - 1;
|
|
540
|
+
const layoutComponent = layoutBySegment.get(segments[i]);
|
|
541
|
+
|
|
542
|
+
// Never skip denied segments — their AccessGate must render the
|
|
543
|
+
// deny page, not reuse the client's cached (passing) layout.
|
|
544
|
+
if (i >= firstDeniedIndex) break;
|
|
545
|
+
|
|
546
|
+
if (shouldSkipSegment(segmentKeys[i], layoutComponent, isLeaf, clientStateTree)) {
|
|
547
|
+
skippableSet.add(i);
|
|
548
|
+
} else if (layoutComponent) {
|
|
549
|
+
break;
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Ensure there's a merge point: a non-skipped layout whose key is
|
|
554
|
+
// in the client's state tree (so the client has a mounted outlet).
|
|
555
|
+
// If all layouts are in the prefix, keep the innermost as merge point.
|
|
556
|
+
if (skippableSet.size > 0) {
|
|
557
|
+
let mergePointIndex = -1;
|
|
558
|
+
for (let i = 0; i < segments.length; i++) {
|
|
559
|
+
if (skippableSet.has(i)) continue;
|
|
560
|
+
if (layoutBySegment.has(segments[i])) {
|
|
561
|
+
mergePointIndex = i;
|
|
562
|
+
break;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
if (mergePointIndex === -1) {
|
|
567
|
+
// All layouts are skippable — keep the innermost as merge point
|
|
568
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
569
|
+
if (skippableSet.has(i) && layoutBySegment.has(segments[i])) {
|
|
570
|
+
skippableSet.delete(i);
|
|
571
|
+
mergePointIndex = i;
|
|
572
|
+
break;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Cross-section guard: at least one rendered layout below the
|
|
578
|
+
// prefix must exist in the client's state tree (the client needs
|
|
579
|
+
// a mounted outlet to receive the partial payload). Check ALL
|
|
580
|
+
// non-skipped layouts, not just the merge point — async layouts
|
|
581
|
+
// are excluded from the client state tree but may sit above a
|
|
582
|
+
// sync layout that IS in the tree.
|
|
583
|
+
let hasClientOutlet = false;
|
|
584
|
+
for (let j = 0; j < segments.length; j++) {
|
|
585
|
+
if (skippableSet.has(j)) continue;
|
|
586
|
+
if (layoutBySegment.has(segments[j]) && clientStateTree.has(segmentKeys[j])) {
|
|
587
|
+
hasClientOutlet = true;
|
|
588
|
+
break;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
if (!hasClientOutlet) {
|
|
592
|
+
skippableSet.clear();
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
524
597
|
let outermostSegmentProvider: React.ReactElement | null = null;
|
|
525
|
-
// Track whether any rendered inner layout also exists in the client's
|
|
526
|
-
// state tree. This prevents cross-section skipping: e.g., navigating
|
|
527
|
-
// from /(group-a) to /dashboard shouldn't skip root "/" because the
|
|
528
|
-
// client has no mounted outlet at "/dashboard".
|
|
529
|
-
let innerRenderedInClientTree = false;
|
|
530
598
|
|
|
531
599
|
for (let i = segments.length - 1; i >= 0; i--) {
|
|
532
600
|
const segment = segments[i];
|
|
533
601
|
const isLeaf = i === segments.length - 1;
|
|
534
602
|
const layoutComponent = layoutBySegment.get(segment);
|
|
535
603
|
|
|
536
|
-
//
|
|
537
|
-
|
|
538
|
-
// no AccessGate in element tree (access already ran pre-render).
|
|
539
|
-
//
|
|
540
|
-
// Additional constraints beyond shouldSkipSegment:
|
|
541
|
-
// - Must have a rendered layout below (so the merger can find an
|
|
542
|
-
// inner SegmentProvider to splice the new content into)
|
|
543
|
-
// - Route groups are never skipped because sibling groups share the
|
|
544
|
-
// same urlPath (e.g., /(marketing) and /(app) both have "/"),
|
|
545
|
-
// which would cause the wrong cached layout to be reused
|
|
546
|
-
// - At least one inner rendered layout must exist in the client's
|
|
547
|
-
// state tree, ensuring the client has a mounted outlet to receive
|
|
548
|
-
// the partial payload
|
|
549
|
-
const skip =
|
|
550
|
-
shouldSkipSegment(segment.urlPath, layoutComponent, isLeaf, clientStateTree ?? null) &&
|
|
551
|
-
hasRenderedLayoutBelow &&
|
|
552
|
-
segment.segmentType !== 'group' &&
|
|
553
|
-
innerRenderedInClientTree;
|
|
554
|
-
|
|
555
|
-
if (skip) {
|
|
556
|
-
// Skip this segment's layout/error boundaries — the client uses its cached version.
|
|
557
|
-
// Metadata was already resolved above (head elements are correct).
|
|
604
|
+
// Skip decision was pre-computed in the top-down prefix pass.
|
|
605
|
+
if (skippableSet.has(i)) {
|
|
558
606
|
// Record for X-Timber-Skipped-Segments header (outermost first, so prepend).
|
|
559
|
-
skippedSegments.unshift(
|
|
607
|
+
skippedSegments.unshift(segmentKeys[i]);
|
|
560
608
|
|
|
561
609
|
// SECURITY: Even though the layout is skipped, AccessGate MUST still
|
|
562
610
|
// wrap the element tree. access.ts runs on every navigation regardless
|
|
@@ -566,9 +614,6 @@ export async function buildRouteElement(
|
|
|
566
614
|
const accessMod = await loadModule(segment.access);
|
|
567
615
|
const accessFn = accessMod.default as (() => unknown) | undefined;
|
|
568
616
|
if (accessFn) {
|
|
569
|
-
// Pass verdict for denied/redirected segments so AccessGate replays
|
|
570
|
-
// without re-execution. Passing segments omit verdict so AccessGate
|
|
571
|
-
// re-runs access during render for React.cache population.
|
|
572
617
|
const verdict = accessVerdicts.get(i);
|
|
573
618
|
element = h(AccessGate, {
|
|
574
619
|
accessFn,
|
|
@@ -583,23 +628,6 @@ export async function buildRouteElement(
|
|
|
583
628
|
continue;
|
|
584
629
|
}
|
|
585
630
|
|
|
586
|
-
// This segment is rendered — mark that future (outer) segments have
|
|
587
|
-
// a rendered layout below them and can safely be skipped.
|
|
588
|
-
if (layoutComponent) {
|
|
589
|
-
hasRenderedLayoutBelow = true;
|
|
590
|
-
// Use segmentId for the client state tree check. Route groups share
|
|
591
|
-
// their parent's urlPath (both "/"), but their segmentId includes
|
|
592
|
-
// the group name (e.g., "/(group-a)"), so the check correctly
|
|
593
|
-
// fails when the client has never visited that group.
|
|
594
|
-
const outletKey =
|
|
595
|
-
segment.segmentType === 'group'
|
|
596
|
-
? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
|
|
597
|
-
: segment.urlPath;
|
|
598
|
-
if (clientStateTree?.has(outletKey)) {
|
|
599
|
-
innerRenderedInClientTree = true;
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
|
|
603
631
|
// Wrap with error boundaries from this segment (inside layout).
|
|
604
632
|
// Keep ALL error boundaries (including 4xx) — they're the safety net for
|
|
605
633
|
// DenySignal from nested server components that escape AccessGate/PageDenyBoundary
|
|
@@ -641,22 +669,21 @@ export async function buildRouteElement(
|
|
|
641
669
|
const segmentPath = rawPath.length === 2 && rawPath[1] === '' ? [''] : rawPath;
|
|
642
670
|
const parallelRouteKeys = Object.keys(segment.slots ?? {});
|
|
643
671
|
|
|
644
|
-
|
|
645
|
-
// so include the group name to distinguish them. Used for both OTEL
|
|
646
|
-
// span labels and client-side element caching (segmentId).
|
|
647
|
-
const segmentId =
|
|
648
|
-
segment.segmentType === 'group'
|
|
649
|
-
? `${segment.urlPath === '/' ? '' : segment.urlPath}/${segment.segmentName}`
|
|
650
|
-
: segment.urlPath;
|
|
672
|
+
const segmentId = segmentKeys[i];
|
|
651
673
|
|
|
652
674
|
// Build the layout element.
|
|
653
|
-
//
|
|
654
|
-
//
|
|
675
|
+
// Layouts receive <ChildSegmentOutlet /> as children — a stable client
|
|
676
|
+
// reference — instead of the varying inner content. The actual child
|
|
677
|
+
// content is provided via ChildSegmentProvider above the layout.
|
|
678
|
+
// This makes layout cache.component hits possible: the cached payload
|
|
679
|
+
// contains a deterministic children reference, not varying content.
|
|
680
|
+
// See design/45-cache-lifetimes.md §Layout Propagation.
|
|
681
|
+
const childOutlet = h(ChildSegmentOutlet, {});
|
|
655
682
|
let layoutElement: React.ReactElement;
|
|
656
683
|
if (isClientReference(layoutComponent)) {
|
|
657
684
|
layoutElement = h(layoutComponent, {
|
|
658
685
|
...slotProps,
|
|
659
|
-
children:
|
|
686
|
+
children: childOutlet,
|
|
660
687
|
});
|
|
661
688
|
} else {
|
|
662
689
|
// Server component layout — wrap with OTEL tracing AND DenySignal
|
|
@@ -686,10 +713,17 @@ export async function buildRouteElement(
|
|
|
686
713
|
};
|
|
687
714
|
layoutElement = h(TracedLayout, {
|
|
688
715
|
...slotProps,
|
|
689
|
-
children:
|
|
716
|
+
children: childOutlet,
|
|
690
717
|
});
|
|
691
718
|
}
|
|
692
719
|
|
|
720
|
+
// Wrap the layout in ChildSegmentProvider so ChildSegmentOutlet
|
|
721
|
+
// (inside the layout's children) can read the actual child content.
|
|
722
|
+
layoutElement = h(ChildSegmentProvider, {
|
|
723
|
+
childContent: element,
|
|
724
|
+
children: layoutElement,
|
|
725
|
+
});
|
|
726
|
+
|
|
693
727
|
const segmentProviderElement = h(SegmentProvider, {
|
|
694
728
|
segments: segmentPath,
|
|
695
729
|
segmentId,
|