@timber-js/app 0.2.0-alpha.187 → 0.2.0-alpha.189

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 (69) hide show
  1. package/dist/_chunks/{resolve-schema-5ma5pp1b.js → resolve-schema-CBR6Lm4i.js} +2 -2
  2. package/dist/_chunks/{resolve-schema-5ma5pp1b.js.map → resolve-schema-CBR6Lm4i.js.map} +1 -1
  3. package/dist/_chunks/{schema-bridge-Cc2Gngu1.js → schema-bridge-C83xa9lT.js} +2 -2
  4. package/dist/_chunks/{schema-bridge-Cc2Gngu1.js.map → schema-bridge-C83xa9lT.js.map} +1 -1
  5. package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
  6. package/dist/_chunks/{use-query-states-BbU5Ge1V.js → use-query-states-I3JMng6J.js} +29 -5
  7. package/dist/_chunks/use-query-states-I3JMng6J.js.map +1 -0
  8. package/dist/client/index.js +1 -1
  9. package/dist/client/internal.js +13 -4
  10. package/dist/client/internal.js.map +1 -1
  11. package/dist/client/segment-cache.d.ts +6 -0
  12. package/dist/client/segment-cache.d.ts.map +1 -1
  13. package/dist/client/use-query-states.d.ts.map +1 -1
  14. package/dist/codec.js +1 -1
  15. package/dist/cookies/index.js +1 -1
  16. package/dist/params/index.js +1 -1
  17. package/dist/routing/segment-keys.d.ts +22 -0
  18. package/dist/routing/segment-keys.d.ts.map +1 -1
  19. package/dist/schema-bridge.d.ts +4 -1
  20. package/dist/schema-bridge.d.ts.map +1 -1
  21. package/dist/search-params/define.d.ts +30 -4
  22. package/dist/search-params/define.d.ts.map +1 -1
  23. package/dist/search-params/index.d.ts +1 -1
  24. package/dist/search-params/index.d.ts.map +1 -1
  25. package/dist/search-params/index.js +20 -5
  26. package/dist/search-params/index.js.map +1 -1
  27. package/dist/search-params/parse-total.d.ts +14 -3
  28. package/dist/search-params/parse-total.d.ts.map +1 -1
  29. package/dist/search-params/serialize-equal.d.ts +9 -0
  30. package/dist/search-params/serialize-equal.d.ts.map +1 -0
  31. package/dist/server/access-gate.d.ts.map +1 -1
  32. package/dist/server/chain-url-parts.d.ts +2 -3
  33. package/dist/server/chain-url-parts.d.ts.map +1 -1
  34. package/dist/server/internal.js +2 -8
  35. package/dist/server/internal.js.map +1 -1
  36. package/dist/server/route-element-builder.d.ts.map +1 -1
  37. package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
  38. package/dist/server/skippable-prefix.d.ts +3 -0
  39. package/dist/server/skippable-prefix.d.ts.map +1 -1
  40. package/dist/server/slot-resolver.d.ts +30 -19
  41. package/dist/server/slot-resolver.d.ts.map +1 -1
  42. package/dist/server/state-tree-diff.d.ts +9 -16
  43. package/dist/server/state-tree-diff.d.ts.map +1 -1
  44. package/dist/server/tree-builder.d.ts +0 -7
  45. package/dist/server/tree-builder.d.ts.map +1 -1
  46. package/dist/shared/segment-info.d.ts +7 -0
  47. package/dist/shared/segment-info.d.ts.map +1 -1
  48. package/docs/api/33-api-search-params.mdx +3 -3
  49. package/docs/learn/05-typed-params.mdx +1 -1
  50. package/package.json +1 -1
  51. package/src/client/segment-cache.ts +18 -5
  52. package/src/client/use-query-states.ts +23 -14
  53. package/src/routing/segment-keys.ts +37 -0
  54. package/src/schema-bridge.ts +8 -3
  55. package/src/search-params/define.ts +73 -14
  56. package/src/search-params/index.ts +1 -0
  57. package/src/search-params/parse-total.ts +17 -4
  58. package/src/search-params/serialize-equal.ts +14 -0
  59. package/src/search-params/wrappers.ts +1 -1
  60. package/src/server/access-gate.tsx +1 -28
  61. package/src/server/chain-url-parts.ts +2 -3
  62. package/src/server/route-element-builder.ts +14 -26
  63. package/src/server/rsc-entry/helpers.ts +1 -0
  64. package/src/server/skippable-prefix.ts +21 -39
  65. package/src/server/slot-resolver.ts +141 -187
  66. package/src/server/state-tree-diff.ts +11 -62
  67. package/src/server/tree-builder.ts +0 -10
  68. package/src/shared/segment-info.ts +7 -0
  69. package/dist/_chunks/use-query-states-BbU5Ge1V.js.map +0 -1
@@ -21,6 +21,7 @@ import type { Codec } from '../codec.js';
21
21
  // `nuqs` into every server entry (TIM-1298). See shared/als-slots.ts.
22
22
  import { getSearchParamsFromAls } from '../shared/als-slots.js';
23
23
  import { parseTotal } from './parse-total.js';
24
+ import { serializedEqual } from './serialize-equal.js';
24
25
 
25
26
  // ---------------------------------------------------------------------------
26
27
  // Types
@@ -45,7 +46,19 @@ import { parseTotal } from './parse-total.js';
45
46
  * by defineSearchParams and wrapped via fromSchema; those ARE total
46
47
  * through `parse` and are called that way.
47
48
  */
48
- export interface SearchParamCodec<T> extends Codec<T> {
49
+ export interface SearchParamCodec<T> extends Omit<Codec<T>, 'serialize'> {
50
+ /**
51
+ * Typed value → URL string(s). Return `null` to omit/clear.
52
+ *
53
+ * A codec MAY return `string[]` to emit repeated keys (`?tag=a&tag=b`).
54
+ * `buildSearchParams` appends one `key=value` entry per array element;
55
+ * the nuqs bridge forwards the array to nuqs, which does the same.
56
+ * Codecs that always produce a single value return a plain string.
57
+ *
58
+ * Wider than `Codec<T>.serialize` (`string | null`) because search params
59
+ * have a repeated-key concept that cookies and segment params do not.
60
+ */
61
+ serialize(value: T): string | string[] | null;
49
62
  /** Optional URL key alias, set by withUrlKey(). */
50
63
  urlKey?: string;
51
64
  /**
@@ -136,7 +149,7 @@ export interface SearchParamsDefinition<T extends Record<string, unknown>> {
136
149
  useQueryStates(options?: QueryStatesOptions): [T, SetParams<T>];
137
150
 
138
151
  /** Extend with additional codecs or Standard Schema objects. */
139
- extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(
152
+ extend<U extends Record<string, SearchParamField>>(
140
153
  codecs: U
141
154
  ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }>;
142
155
 
@@ -235,8 +248,27 @@ export type InferField<V> = V extends {
235
248
  : T | undefined
236
249
  : never;
237
250
 
238
- /** Acceptable field value for defineSearchParams: a codec or a Standard Schema. */
239
- export type SearchParamField<T = unknown> = SearchParamCodec<T> | StandardSchemaV1<T>;
251
+ /**
252
+ * A codec whose total entry point is `parseServerSide`, even when `parse`
253
+ * has a narrower signature. nuqs multi parsers (`parseAsNativeArrayOf`) have
254
+ * `parse(value: readonly string[])` — too narrow for `SearchParamCodec`'s
255
+ * `parse(string | string[] | undefined)` — but their `parseServerSide`
256
+ * covers the full domain. Timber never calls `parse` directly on these;
257
+ * `parseTotal` always reaches `parseServerSide` first (TIM-1355).
258
+ */
259
+ export interface TotalSearchParamCodec<T> {
260
+ parseServerSide(value: string | string[] | undefined): T;
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ parse(...args: any[]): any;
263
+ serialize(value: T): string | string[] | null;
264
+ urlKey?: string;
265
+ }
266
+
267
+ /** Acceptable field value for defineSearchParams: a codec, a total codec, or a Standard Schema. */
268
+ export type SearchParamField<T = unknown> =
269
+ | SearchParamCodec<T>
270
+ | TotalSearchParamCodec<T>
271
+ | StandardSchemaV1<T>;
240
272
 
241
273
  // ---------------------------------------------------------------------------
242
274
  // Internal helpers
@@ -291,7 +323,7 @@ function normalizeRaw(
291
323
  * which for a nuqs parser is `T | null` while its `serialize` declares
292
324
  * `T`. Widening to `unknown` states that honestly instead of casting.
293
325
  */
294
- function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | null {
326
+ function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | string[] | null {
295
327
  try {
296
328
  const absent = parseTotal(codec, undefined);
297
329
  return absent === null || absent === undefined ? null : codec.serialize(absent);
@@ -305,6 +337,12 @@ function getDefaultSerialized(codec: SearchParamCodec<unknown>): string | null {
305
337
  /**
306
338
  * Resolve a field value to a SearchParamCodec. Auto-detects Standard Schema
307
339
  * objects and wraps them with fromSchema. Reads .urlKey from codecs.
340
+ *
341
+ * The returned codec is typed as `SearchParamCodec<unknown>` because the
342
+ * codec map uses that type. At runtime, the value may be a `Codec<T>` from
343
+ * `fromSchema` (whose serialize returns `string | null`) or a nuqs parser
344
+ * (whose serialize returns `string | string[] | null`). Both are safe
345
+ * because `buildSearchParams` handles the union.
308
346
  */
309
347
  function resolveField(
310
348
  fieldName: string,
@@ -312,7 +350,8 @@ function resolveField(
312
350
  ): { codec: SearchParamCodec<unknown>; urlKey?: string } {
313
351
  // Check for codec first (codecs may also have '~standard' if they're nuqs parsers)
314
352
  if (isCodec(value)) {
315
- return { codec: value, urlKey: value.urlKey };
353
+ const urlKey = (value as SearchParamCodec<unknown>).urlKey;
354
+ return { codec: value as SearchParamCodec<unknown>, urlKey };
316
355
  }
317
356
 
318
357
  // Auto-detect Standard Schema. Schemas that reject undefined input and
@@ -320,7 +359,7 @@ function resolveField(
320
359
  // for absent params, and InferField widens the output type to
321
360
  // T | undefined. design/23-search-params.md §"Implicit Optionality"
322
361
  if (isStandardSchema(value)) {
323
- return { codec: fromSchema(value) };
362
+ return { codec: fromSchema(value) as SearchParamCodec<unknown> };
324
363
  }
325
364
 
326
365
  throw new Error(
@@ -443,7 +482,7 @@ function buildDefinition<T extends Record<string, unknown>>(
443
482
  urlKeys: Record<string, string>
444
483
  ): SearchParamsDefinition<T> {
445
484
  // Pre-compute default serialized values for omission check
446
- const defaultSerialized: Record<string, string | null> = {};
485
+ const defaultSerialized: Record<string, string | string[] | null> = {};
447
486
  for (const key of Object.keys(codecMap)) {
448
487
  defaultSerialized[key] = getDefaultSerialized(codecMap[key as keyof T]);
449
488
  }
@@ -497,13 +536,33 @@ function buildDefinition<T extends Record<string, unknown>>(
497
536
  for (const prop of Object.keys(codecMap)) {
498
537
  if (!(prop in values)) continue;
499
538
  const codec = codecMap[prop as keyof T] as SearchParamCodec<unknown>;
500
- const serialized = codec.serialize(values[prop as keyof T] as unknown);
501
539
 
502
- // Omit if serialized value matches the default
503
- if (serialized === defaultSerialized[prop]) continue;
504
- if (serialized === null) continue;
540
+ // TIM-1354: serialize may not be total over null — nuqs parsers like
541
+ // parseAsIsoDate throw TypeError on null (null.toISOString()). Catch
542
+ // TypeError and omit, matching what setParams does on the client.
543
+ // Only TypeError — a deliberate throw (redirect(), notFound()) from a
544
+ // codec's serialize must propagate, not be silently swallowed.
545
+ let serialized: string | string[] | null;
546
+ try {
547
+ serialized = codec.serialize(values[prop as keyof T] as unknown);
548
+ } catch (error) {
549
+ if (error instanceof TypeError) continue;
550
+ throw error;
551
+ }
505
552
 
506
- parts.push(`${encodeURIComponent(getUrlKey(prop))}=${encodeURIComponent(serialized)}`);
553
+ if (serialized === null) continue;
554
+ // Omit if serialized value matches the default
555
+ if (serializedEqual(serialized, defaultSerialized[prop])) continue;
556
+
557
+ // TIM-1353: string[] → repeated keys (?tag=a&tag=b)
558
+ const urlKey = encodeURIComponent(getUrlKey(prop));
559
+ if (Array.isArray(serialized)) {
560
+ for (const entry of serialized) {
561
+ parts.push(`${urlKey}=${encodeURIComponent(entry)}`);
562
+ }
563
+ } else {
564
+ parts.push(`${urlKey}=${encodeURIComponent(serialized)}`);
565
+ }
507
566
  }
508
567
 
509
568
  return parts.join('&');
@@ -516,7 +575,7 @@ function buildDefinition<T extends Record<string, unknown>>(
516
575
  }
517
576
 
518
577
  // ---- extend ----
519
- function extend<U extends Record<string, SearchParamCodec<unknown> | StandardSchemaV1<unknown>>>(
578
+ function extend<U extends Record<string, SearchParamField>>(
520
579
  newCodecs: U
521
580
  ): SearchParamsDefinition<T & { [K in keyof U]: InferField<U[K]> }> {
522
581
  type Combined = T & { [K in keyof U]: InferField<U[K]> };
@@ -7,6 +7,7 @@
7
7
  export type {
8
8
  SearchParamCodec,
9
9
  SearchParamCodecWithUrlKey,
10
+ TotalSearchParamCodec,
10
11
  InferCodec,
11
12
  InferField,
12
13
  CodecMap,
@@ -43,12 +43,23 @@
43
43
  * Design doc: design/23-search-params.md §"nuqs parsers, made total"
44
44
  */
45
45
 
46
- import type { Codec } from '../codec.js';
46
+ /**
47
+ * Minimal interface for anything `parseTotal` can dispatch. Only `parse` is
48
+ * required; `parseServerSide` is the optional total entry point. `serialize`
49
+ * is deliberately absent — `parseTotal` never calls it, and requiring it
50
+ * would prevent `SearchParamCodec<T>` (whose serialize returns `string |
51
+ * string[] | null`) from being passed where `Codec<T>` (whose serialize
52
+ * returns `string | null`) is expected.
53
+ */
54
+ export interface ParseableCodec<T> {
55
+ parse(value: string | string[] | undefined): T;
56
+ parseServerSide?(value: string | string[] | undefined): T;
57
+ }
47
58
 
48
59
  /**
49
60
  * A codec that publishes a total entry point over the raw URL domain.
50
61
  *
51
- * The return is `T`, not `T | null`: this is the entry point timber calls,
62
+ * The return is `T`, not `T | null`. This is the entry point timber calls,
52
63
  * so whatever it answers IS the field's type. A `null` for an absent param
53
64
  * belongs in `T` — a bare nuqs parser is a codec of `string | null`, and
54
65
  * `parseAsInteger.withDefault(1)` is a codec of `number`, because nuqs
@@ -60,7 +71,9 @@ export interface TotalCodec<T> {
60
71
  parseServerSide(value: string | string[] | undefined): T;
61
72
  }
62
73
 
63
- function hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<T> {
74
+ function hasParseServerSide<T>(
75
+ codec: ParseableCodec<T>
76
+ ): codec is ParseableCodec<T> & TotalCodec<T> {
64
77
  return typeof (codec as Partial<TotalCodec<T>>).parseServerSide === 'function';
65
78
  }
66
79
 
@@ -73,6 +86,6 @@ function hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<
73
86
  * widen it, and a caller that must handle "no value" (`withDefault`) sees
74
87
  * it because `T` carries it.
75
88
  */
76
- export function parseTotal<T>(codec: Codec<T>, raw: string | string[] | undefined): T {
89
+ export function parseTotal<T>(codec: ParseableCodec<T>, raw: string | string[] | undefined): T {
77
90
  return hasParseServerSide(codec) ? codec.parseServerSide(raw) : codec.parse(raw);
78
91
  }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Compare two serialize results for equality. Needed because `string[]`
3
+ * from repeated-key codecs does not compare by reference.
4
+ *
5
+ * Shared between define.ts (buildSearchParams default-omission) and
6
+ * use-query-states.ts (bridgeCodec eq). One source of truth.
7
+ */
8
+ export function serializedEqual(a: string | string[] | null, b: string | string[] | null): boolean {
9
+ if (a === b) return true;
10
+ if (Array.isArray(a) && Array.isArray(b)) {
11
+ return a.length === b.length && a.every((v, i) => v === b[i]);
12
+ }
13
+ return false;
14
+ }
@@ -75,7 +75,7 @@ export function withDefault<T>(
75
75
  const result = parseTotal(codec, value);
76
76
  return result === null || result === undefined ? defaultValue : result;
77
77
  },
78
- serialize(value: NonNullable<T>): string | null {
78
+ serialize(value: NonNullable<T>): string | string[] | null {
79
79
  return codec.serialize(value);
80
80
  },
81
81
  };
@@ -144,35 +144,8 @@ async function accessGateFallback(
144
144
  * slot doesn't make architectural sense.
145
145
  */
146
146
  export async function SlotAccessGate(props: SlotAccessGateProps): Promise<ReactNode> {
147
- const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children, verdict } =
148
- props;
147
+ const { accessFn, DeniedComponent, slotName, createElement, defaultFallback, children } = props;
149
148
 
150
- // Fast path: replay pre-computed verdict from eager evaluation in resolveSlotProps.
151
- if (verdict !== undefined) {
152
- if (verdict === 'pass') return children;
153
- if (verdict instanceof DenySignal) {
154
- return (
155
- buildDeniedFallback(DeniedComponent, slotName, verdict.data, createElement) ??
156
- defaultFallback ??
157
- null
158
- );
159
- }
160
- // RedirectSignal: treat as deny in production (same as existing behavior)
161
- if (isDebug()) {
162
- console.error(
163
- '[timber] redirect() is not allowed in slot access.ts. ' +
164
- 'Slots use deny() for graceful degradation — denied.tsx → default.tsx → null. ' +
165
- "If you need to redirect, move the logic to the parent segment's access.ts."
166
- );
167
- }
168
- return (
169
- buildDeniedFallback(DeniedComponent, slotName, undefined, createElement) ??
170
- defaultFallback ??
171
- null
172
- );
173
- }
174
-
175
- // Fallback path: call accessFn during render (no pre-computed verdict).
176
149
  try {
177
150
  await accessFn();
178
151
  } catch (error: unknown) {
@@ -7,9 +7,8 @@
7
7
  * - the layout depth handed to `SegmentProvider` (`route-element-builder.ts`),
8
8
  * which `useSelectedLayoutSegment(s)` indexes the live pathname with;
9
9
  * - `slotUrlParts`, which slices the destination pathname for slot matching;
10
- * - `resolveSlotProps`, which slices the *departing* pathname the same way to
11
- * decide whether a slot can be skipped;
12
- * - `skippable-prefix.ts`, which does the same for the contiguous skip prefix.
10
+ * - `resolveSlotProps` and `skippable-prefix.ts`, which derive content keys
11
+ * for slot skip decisions (TIM-1370).
13
12
  *
14
13
  * They were four separate `extractUrlParts(...).length` calls. That was fine
15
14
  * while the answer was a plain walk, and stopped being fine when interception
@@ -28,7 +28,12 @@ import { requestContextAls } from './als-registry.js';
28
28
  import { PageDenyBoundary, buildDenyPageChain, handleCaughtDeny } from './deny-boundary.js';
29
29
  import type { DenyPageEntry } from './deny-boundary.js';
30
30
  import { LAYOUT_CHILDREN_SLOT } from './prebuilt/slots.js';
31
- import { resolveSlotProps, publishSlotSegmentParams, type SlotSkipEntry } from './slot-resolver.js';
31
+ import {
32
+ resolveSlotProps,
33
+ publishSlotSegmentParams,
34
+ emitSlotInfoForSkippedLayout,
35
+ type SlotSkipEntry,
36
+ } from './slot-resolver.js';
32
37
  import { consumedUrlParts } from './chain-url-parts.js';
33
38
  import { withPublishedParams } from './publish-params.js';
34
39
  import type { RscPayloadRoot } from '../shared/payload-root.js';
@@ -547,30 +552,9 @@ export async function buildRouteElement(
547
552
 
548
553
  let outermostSegmentProvider: React.ReactElement | null = null;
549
554
 
550
- // Slot caching: the departing URL (X-Timber-URL header) and the
551
- // destination URL (request URL) are compared to determine which
552
- // slots' content has changed.
553
- //
554
- // Pathnames only. The client sends `X-Timber-URL` as a pathname
555
- // (`client/router.ts` takes `.pathname` on both branches) and
556
- // `slot-resolver.ts` reads `.pathname` from both sides, so search
557
- // params take no part in the skip decision.
558
- //
559
- // Nothing here can tell whether a slot's content depends on the query.
560
- // What is supposed to make that safe lives elsewhere: a slot whose server
561
- // render varies with the query must reach it through a request accessor
562
- // (page components are built with no props), which the request-dep analysis
563
- // is meant to catch — and that analysis is incomplete for accessors reached
564
- // through indirection, so this is currently a real staleness hole. See
565
- // TIM-1291 and design/19-client-navigation.md. A slot whose *client* subtree
566
- // varies with the query reads useSearchParams(), which re-renders from
567
- // NavigationContext whether or not the slot was skipped; that half is
568
- // unconditional.
569
- //
570
- // Do not add search to the comparison by sending the full departing URL:
571
- // the header is hashed into the public `_rsc` cache key (TIM-1268),
572
- // and a digest does not anonymize a guessable query string.
573
- const departingUrl = req.headers.get('X-Timber-URL');
555
+ // Slot caching uses key-based diffing (TIM-1370). The server computes a
556
+ // content key per slot and the client advertises keys for cached slots.
557
+ // The skip decision is a set-membership check — no departing URL needed.
574
558
  const destinationUrl = req.url;
575
559
  const slotSkipInfo: SlotSkipEntry[] = [];
576
560
 
@@ -592,6 +576,11 @@ export async function buildRouteElement(
592
576
  // because an unchanged slot is what made this layout skippable.
593
577
  publishSlotSegmentParams(segment, match, destinationUrl, segmentTreePaths[i]);
594
578
 
579
+ // Emit slot metadata so the client's segment tree retains contentKeys
580
+ // for slots of skipped layouts. Without this, the client rebuilds its
581
+ // tree from the response and loses slot keys it can't advertise next time.
582
+ emitSlotInfoForSkippedLayout(segment, segmentKeys[i], match, destinationUrl, slotSkipInfo);
583
+
595
584
  // No error boundaries are wrapped for a skipped segment — the `continue`
596
585
  // below returns before that walk. A deny addressed to this segment
597
586
  // therefore finds no boundary in the payload, and since TIM-1356 the
@@ -705,7 +694,6 @@ export async function buildRouteElement(
705
694
  h,
706
695
  interception,
707
696
  parentTreePath,
708
- departingUrl,
709
697
  destinationUrl,
710
698
  clientStateTree: clientStateTree as ClientStateTree | null,
711
699
  slotSkipInfo,
@@ -248,6 +248,7 @@ export function buildSegmentInfo(
248
248
  isRequestDependent: slot.isRequestDependent,
249
249
  slot: true,
250
250
  parentSegment: slot.parentSegmentId,
251
+ contentKey: slot.contentKey,
251
252
  };
252
253
  if (slot.skipped) entry.skipped = true;
253
254
  if (slot.denied) entry.denied = true;
@@ -11,7 +11,9 @@ import type { ManifestSegmentNode } from './route-matcher.js';
11
11
  import type { RouteMatch } from './pipeline.js';
12
12
  import { shouldSkipSlot, type ClientStateTree } from './state-tree-diff.js';
13
13
  import { consumedUrlParts } from './chain-url-parts.js';
14
- import { computeSlotKey } from '../routing/segment-keys.js';
14
+ import { computeSlotContentKey, computeSlotKey } from '../routing/segment-keys.js';
15
+ import { matchUrlParts } from './tree-match.js';
16
+ import { slotUrlParts } from './slot-resolver.js';
15
17
 
16
18
  /**
17
19
  * Truncate the skippable prefix when a skippable layout has slots whose
@@ -22,6 +24,9 @@ import { computeSlotKey } from '../routing/segment-keys.js';
22
24
  * Removing the segment from the skippable set forces it to render with
23
25
  * its slots. The prefix must stay contiguous, so all segments after the
24
26
  * first changed-slot segment are also removed.
27
+ *
28
+ * Uses key-based diffing (TIM-1370): computes the destination content key
29
+ * per slot and checks if the client's advertised set contains it.
25
30
  */
26
31
  export function truncateSkippableForChangedSlots(
27
32
  skippableSet: Set<number>,
@@ -31,23 +36,8 @@ export function truncateSkippableForChangedSlots(
31
36
  clientStateTree: ClientStateTree,
32
37
  req: Request
33
38
  ): void {
34
- const departingUrl = req.headers.get('X-Timber-URL');
35
- let depParsed: URL | null = null;
36
- if (departingUrl) {
37
- try {
38
- depParsed = new URL(departingUrl, 'http://localhost');
39
- } catch {
40
- skippableSet.clear();
41
- return;
42
- }
43
- }
44
- const destPathname = new URL(req.url, 'http://localhost').pathname;
45
- const depPathname = depParsed?.pathname ?? null;
46
- const split = (p: string) => (p === '/' ? [] : p.slice(1).split('/'));
47
- const destAll = split(destPathname);
48
- const depAll = depPathname ? split(depPathname) : null;
49
- const clientSlots = clientStateTree.slots ?? null;
50
- const rawParams = match.rawSegmentParams ?? match.segmentParams ?? {};
39
+ const clientSlotKeys = clientStateTree.slots ?? null;
40
+ const destUrl = req.url;
51
41
 
52
42
  for (let i = 0; i < segments.length; i++) {
53
43
  if (!skippableSet.has(i)) continue;
@@ -56,33 +46,25 @@ export function truncateSkippableForChangedSlots(
56
46
  if (slotEntries.length === 0) continue;
57
47
 
58
48
  const segIdx = match.segments.indexOf(segment);
59
- // The root consumes no URL part, and a climbing interception marker
60
- // removes owner segments from the target — `consumedUrlParts` handles both,
61
- // and is the same derivation slot matching and layout depth use.
62
- const sliceAt = segIdx >= 0 ? consumedUrlParts(match.segments, segIdx, match).length : 0;
63
- const destParts = destAll.slice(sliceAt);
64
- const depParts = depAll ? depAll.slice(sliceAt) : null;
65
- let parentChanged = false;
66
- if (depAll) {
67
- const dp = depAll.slice(0, sliceAt),
68
- dd = destAll.slice(0, sliceAt);
69
- parentChanged = dp.length !== dd.length || dp.some((p, j) => p !== dd[j]);
70
- }
49
+ const ownerParts = segIdx >= 0 ? consumedUrlParts(match.segments, segIdx, match) : [];
50
+ const destParts = slotUrlParts(segment, match, destUrl);
71
51
 
72
52
  for (const [slotName, slotNode] of slotEntries) {
73
53
  const m = slotNode as ManifestSegmentNode;
74
54
  const k = computeSlotKey(segmentKeys[i], `@${slotName}`);
55
+
56
+ // Slots with access.ts are never skip candidates (TIM-1364).
57
+ const destMatch = matchUrlParts(m, destParts);
58
+ const hasAccessInChain = !!m.access || (destMatch?.chain.some((seg) => seg.access) ?? false);
59
+
60
+ const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
61
+ const entryFile = destLeaf?.page?.filePath ?? null;
62
+ const contentKey = computeSlotContentKey(k, ownerParts, entryFile, destMatch?.params ?? {});
63
+
75
64
  if (
65
+ hasAccessInChain ||
76
66
  m.children.some((c) => c.segmentType === 'intercepting') ||
77
- parentChanged ||
78
- depParts === null ||
79
- !shouldSkipSlot({
80
- slotKey: k,
81
- clientSlots,
82
- slotNode: m,
83
- departingUrlParts: depParts,
84
- destinationUrlParts: destParts,
85
- })
67
+ !shouldSkipSlot(contentKey, clientSlotKeys)
86
68
  ) {
87
69
  for (let j = i; j < segments.length; j++) skippableSet.delete(j);
88
70
  return;