@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.
- package/dist/_chunks/{resolve-schema-5ma5pp1b.js → resolve-schema-CBR6Lm4i.js} +2 -2
- package/dist/_chunks/{resolve-schema-5ma5pp1b.js.map → resolve-schema-CBR6Lm4i.js.map} +1 -1
- package/dist/_chunks/{schema-bridge-Cc2Gngu1.js → schema-bridge-C83xa9lT.js} +2 -2
- package/dist/_chunks/{schema-bridge-Cc2Gngu1.js.map → schema-bridge-C83xa9lT.js.map} +1 -1
- package/dist/_chunks/segment-keys-BawYuNFO.js.map +1 -1
- package/dist/_chunks/{use-query-states-BbU5Ge1V.js → use-query-states-I3JMng6J.js} +29 -5
- package/dist/_chunks/use-query-states-I3JMng6J.js.map +1 -0
- package/dist/client/index.js +1 -1
- package/dist/client/internal.js +13 -4
- package/dist/client/internal.js.map +1 -1
- package/dist/client/segment-cache.d.ts +6 -0
- package/dist/client/segment-cache.d.ts.map +1 -1
- package/dist/client/use-query-states.d.ts.map +1 -1
- package/dist/codec.js +1 -1
- package/dist/cookies/index.js +1 -1
- package/dist/params/index.js +1 -1
- package/dist/routing/segment-keys.d.ts +22 -0
- package/dist/routing/segment-keys.d.ts.map +1 -1
- package/dist/schema-bridge.d.ts +4 -1
- package/dist/schema-bridge.d.ts.map +1 -1
- package/dist/search-params/define.d.ts +30 -4
- package/dist/search-params/define.d.ts.map +1 -1
- package/dist/search-params/index.d.ts +1 -1
- package/dist/search-params/index.d.ts.map +1 -1
- package/dist/search-params/index.js +20 -5
- package/dist/search-params/index.js.map +1 -1
- package/dist/search-params/parse-total.d.ts +14 -3
- package/dist/search-params/parse-total.d.ts.map +1 -1
- package/dist/search-params/serialize-equal.d.ts +9 -0
- package/dist/search-params/serialize-equal.d.ts.map +1 -0
- package/dist/server/access-gate.d.ts.map +1 -1
- package/dist/server/chain-url-parts.d.ts +2 -3
- package/dist/server/chain-url-parts.d.ts.map +1 -1
- package/dist/server/internal.js +2 -8
- package/dist/server/internal.js.map +1 -1
- package/dist/server/route-element-builder.d.ts.map +1 -1
- package/dist/server/rsc-entry/helpers.d.ts.map +1 -1
- package/dist/server/skippable-prefix.d.ts +3 -0
- package/dist/server/skippable-prefix.d.ts.map +1 -1
- package/dist/server/slot-resolver.d.ts +30 -19
- package/dist/server/slot-resolver.d.ts.map +1 -1
- package/dist/server/state-tree-diff.d.ts +9 -16
- package/dist/server/state-tree-diff.d.ts.map +1 -1
- package/dist/server/tree-builder.d.ts +0 -7
- package/dist/server/tree-builder.d.ts.map +1 -1
- package/dist/shared/segment-info.d.ts +7 -0
- package/dist/shared/segment-info.d.ts.map +1 -1
- package/docs/api/33-api-search-params.mdx +3 -3
- package/docs/learn/05-typed-params.mdx +1 -1
- package/package.json +1 -1
- package/src/client/segment-cache.ts +18 -5
- package/src/client/use-query-states.ts +23 -14
- package/src/routing/segment-keys.ts +37 -0
- package/src/schema-bridge.ts +8 -3
- package/src/search-params/define.ts +73 -14
- package/src/search-params/index.ts +1 -0
- package/src/search-params/parse-total.ts +17 -4
- package/src/search-params/serialize-equal.ts +14 -0
- package/src/search-params/wrappers.ts +1 -1
- package/src/server/access-gate.tsx +1 -28
- package/src/server/chain-url-parts.ts +2 -3
- package/src/server/route-element-builder.ts +14 -26
- package/src/server/rsc-entry/helpers.ts +1 -0
- package/src/server/skippable-prefix.ts +21 -39
- package/src/server/slot-resolver.ts +141 -187
- package/src/server/state-tree-diff.ts +11 -62
- package/src/server/tree-builder.ts +0 -10
- package/src/shared/segment-info.ts +7 -0
- 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,
|
|
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
|
-
/**
|
|
239
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
503
|
-
|
|
504
|
-
|
|
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
|
-
|
|
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,
|
|
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]> };
|
|
@@ -43,12 +43,23 @@
|
|
|
43
43
|
* Design doc: design/23-search-params.md §"nuqs parsers, made total"
|
|
44
44
|
*/
|
|
45
45
|
|
|
46
|
-
|
|
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
|
|
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>(
|
|
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:
|
|
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
|
|
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
|
|
11
|
-
*
|
|
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 {
|
|
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
|
|
551
|
-
//
|
|
552
|
-
//
|
|
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
|
|
35
|
-
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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;
|