@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.
@@ -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,EAAoB,KAAK,aAAa,EAAE,MAAM,oBAAoB,CAAC;AAO1E,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,eAAe,CAAC;AACzD,OAAO,EAAyC,KAAK,eAAe,EAAE,MAAM,sBAAsB,CAAC;AAyCnG;;;;;;;;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,CAsgB7B"}
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;AA8PD,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"}
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.179",
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
  });
@@ -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: { replace: boolean; commitUrl?: string; signal?: AbortSignal; skipHistory?: boolean }
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 current URL for intercepting route resolution (modal pattern).
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 rawCurrentUrl = deps.getCurrentUrl();
553
- const currentUrl = rawCurrentUrl.startsWith('http')
554
- ? new URL(rawCurrentUrl).pathname
555
- : new URL(rawCurrentUrl, 'http://localhost').pathname;
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
- // No cached content — the SegmentOutlet instance is fresh (not
84
- // reconciled from a previous render). Fall through to render
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 { shouldSkipSegment, computeSegmentKeys, type ClientStateTree } from './state-tree-diff.js';
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[] {