@timber-js/app 0.2.0-alpha.179 → 0.2.0-alpha.180
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/client/browser-entry/router-init.d.ts.map +1 -1
- package/dist/client/index.js.map +1 -1
- package/dist/client/internal.js +5 -3
- package/dist/client/internal.js.map +1 -1
- package/dist/client/navigation-api.d.ts +1 -0
- package/dist/client/navigation-api.d.ts.map +1 -1
- package/dist/client/router.d.ts +6 -0
- package/dist/client/router.d.ts.map +1 -1
- package/dist/client/segment-outlet.d.ts.map +1 -1
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +12 -0
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/client/browser-entry/router-init.ts +2 -5
- package/src/client/navigation-api.ts +9 -5
- package/src/client/router.ts +27 -8
- package/src/client/segment-outlet.tsx +44 -2
- package/src/server/route-element-builder.ts +99 -2
- package/src/server/slot-resolver.ts +1 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAe9D,OAAO,
|
|
1
|
+
{"version":3,"file":"route-element-builder.d.ts","sourceRoot":"","sources":["../../src/server/route-element-builder.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAKH,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AAChD,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAe9D,OAAO,EAAqC,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAO3F,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAKL,KAAK,eAAe,EACrB,MAAM,sBAAsB,CAAC;AAsH9B;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,CAAC,SAAS,EAAE,OAAO,GAAG,OAAO,CAM7D;AAID;;;GAGG;AACH,qBAAa,kBAAmB,SAAQ,KAAK;IAC3C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAED;;;GAGG;AACH,qBAAa,oBAAqB,SAAQ,KAAK;IAC7C,YAAY,OAAO,EAAE,MAAM,EAG1B;CACF;AAID,+CAA+C;AAC/C,MAAM,WAAW,oBAAoB;IACnC,SAAS,EAAE,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,OAAO,CAAC;IAC3C,OAAO,EAAE,mBAAmB,CAAC;CAC9B;AAED,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,wFAAwF;IACxF,OAAO,EAAE,KAAK,CAAC,YAAY,CAAC;IAC5B,wDAAwD;IACxD,gBAAgB,EAAE,oBAAoB,EAAE,CAAC;IACzC,qCAAqC;IACrC,QAAQ,EAAE,mBAAmB,EAAE,CAAC;IAChC,4DAA4D;IAC5D,gBAAgB,EAAE,MAAM,CAAC;IACzB;;;;;OAKG;IACH,eAAe,EAAE,MAAM,EAAE,CAAC;IAC1B;;;;;;;OAOG;IACH,YAAY,CAAC,EAAE,OAAO,CAAC,IAAI,CAAC,CAAC;IAC7B;;;;OAIG;IACH,YAAY,EAAE,aAAa,EAAE,CAAC;CAC/B;AA8DD;;;;;;;;;;;;;;;GAeG;AACH,wBAAsB,iBAAiB,CACrC,GAAG,EAAE,OAAO,EACZ,KAAK,EAAE,UAAU,EACjB,YAAY,CAAC,EAAE,mBAAmB,EAClC,eAAe,CAAC,EAAE,eAAe,GAAG,IAAI,EACxC,mBAAmB,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC3C,OAAO,CAAC,kBAAkB,CAAC,CAohB7B"}
|
|
@@ -34,6 +34,18 @@ type CreateElementFn = (...args: unknown[]) => React.ReactElement;
|
|
|
34
34
|
* so a denied layout never executes — see TIM-1074)
|
|
35
35
|
*/
|
|
36
36
|
export declare function resolveSlotElement(slotNode: ManifestSegmentNode, match: RouteMatch, h: CreateElementFn, interception?: InterceptionContext, parentTreePath?: string, accessVerdicts?: SlotAccessVerdict[]): Promise<React.ReactElement | null>;
|
|
37
|
+
/**
|
|
38
|
+
* Extract raw URL path parts from a main-route segment chain.
|
|
39
|
+
*
|
|
40
|
+
* Each dynamic segment consumed one URL part (its param value).
|
|
41
|
+
* Static segments consumed their own name. Groups are transparent.
|
|
42
|
+
* Catch-all segments consumed multiple parts (their param value is string[]).
|
|
43
|
+
*
|
|
44
|
+
* Uses rawSegmentParams (pre-coercion) to get actual URL strings.
|
|
45
|
+
* Values are coerced to String() defensively in case a codec leaked
|
|
46
|
+
* a non-string value into the params map.
|
|
47
|
+
*/
|
|
48
|
+
export declare function extractUrlParts(segments: ManifestSegmentNode[], mainParams: Record<string, string | string[]>): string[];
|
|
37
49
|
/** Result of eagerly evaluating one segment's access.ts. */
|
|
38
50
|
export interface SlotAccessVerdict {
|
|
39
51
|
node: ManifestSegmentNode;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAkC,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAG5F,KAAK,eAAe,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,YAAY,CAAC;AAgClE;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,UAAU,EACjB,CAAC,EAAE,eAAe,EAClB,YAAY,CAAC,EAAE,mBAAmB,EAClC,cAAc,CAAC,EAAE,MAAM,EACvB,cAAc,CAAC,EAAE,iBAAiB,EAAE,GACnC,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,CAwHpC;
|
|
1
|
+
{"version":3,"file":"slot-resolver.d.ts","sourceRoot":"","sources":["../../src/server/slot-resolver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAQH,OAAO,KAAK,EAAE,mBAAmB,EAAE,UAAU,EAAE,MAAM,eAAe,CAAC;AACrE,OAAO,EAAE,UAAU,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE7D,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,oBAAoB,CAAC;AAK9D,OAAO,EAAkC,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAG5F,KAAK,eAAe,GAAG,CAAC,GAAG,IAAI,EAAE,OAAO,EAAE,KAAK,KAAK,CAAC,YAAY,CAAC;AAgClE;;;;;;;;;;;;GAYG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,mBAAmB,EAC7B,KAAK,EAAE,UAAU,EACjB,CAAC,EAAE,eAAe,EAClB,YAAY,CAAC,EAAE,mBAAmB,EAClC,cAAc,CAAC,EAAE,MAAM,EACvB,cAAc,CAAC,EAAE,iBAAiB,EAAE,GACnC,OAAO,CAAC,KAAK,CAAC,YAAY,GAAG,IAAI,CAAC,CAwHpC;AAkKD;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,CAC7B,QAAQ,EAAE,mBAAmB,EAAE,EAC/B,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC,GAC5C,MAAM,EAAE,CAsBV;AAwDD,4DAA4D;AAC5D,MAAM,WAAW,iBAAiB;IAChC,IAAI,EAAE,mBAAmB,CAAC;IAC1B;;;;;;OAMG;IACH,OAAO,EAAE,MAAM,GAAG,OAAO,GAAG,UAAU,GAAG,cAAc,CAAC;CACzD;AAwDD,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,MAAM,CAAC;IAChB,eAAe,EAAE,MAAM,CAAC;IACxB;;;;OAIG;IACH,kBAAkB,EAAE,OAAO,CAAC;IAC5B,0DAA0D;IAC1D,MAAM,EAAE,OAAO,CAAC;IAChB,gFAAgF;IAChF,OAAO,CAAC,EAAE,OAAO,CAAC;CACnB;AAED,UAAU,oBAAoB;IAC5B,OAAO,EAAE,mBAAmB,CAAC;IAC7B,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,UAAU,CAAC;IAClB,CAAC,EAAE,eAAe,CAAC;IACnB,YAAY,CAAC,EAAE,mBAAmB,CAAC;IACnC,cAAc,EAAE,MAAM,CAAC;IACvB,YAAY,EAAE,MAAM,GAAG,IAAI,CAAC;IAC5B,cAAc,EAAE,MAAM,CAAC;IACvB,eAAe,EAAE,eAAe,GAAG,IAAI,CAAC;IACxC,YAAY,EAAE,aAAa,EAAE,CAAC;CAC/B;AAED;;;;;GAKG;AACH,wBAAsB,gBAAgB,CAAC,EACrC,OAAO,EACP,SAAS,EACT,KAAK,EACL,CAAC,EACD,YAAY,EACZ,cAAc,EACd,YAAY,EACZ,cAAc,EACd,eAAe,EACf,YAAY,GACb,EAAE,oBAAoB,GAAG,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAwLzD"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@timber-js/app",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
3
|
+
"version": "0.2.0-alpha.180",
|
|
4
4
|
"description": "Vite-native React framework built for Servers and Serverless Platforms — correct HTTP semantics, real status codes, pages that work without JavaScript",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -255,16 +255,13 @@ export function createTimberRouter(options?: RouterInitOptions): RouterInitResul
|
|
|
255
255
|
let navApiController: NavigationApiController | null = null;
|
|
256
256
|
if (useNavApi) {
|
|
257
257
|
navApiController = setupNavigationApi({
|
|
258
|
-
onExternalNavigate: async (url, { replace, signal, scroll }) => {
|
|
259
|
-
// Navigation intercepted by the Navigation API. Covers both
|
|
260
|
-
// Link <a> clicks (user-initiated) and external navigations.
|
|
261
|
-
// The Navigation API handles the URL update via intercept(),
|
|
262
|
-
// so pass _skipHistory to avoid double pushState.
|
|
258
|
+
onExternalNavigate: async (url, { replace, signal, scroll, departingUrl }) => {
|
|
263
259
|
await router.navigate(url, {
|
|
264
260
|
replace,
|
|
265
261
|
scroll,
|
|
266
262
|
_signal: signal,
|
|
267
263
|
_skipHistory: true,
|
|
264
|
+
_departingUrl: departingUrl,
|
|
268
265
|
});
|
|
269
266
|
},
|
|
270
267
|
onTraverse: async (url, scrollY, signal) => {
|
|
@@ -54,7 +54,7 @@ export interface NavigationApiCallbacks {
|
|
|
54
54
|
*/
|
|
55
55
|
onExternalNavigate: (
|
|
56
56
|
url: string,
|
|
57
|
-
options: { replace: boolean; signal: AbortSignal; scroll?: boolean }
|
|
57
|
+
options: { replace: boolean; signal: AbortSignal; scroll?: boolean; departingUrl?: string }
|
|
58
58
|
) => Promise<void>;
|
|
59
59
|
|
|
60
60
|
/**
|
|
@@ -261,18 +261,22 @@ export function setupNavigationApi(callbacks: NavigationApiCallbacks): Navigatio
|
|
|
261
261
|
// Ignore — entry may be disposed
|
|
262
262
|
}
|
|
263
263
|
|
|
264
|
+
// Capture the departing URL BEFORE event.intercept() commits the
|
|
265
|
+
// destination. Once intercept() is called, currentEntry switches and
|
|
266
|
+
// getCurrentUrl() returns the destination (TIM-1232).
|
|
267
|
+
const departingUrl = nav.currentEntry?.url
|
|
268
|
+
? new URL(nav.currentEntry.url).pathname + new URL(nav.currentEntry.url).search
|
|
269
|
+
: undefined;
|
|
270
|
+
|
|
264
271
|
event.intercept({
|
|
265
272
|
scroll: 'manual',
|
|
266
273
|
focusReset: 'manual',
|
|
267
274
|
async handler() {
|
|
268
|
-
// Keep the #fragment — router.navigate splits it back off for the
|
|
269
|
-
// RSC fetch and scrolls to the matching element after render
|
|
270
|
-
// (TIM-1035). Traversals above stay hash-less: history-stack keys
|
|
271
|
-
// use pathname + search.
|
|
272
275
|
await callbacks.onExternalNavigate(url + destUrl.hash, {
|
|
273
276
|
replace: event.navigationType === 'replace',
|
|
274
277
|
signal: event.signal,
|
|
275
278
|
scroll: undefined,
|
|
279
|
+
departingUrl,
|
|
276
280
|
});
|
|
277
281
|
},
|
|
278
282
|
});
|
package/src/client/router.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface NavigationOptions {
|
|
|
40
40
|
* intercepted by the navigate event handler.
|
|
41
41
|
*/
|
|
42
42
|
_skipHistory?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* @internal The URL the user is navigating FROM, captured before the
|
|
45
|
+
* Navigation API commits the destination. Sent as X-Timber-URL for
|
|
46
|
+
* slot skip comparison on the server (TIM-1232).
|
|
47
|
+
*/
|
|
48
|
+
_departingUrl?: string;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/**
|
|
@@ -412,8 +418,6 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
412
418
|
if (segmentInfo) {
|
|
413
419
|
for (const info of segmentInfo) {
|
|
414
420
|
if (!skipped.has(info.segmentId ?? info.path)) {
|
|
415
|
-
// Use segmentId (outlet key) when available — route groups
|
|
416
|
-
// use a disambiguated key like "/(marketing)" instead of "/".
|
|
417
421
|
updates.set(info.segmentId ?? info.path, result.payload);
|
|
418
422
|
break;
|
|
419
423
|
}
|
|
@@ -530,7 +534,13 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
530
534
|
*/
|
|
531
535
|
async function performNavigationFetch(
|
|
532
536
|
url: string,
|
|
533
|
-
options: {
|
|
537
|
+
options: {
|
|
538
|
+
replace: boolean;
|
|
539
|
+
commitUrl?: string;
|
|
540
|
+
signal?: AbortSignal;
|
|
541
|
+
skipHistory?: boolean;
|
|
542
|
+
departingUrl?: string;
|
|
543
|
+
}
|
|
534
544
|
): Promise<FetchResult & { navState: NavigationState }> {
|
|
535
545
|
// Check prefetch cache first. PrefetchResult has optional segmentInfo/params
|
|
536
546
|
// fields — normalize to null for FetchResult compatibility.
|
|
@@ -547,12 +557,14 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
547
557
|
|
|
548
558
|
if (result === undefined) {
|
|
549
559
|
// Fetch RSC payload with state tree for partial rendering.
|
|
550
|
-
// Send
|
|
560
|
+
// Send departing URL (pre-navigation) for slot skip comparison.
|
|
561
|
+
// getCurrentUrl() can't be used here because the Navigation API
|
|
562
|
+
// may have already committed the destination URL (TIM-1232).
|
|
551
563
|
const stateTree = deps.clientSegmentCache ? segmentCache.serializeStateTree() : undefined;
|
|
552
|
-
const
|
|
553
|
-
const currentUrl =
|
|
554
|
-
? new URL(
|
|
555
|
-
: new URL(
|
|
564
|
+
const rawDepartingUrl = options.departingUrl ?? deps.getCurrentUrl();
|
|
565
|
+
const currentUrl = rawDepartingUrl.startsWith('http')
|
|
566
|
+
? new URL(rawDepartingUrl).pathname
|
|
567
|
+
: new URL(rawDepartingUrl, 'http://localhost').pathname;
|
|
556
568
|
result = await fetchRscPayload(url, deps, stateTree, currentUrl, options.signal);
|
|
557
569
|
}
|
|
558
570
|
|
|
@@ -607,6 +619,12 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
607
619
|
const hash = hashIndex === -1 ? '' : url.slice(hashIndex);
|
|
608
620
|
const fetchUrl = hashIndex === -1 ? url : url.slice(0, hashIndex);
|
|
609
621
|
|
|
622
|
+
// Use the pre-intercept departing URL when the Navigation API has already
|
|
623
|
+
// committed the destination (_departingUrl from navigation-api.ts). Otherwise
|
|
624
|
+
// capture it now — getCurrentUrl() is still the departing URL at this point
|
|
625
|
+
// for non-Navigation-API navigations (TIM-1232).
|
|
626
|
+
const departingUrl = options._departingUrl ?? deps.getCurrentUrl();
|
|
627
|
+
|
|
610
628
|
// Capture the departing page's scroll position for scroll={false} preservation.
|
|
611
629
|
const currentScrollY = deps.getScrollY();
|
|
612
630
|
|
|
@@ -641,6 +659,7 @@ export function createRouter(deps: RouterDeps): RouterInstance {
|
|
|
641
659
|
commitUrl: url,
|
|
642
660
|
signal: navAbort.signal,
|
|
643
661
|
skipHistory: effectiveSkipHistory,
|
|
662
|
+
departingUrl,
|
|
644
663
|
})
|
|
645
664
|
);
|
|
646
665
|
|
|
@@ -49,6 +49,37 @@ export interface SegmentOutletProps {
|
|
|
49
49
|
skip?: boolean;
|
|
50
50
|
}
|
|
51
51
|
|
|
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
|
+
|
|
52
83
|
/**
|
|
53
84
|
* Client component boundary at each layout segment in the element tree.
|
|
54
85
|
*
|
|
@@ -64,11 +95,13 @@ export interface SegmentOutletProps {
|
|
|
64
95
|
export function SegmentOutlet({ segmentPath, children, skip }: SegmentOutletProps) {
|
|
65
96
|
const updates = useContext(SegmentUpdateContext);
|
|
66
97
|
const contentRef = useRef<ReactNode>(null);
|
|
98
|
+
const slotCache = getSlotContentCache();
|
|
67
99
|
|
|
68
100
|
const update = updates.get(segmentPath);
|
|
69
101
|
|
|
70
102
|
if (update !== undefined) {
|
|
71
103
|
contentRef.current = update;
|
|
104
|
+
slotCache.set(segmentPath, update);
|
|
72
105
|
return update;
|
|
73
106
|
}
|
|
74
107
|
|
|
@@ -80,18 +113,27 @@ export function SegmentOutlet({ segmentPath, children, skip }: SegmentOutletProp
|
|
|
80
113
|
if (contentRef.current !== null) {
|
|
81
114
|
return contentRef.current;
|
|
82
115
|
}
|
|
83
|
-
//
|
|
84
|
-
//
|
|
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
|
|
85
125
|
// children (null). The slot will be fully rendered next navigation.
|
|
86
126
|
}
|
|
87
127
|
|
|
88
128
|
if (contentRef.current === null) {
|
|
89
129
|
contentRef.current = children;
|
|
130
|
+
slotCache.set(segmentPath, children);
|
|
90
131
|
return children;
|
|
91
132
|
}
|
|
92
133
|
|
|
93
134
|
if (children !== contentRef.current) {
|
|
94
135
|
contentRef.current = children;
|
|
136
|
+
slotCache.set(segmentPath, children);
|
|
95
137
|
return children;
|
|
96
138
|
}
|
|
97
139
|
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
} from './deny-boundary.js';
|
|
35
35
|
import type { DenyPageEntry } from './deny-boundary.js';
|
|
36
36
|
import { LAYOUT_CHILDREN_SLOT } from './prebuilt/slots.js';
|
|
37
|
-
import { resolveSlotProps, type SlotSkipEntry } from './slot-resolver.js';
|
|
37
|
+
import { resolveSlotProps, extractUrlParts, type SlotSkipEntry } from './slot-resolver.js';
|
|
38
38
|
import { SegmentProvider } from '../client/segment-context.js';
|
|
39
39
|
import { SegmentOutlet } from '../client/segment-outlet.js';
|
|
40
40
|
import { SlotOutlet } from '../client/slot-outlet.js';
|
|
@@ -42,10 +42,93 @@ import { SlotsProvider } from '../client/slot-provider.js';
|
|
|
42
42
|
|
|
43
43
|
import { wrapSegmentWithErrorBoundaries } from './error-boundary-wrapper.js';
|
|
44
44
|
import type { InterceptionContext } from './pipeline.js';
|
|
45
|
-
import {
|
|
45
|
+
import {
|
|
46
|
+
shouldSkipSegment,
|
|
47
|
+
shouldSkipSlot,
|
|
48
|
+
computeSegmentKeys,
|
|
49
|
+
computeSlotKey,
|
|
50
|
+
type ClientStateTree,
|
|
51
|
+
} from './state-tree-diff.js';
|
|
46
52
|
import { headElementsToReact } from './metadata.js';
|
|
47
53
|
import { loadModule } from './safe-load.js';
|
|
48
54
|
|
|
55
|
+
/**
|
|
56
|
+
* Truncate the skippable prefix when a skippable layout has slots whose
|
|
57
|
+
* content changed between the departing and destination URLs (TIM-1232).
|
|
58
|
+
*
|
|
59
|
+
* Without this, changed slots at skipped layouts are never sent to the
|
|
60
|
+
* client — the `continue` in the main loop skips slot resolution entirely.
|
|
61
|
+
* Removing the segment from the skippable set forces it to render with
|
|
62
|
+
* its slots. The prefix must stay contiguous, so all segments after the
|
|
63
|
+
* first changed-slot segment are also removed.
|
|
64
|
+
*/
|
|
65
|
+
function truncateSkippableForChangedSlots(
|
|
66
|
+
skippableSet: Set<number>,
|
|
67
|
+
segments: ManifestSegmentNode[],
|
|
68
|
+
segmentKeys: string[],
|
|
69
|
+
match: RouteMatch,
|
|
70
|
+
clientStateTree: ClientStateTree,
|
|
71
|
+
req: Request
|
|
72
|
+
): void {
|
|
73
|
+
const departingUrl = req.headers.get('X-Timber-URL');
|
|
74
|
+
let depParsed: URL | null = null;
|
|
75
|
+
if (departingUrl) {
|
|
76
|
+
try {
|
|
77
|
+
depParsed = new URL(departingUrl, 'http://localhost');
|
|
78
|
+
} catch {
|
|
79
|
+
skippableSet.clear();
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const destPathname = new URL(req.url, 'http://localhost').pathname;
|
|
84
|
+
const depPathname = depParsed?.pathname ?? null;
|
|
85
|
+
const split = (p: string) => (p === '/' ? [] : p.slice(1).split('/'));
|
|
86
|
+
const destAll = split(destPathname);
|
|
87
|
+
const depAll = depPathname ? split(depPathname) : null;
|
|
88
|
+
const clientSlots = clientStateTree.slots ?? null;
|
|
89
|
+
const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
|
|
90
|
+
|
|
91
|
+
for (let i = 0; i < segments.length; i++) {
|
|
92
|
+
if (!skippableSet.has(i)) continue;
|
|
93
|
+
const segment = segments[i];
|
|
94
|
+
const slotEntries = Object.entries(segment.slots ?? {});
|
|
95
|
+
if (slotEntries.length === 0) continue;
|
|
96
|
+
|
|
97
|
+
const segIdx = match.segments.indexOf(segment);
|
|
98
|
+
const parents =
|
|
99
|
+
segIdx >= 0 ? match.segments.slice(0, segIdx + 1).filter((s) => s.segmentName !== '') : [];
|
|
100
|
+
const sliceAt = extractUrlParts(parents, rawParams).length;
|
|
101
|
+
const destParts = destAll.slice(sliceAt);
|
|
102
|
+
const depParts = depAll ? depAll.slice(sliceAt) : null;
|
|
103
|
+
let parentChanged = false;
|
|
104
|
+
if (depAll) {
|
|
105
|
+
const dp = depAll.slice(0, sliceAt),
|
|
106
|
+
dd = destAll.slice(0, sliceAt);
|
|
107
|
+
parentChanged = dp.length !== dd.length || dp.some((p, j) => p !== dd[j]);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
for (const [slotName, slotNode] of slotEntries) {
|
|
111
|
+
const m = slotNode as ManifestSegmentNode;
|
|
112
|
+
const k = computeSlotKey(segmentKeys[i], `@${slotName}`);
|
|
113
|
+
if (
|
|
114
|
+
m.children.some((c) => c.segmentType === 'intercepting') ||
|
|
115
|
+
parentChanged ||
|
|
116
|
+
depParts === null ||
|
|
117
|
+
!shouldSkipSlot({
|
|
118
|
+
slotKey: k,
|
|
119
|
+
clientSlots,
|
|
120
|
+
slotNode: m,
|
|
121
|
+
departingUrlParts: depParts,
|
|
122
|
+
destinationUrlParts: destParts,
|
|
123
|
+
})
|
|
124
|
+
) {
|
|
125
|
+
for (let j = i; j < segments.length; j++) skippableSet.delete(j);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
49
132
|
/**
|
|
50
133
|
* Replace the outermost SegmentOutlet in a partial payload with its
|
|
51
134
|
* SegmentProvider child. Walks through AccessGate wrappers (which sit
|
|
@@ -544,6 +627,20 @@ export async function buildRouteElement(
|
|
|
544
627
|
}
|
|
545
628
|
}
|
|
546
629
|
|
|
630
|
+
// Slot-change guard (TIM-1232): if a skippable layout has parallel
|
|
631
|
+
// slots whose content changed, the layout cannot be skipped — it must
|
|
632
|
+
// render so its slots are included in the payload.
|
|
633
|
+
if (skippableSet.size > 0 && clientStateTree) {
|
|
634
|
+
truncateSkippableForChangedSlots(
|
|
635
|
+
skippableSet,
|
|
636
|
+
segments,
|
|
637
|
+
segmentKeys,
|
|
638
|
+
match,
|
|
639
|
+
clientStateTree,
|
|
640
|
+
req
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
547
644
|
let outermostSegmentProvider: React.ReactElement | null = null;
|
|
548
645
|
|
|
549
646
|
// Slot caching: the departing URL (X-Timber-URL header) and the
|
|
@@ -378,7 +378,7 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
|
|
|
378
378
|
* Values are coerced to String() defensively in case a codec leaked
|
|
379
379
|
* a non-string value into the params map.
|
|
380
380
|
*/
|
|
381
|
-
function extractUrlParts(
|
|
381
|
+
export function extractUrlParts(
|
|
382
382
|
segments: ManifestSegmentNode[],
|
|
383
383
|
mainParams: Record<string, string | string[]>
|
|
384
384
|
): string[] {
|