@timber-js/app 0.2.0-alpha.187 → 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.
Files changed (36) hide show
  1. package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
  2. package/dist/client/internal.js +12 -3
  3. package/dist/client/internal.js.map +1 -1
  4. package/dist/client/segment-cache.d.ts +6 -0
  5. package/dist/client/segment-cache.d.ts.map +1 -1
  6. package/dist/routing/segment-keys.d.ts +22 -0
  7. package/dist/routing/segment-keys.d.ts.map +1 -1
  8. package/dist/server/access-gate.d.ts.map +1 -1
  9. package/dist/server/chain-url-parts.d.ts +2 -3
  10. package/dist/server/chain-url-parts.d.ts.map +1 -1
  11. package/dist/server/internal.js +1 -7
  12. package/dist/server/internal.js.map +1 -1
  13. package/dist/server/route-element-builder.d.ts.map +1 -1
  14. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  15. package/dist/server/skippable-prefix.d.ts +3 -0
  16. package/dist/server/skippable-prefix.d.ts.map +1 -1
  17. package/dist/server/slot-resolver.d.ts +18 -19
  18. package/dist/server/slot-resolver.d.ts.map +1 -1
  19. package/dist/server/state-tree-diff.d.ts +9 -16
  20. package/dist/server/state-tree-diff.d.ts.map +1 -1
  21. package/dist/server/tree-builder.d.ts +0 -7
  22. package/dist/server/tree-builder.d.ts.map +1 -1
  23. package/dist/shared/segment-info.d.ts +7 -0
  24. package/dist/shared/segment-info.d.ts.map +1 -1
  25. package/package.json +1 -1
  26. package/src/client/segment-cache.ts +18 -5
  27. package/src/routing/segment-keys.ts +37 -0
  28. package/src/server/access-gate.tsx +1 -28
  29. package/src/server/chain-url-parts.ts +2 -3
  30. package/src/server/route-element-builder.ts +3 -25
  31. package/src/server/rsc-entry/helpers.ts +1 -0
  32. package/src/server/skippable-prefix.ts +21 -39
  33. package/src/server/slot-resolver.ts +59 -187
  34. package/src/server/state-tree-diff.ts +11 -62
  35. package/src/server/tree-builder.ts +0 -10
  36. package/src/shared/segment-info.ts +7 -0
@@ -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,7 +188,7 @@ 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, accessVerdicts, scope);
191
+ element = await wrapWithAccessGate(slotNode, element, h, scope);
199
192
 
200
193
  // Wrap with slot root's error boundaries (outermost)
201
194
  element = await wrapSegmentWithErrorBoundaries(slotNode, element, {
@@ -277,7 +270,6 @@ async function wrapWithIntermediateSegments(
277
270
  chain: ManifestSegmentNode[],
278
271
  element: React.ReactElement,
279
272
  h: CreateElementFn,
280
- accessVerdicts?: SlotAccessVerdict[],
281
273
  scope?: SlotParamScope
282
274
  ): Promise<React.ReactElement> {
283
275
  for (let i = chain.length - 1; i > 0; i--) {
@@ -288,7 +280,7 @@ async function wrapWithIntermediateSegments(
288
280
  errorBoundaryComponent: TimberErrorBoundary,
289
281
  });
290
282
  element = await wrapWithLayout(seg, element, h, scope);
291
- element = await wrapWithAccessGate(seg, element, h, accessVerdicts, scope);
283
+ element = await wrapWithAccessGate(seg, element, h, scope);
292
284
  }
293
285
  return element;
294
286
  }
@@ -361,7 +353,6 @@ async function wrapWithAccessGate(
361
353
  slotNode: ManifestSegmentNode,
362
354
  element: React.ReactElement,
363
355
  h: CreateElementFn,
364
- accessVerdicts?: SlotAccessVerdict[],
365
356
  scope?: SlotParamScope
366
357
  ): Promise<React.ReactElement> {
367
358
  if (!slotNode.access) return element;
@@ -382,16 +373,6 @@ async function wrapWithAccessGate(
382
373
 
383
374
  const defaultFallback = await renderDefaultFallback(slotNode, h);
384
375
 
385
- // Look up pre-computed verdict from eager evaluation. Only replay
386
- // denial/redirect verdicts — 'pass' verdicts must NOT be replayed
387
- // because the access function may warm React.cache (e.g., requireUser())
388
- // for layout/page dedup, and replaying 'pass' skips the cache-warming
389
- // call. 'error' verdicts are also excluded — the gate must re-run
390
- // accessFn so the error reaches the slot's error boundary.
391
- const preVerdict = accessVerdicts?.find(
392
- (v) => v.node === slotNode && v.verdict !== 'pass' && v.verdict !== 'error'
393
- );
394
-
395
376
  return h(SlotAccessGate, {
396
377
  accessFn,
397
378
  DeniedComponent,
@@ -399,7 +380,6 @@ async function wrapWithAccessGate(
399
380
  createElement: h,
400
381
  defaultFallback,
401
382
  children: element,
402
- verdict: preVerdict?.verdict,
403
383
  });
404
384
  }
405
385
 
@@ -496,74 +476,6 @@ function findSlotMatch(slotNode: ManifestSegmentNode, match: RouteMatch): SlotMa
496
476
  return { page: leaf.page, chain: result.chain, slotParams: result.params };
497
477
  }
498
478
 
499
- // ─── Slot Access Evaluation ─────────────────────────────────────────────────
500
-
501
- /** Result of eagerly evaluating one segment's access.ts. */
502
- export interface SlotAccessVerdict {
503
- node: ManifestSegmentNode;
504
- /**
505
- * 'pass' — access allowed.
506
- * DenySignal/RedirectSignal — access denied, replayed in SlotAccessGate.
507
- * 'error' — unknown error. Forces full render. No verdict passed to
508
- * SlotAccessGate, so it calls accessFn during render and the error
509
- * reaches the slot's error boundary.
510
- */
511
- verdict: 'pass' | 'error' | DenySignal | RedirectSignal;
512
- }
513
-
514
- /**
515
- * Eagerly evaluate the access chain for a slot (root + intermediate segments).
516
- *
517
- * Runs each access.ts top-down (outermost first). If any denies, the chain
518
- * stops (shallowest failure wins, matching segment access semantics).
519
- *
520
- * Verdicts are stored for replay in SlotAccessGate / wrapWithIntermediateSegments
521
- * so access.ts is called exactly once per request.
522
- */
523
- async function evaluateSlotAccessChain(
524
- slotRoot: ManifestSegmentNode,
525
- chain: ManifestSegmentNode[],
526
- scope?: SlotParamScope
527
- ): Promise<SlotAccessVerdict[]> {
528
- // Collect all nodes with access.ts: slot root + intermediate chain segments
529
- const nodesWithAccess: ManifestSegmentNode[] = [];
530
- if (slotRoot.access) nodesWithAccess.push(slotRoot);
531
- for (let i = 1; i < chain.length; i++) {
532
- if (chain[i].access) nodesWithAccess.push(chain[i]);
533
- }
534
-
535
- if (nodesWithAccess.length === 0) return [];
536
-
537
- const results: SlotAccessVerdict[] = [];
538
- for (const node of nodesWithAccess) {
539
- const accessFn = await loadComponent(node.access!);
540
- if (!accessFn) {
541
- results.push({ node, verdict: 'pass' });
542
- continue;
543
- }
544
- try {
545
- await (scope ? scope(() => accessFn()) : accessFn());
546
- results.push({ node, verdict: 'pass' });
547
- } catch (e) {
548
- if (e instanceof DenySignal) {
549
- results.push({ node, verdict: e });
550
- break; // shallowest failure wins
551
- }
552
- if (e instanceof RedirectSignal) {
553
- results.push({ node, verdict: e });
554
- break;
555
- }
556
- // Unknown error — record as 'error' to force full render (blocks
557
- // canSkip via accessBlocked). No verdict is passed to SlotAccessGate
558
- // (wrapWithAccessGate filters 'error' out), so the gate calls
559
- // accessFn during render and the error reaches the error boundary.
560
- results.push({ node, verdict: 'error' });
561
- break;
562
- }
563
- }
564
- return results;
565
- }
566
-
567
479
  // ─── Slot Caching ──────────────────────────────────────────────────────────
568
480
 
569
481
  export interface SlotSkipEntry {
@@ -579,6 +491,12 @@ export interface SlotSkipEntry {
579
491
  denied: boolean;
580
492
  /** Whether the slot was skipped (content not rendered, client keeps cached). */
581
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;
582
500
  }
583
501
 
584
502
  /**
@@ -650,7 +568,6 @@ interface ResolveSlotPropsArgs {
650
568
  h: CreateElementFn;
651
569
  interception?: InterceptionContext;
652
570
  parentTreePath: string;
653
- departingUrl: string | null;
654
571
  destinationUrl: string;
655
572
  clientStateTree: ClientStateTree | null;
656
573
  slotSkipInfo: SlotSkipEntry[];
@@ -658,9 +575,16 @@ interface ResolveSlotPropsArgs {
658
575
 
659
576
  /**
660
577
  * Resolve all parallel route slots for a layout, wrapping each in a
661
- * SegmentOutlet for client-side caching. Slots whose matched content
662
- * hasn't changed between the departing and destination URLs are rendered
663
- * with `skip=true` so the client keeps its cached content.
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.
664
588
  */
665
589
  export async function resolveSlotProps({
666
590
  segment,
@@ -669,7 +593,6 @@ export async function resolveSlotProps({
669
593
  h,
670
594
  interception,
671
595
  parentTreePath,
672
- departingUrl,
673
596
  destinationUrl,
674
597
  clientStateTree,
675
598
  slotSkipInfo,
@@ -678,106 +601,51 @@ export async function resolveSlotProps({
678
601
  const slotEntries = Object.entries(segment.slots ?? {});
679
602
  if (slotEntries.length === 0) return slotProps;
680
603
 
681
- // Parse URLs to extract pathnames for slot skip comparison.
682
- // The departing URL (X-Timber-URL) is an untrusted request header —
683
- // catch parse failures and fall back to no-cache (full render).
684
- const destParsed = new URL(destinationUrl, 'http://localhost');
685
- let depParsed: URL | null = null;
686
- if (departingUrl) {
687
- try {
688
- depParsed = new URL(departingUrl, 'http://localhost');
689
- } catch {
690
- // Malformed departing URL — disable slot skipping for this request
691
- }
692
- }
693
- const destinationPathname = destParsed.pathname;
694
- const departingPathname = depParsed?.pathname ?? null;
695
-
696
- const sliceAt = consumedPartsThroughSegment(segment, match).length;
697
-
698
- function splitPathname(pathname: string): string[] {
699
- return pathname === '/' ? [] : pathname.slice(1).split('/');
700
- }
701
-
702
- const destinationAll = splitPathname(destinationPathname);
703
- const departingAll = departingPathname ? splitPathname(departingPathname) : null;
704
604
  const destinationParts = slotUrlParts(segment, match, destinationUrl);
705
- const departingParts = departingAll ? departingAll.slice(sliceAt) : null;
706
- const clientSlots = clientStateTree?.slots ?? null;
707
-
708
- // Check if parent segment's URL parts changed (e.g., /users/1 → /users/2).
709
- let parentParamsChanged = false;
710
- if (departingAll) {
711
- const depParent = departingAll.slice(0, sliceAt);
712
- const destParent = destinationAll.slice(0, sliceAt);
713
- parentParamsChanged =
714
- depParent.length !== destParent.length || depParent.some((p, i) => p !== destParent[i]);
715
- }
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);
716
611
 
717
612
  for (const [slotName, slotNode] of slotEntries) {
718
613
  const slotManifest = slotNode as ManifestSegmentNode;
719
614
  const slotKey = computeSlotKey(segmentId, `@${slotName}`);
720
615
 
721
616
  // Match the slot's sub-tree against the destination URL parts.
722
- // Used for both the skip decision and eager access evaluation.
723
617
  const destMatch = matchUrlParts(slotManifest, destinationParts);
724
618
 
725
- // Seed slot params BEFORE eager access evaluation so that access.ts
726
- // files calling getSegmentParams() see the slot's own coerced params,
727
- // not the main route's. This is the same seeding that resolveSlotElement
728
- // does, but we need it here for the eager path.
729
- let eagerScope: SlotParamScope | undefined;
730
- if (destMatch) {
731
- const coerced = coerceSlotParams(destMatch.chain, destMatch.params);
732
- const fullSlotPath = computeSlotSegmentPath(destMatch.chain, parentTreePath);
733
- if (fullSlotPath && Object.keys(coerced).length > 0) {
734
- setSlotParams(fullSlotPath, coerced);
735
- }
736
- // The eager path calls access.ts outside the React tree, so the render
737
- // scope in resolveSlotElement cannot cover it. Without this, the same
738
- // access.ts would read the main route's params when the slot is a skip
739
- // candidate and the slot's own params when it is not.
740
- eagerScope = (fn) => runWithSlotSegmentParams(fullSlotPath, coerced, fn);
741
- }
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
+ );
742
628
 
743
- // Check non-access skip conditions first. If any of these fail,
744
- // the slot can't be skipped regardless of access — no need to run
745
- // the eager access evaluation (which would double-run access.ts
746
- // since the in-tree gate also calls it during render).
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);
747
638
  const hasInterceptingChildren = slotManifest.children.some(
748
639
  (c) => c.segmentType === 'intercepting'
749
640
  );
750
- const isSkipCandidate =
641
+ const canSkip =
751
642
  !interception &&
752
643
  !hasInterceptingChildren &&
753
- !parentParamsChanged &&
754
- departingParts !== null &&
755
- shouldSkipSlot({
756
- slotKey,
757
- clientSlots,
758
- slotNode: slotManifest,
759
- departingUrlParts: departingParts,
760
- destinationUrlParts: destinationParts,
761
- });
762
-
763
- // Eagerly evaluate the slot's access chain only for skip candidates.
764
- // This serves two purposes:
765
- // 1. Satisfies security principle #3 (auth always runs) for skipped slots
766
- // 2. Determines whether access denied (denied slots must not be skipped)
767
- //
768
- // Non-skip-candidate slots skip eager evaluation — their access.ts
769
- // runs during render via SlotAccessGate (normal path). Per design doc:
770
- // "No access check for default.tsx" (destMatch null = unmatched slot).
771
- const chainVerdicts =
772
- isSkipCandidate && destMatch
773
- ? await evaluateSlotAccessChain(slotManifest, destMatch.chain, eagerScope)
774
- : [];
775
- const accessBlocked = chainVerdicts.some((v) => v.verdict !== 'pass');
776
- const canSkip = isSkipCandidate && !accessBlocked;
644
+ !hasAccessInChain &&
645
+ shouldSkipSlot(contentKey, clientSlotKeys);
777
646
 
778
647
  if (canSkip) {
779
- // Access already ran eagerly (all verdicts 'pass') — principle #3 satisfied.
780
- // No gate wrapper needed.
648
+ // No access.ts in this slot's chain (excluded above), so no gate needed.
781
649
  slotProps[slotName] = h(SegmentOutlet, {
782
650
  segmentPath: slotKey,
783
651
  skip: true,
@@ -789,6 +657,7 @@ export async function resolveSlotProps({
789
657
  isRequestDependent: false,
790
658
  denied: false,
791
659
  skipped: true,
660
+ contentKey,
792
661
  });
793
662
  } else {
794
663
  const resolvedElement = await resolveSlotElement(
@@ -796,8 +665,7 @@ export async function resolveSlotProps({
796
665
  match,
797
666
  h,
798
667
  interception,
799
- parentTreePath,
800
- chainVerdicts
668
+ parentTreePath
801
669
  );
802
670
  slotProps[slotName] = h(SegmentOutlet, {
803
671
  segmentPath: slotKey,
@@ -806,10 +674,13 @@ export async function resolveSlotProps({
806
674
  // Static analysis: check if any file in the slot's RENDERED tree
807
675
  // is request-dependent. Only check layouts when destMatch exists —
808
676
  // unmatched slots render default.tsx directly without layouts.
809
- let slotRequestDep = false;
810
- const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
677
+ // Slots with access.ts are inherently request-dependent — their
678
+ // content varies with user state (TIM-1364).
679
+ let slotRequestDep = hasAccessInChain;
811
680
  const hasRenderedPage = destMatch && destLeaf?.page;
812
- if (interception) {
681
+ if (slotRequestDep) {
682
+ // Already marked — skip static analysis
683
+ } else if (interception) {
813
684
  // `destMatch` walks the slot's ordinary children against the
814
685
  // destination URL; the interception resolver renders an intercepting
815
686
  // child instead, which that walk never sees. So the files analyzed
@@ -854,7 +725,8 @@ export async function resolveSlotProps({
854
725
  slotKey,
855
726
  parentSegmentId: segmentId,
856
727
  isRequestDependent: slotRequestDep,
857
- denied: accessBlocked,
728
+ denied: false,
729
+ contentKey,
858
730
  });
859
731
  }
860
732
  }
@@ -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 ALL of:
127
- * 1. The client has this slot cached (slotKey is in clientSlots)
128
- * 2. The slot's matched page is the SAME for both the departing and
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
- * The comparison uses matchUrlParts to find what each URL would match
132
- * in the slot's sub-tree, then compares the matched page file and params.
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
- slotKey,
139
- clientSlots,
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
  }
@@ -138,16 +138,6 @@ export interface SlotAccessGateProps {
138
138
  createElement: CreateElement;
139
139
  defaultFallback: ReactNode;
140
140
  children: ReactNode;
141
- /**
142
- * Pre-computed verdict from eager access evaluation. When provided,
143
- * SlotAccessGate replays it synchronously instead of re-calling accessFn.
144
- * 'pass' → render children. DenySignal → graceful degradation.
145
- * undefined → call accessFn during render (backward compat, error re-run).
146
- */
147
- verdict?:
148
- | 'pass'
149
- | import('./primitives.js').DenySignal
150
- | import('./primitives.js').RedirectSignal;
151
141
  }
152
142
 
153
143
  // ─── Tree Builder ────────────────────────────────────────────────────────────
@@ -39,6 +39,13 @@ export interface SegmentInfo {
39
39
  denied?: boolean;
40
40
  /** True when the slot was skipped (cached content reused). Payloads with skipped slots are not replayable. */
41
41
  skipped?: boolean;
42
+ /**
43
+ * Content key for slot entries — encodes owner parts, slot name, matched
44
+ * entry file, and slot params. The client stores and advertises this key;
45
+ * the server computes the destination key and checks membership.
46
+ * Replaces URL-based departing/destination diffing (TIM-1370).
47
+ */
48
+ contentKey?: string;
42
49
  }
43
50
 
44
51
  /**