@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
|
@@ -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,
|
|
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,
|
|
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
|
/**
|
|
@@ -643,6 +561,88 @@ export function publishSlotSegmentParams(
|
|
|
643
561
|
}
|
|
644
562
|
}
|
|
645
563
|
|
|
564
|
+
/**
|
|
565
|
+
* Emit slot metadata for a layout whose rendering was SKIPPED.
|
|
566
|
+
*
|
|
567
|
+
* Skipped layouts never reach `resolveSlotProps`, but the client rebuilds
|
|
568
|
+
* its segment tree from each navigation's X-Timber-Segments response. If
|
|
569
|
+
* skipped slots are absent, the client loses their contentKeys and can't
|
|
570
|
+
* advertise them on the next navigation — breaking key-based slot skipping.
|
|
571
|
+
*
|
|
572
|
+
* This computes contentKey and isRequestDependent for each slot without
|
|
573
|
+
* rendering anything, using the same derivation as `resolveSlotProps`.
|
|
574
|
+
*/
|
|
575
|
+
export function emitSlotInfoForSkippedLayout(
|
|
576
|
+
segment: ManifestSegmentNode,
|
|
577
|
+
segmentId: string,
|
|
578
|
+
match: RouteMatch,
|
|
579
|
+
destinationUrl: string,
|
|
580
|
+
slotSkipInfo: SlotSkipEntry[]
|
|
581
|
+
): void {
|
|
582
|
+
const slotEntries = Object.entries(segment.slots ?? {});
|
|
583
|
+
if (slotEntries.length === 0) return;
|
|
584
|
+
|
|
585
|
+
const destinationParts = slotUrlParts(segment, match, destinationUrl);
|
|
586
|
+
const ownerParts = consumedPartsThroughSegment(segment, match);
|
|
587
|
+
|
|
588
|
+
for (const [slotName, slotNode] of slotEntries) {
|
|
589
|
+
const slotManifest = slotNode as ManifestSegmentNode;
|
|
590
|
+
const slotKey = computeSlotKey(segmentId, `@${slotName}`);
|
|
591
|
+
const destMatch = matchUrlParts(slotManifest, destinationParts);
|
|
592
|
+
|
|
593
|
+
const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
|
|
594
|
+
const entryFile = destLeaf?.page?.filePath ?? null;
|
|
595
|
+
const contentKey = computeSlotContentKey(
|
|
596
|
+
slotKey,
|
|
597
|
+
ownerParts,
|
|
598
|
+
entryFile,
|
|
599
|
+
destMatch?.params ?? {}
|
|
600
|
+
);
|
|
601
|
+
|
|
602
|
+
const hasAccessInChain =
|
|
603
|
+
!!slotManifest.access || (destMatch?.chain.some((seg) => seg.access) ?? false);
|
|
604
|
+
|
|
605
|
+
let slotRequestDep = hasAccessInChain;
|
|
606
|
+
if (!slotRequestDep) {
|
|
607
|
+
const hasRenderedPage = destMatch && destLeaf?.page;
|
|
608
|
+
if (hasRenderedPage) {
|
|
609
|
+
if (
|
|
610
|
+
slotManifest.layout?.filePath &&
|
|
611
|
+
isStaticRequestDependent(slotManifest.layout.filePath)
|
|
612
|
+
) {
|
|
613
|
+
slotRequestDep = true;
|
|
614
|
+
}
|
|
615
|
+
if (!slotRequestDep) {
|
|
616
|
+
for (const seg of destMatch.chain) {
|
|
617
|
+
if (seg.layout?.filePath && isStaticRequestDependent(seg.layout.filePath)) {
|
|
618
|
+
slotRequestDep = true;
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
if (!slotRequestDep && destLeaf?.page?.filePath) {
|
|
624
|
+
slotRequestDep = isStaticRequestDependent(destLeaf.page.filePath);
|
|
625
|
+
}
|
|
626
|
+
} else {
|
|
627
|
+
const defaultFile = slotManifest.default?.filePath;
|
|
628
|
+
if (!defaultFile) {
|
|
629
|
+
slotRequestDep = true;
|
|
630
|
+
} else {
|
|
631
|
+
slotRequestDep = isStaticRequestDependent(defaultFile);
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
slotSkipInfo.push({
|
|
637
|
+
slotKey,
|
|
638
|
+
parentSegmentId: segmentId,
|
|
639
|
+
isRequestDependent: slotRequestDep,
|
|
640
|
+
denied: false,
|
|
641
|
+
contentKey,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
|
|
646
646
|
interface ResolveSlotPropsArgs {
|
|
647
647
|
segment: ManifestSegmentNode;
|
|
648
648
|
segmentId: string;
|
|
@@ -650,7 +650,6 @@ interface ResolveSlotPropsArgs {
|
|
|
650
650
|
h: CreateElementFn;
|
|
651
651
|
interception?: InterceptionContext;
|
|
652
652
|
parentTreePath: string;
|
|
653
|
-
departingUrl: string | null;
|
|
654
653
|
destinationUrl: string;
|
|
655
654
|
clientStateTree: ClientStateTree | null;
|
|
656
655
|
slotSkipInfo: SlotSkipEntry[];
|
|
@@ -658,9 +657,16 @@ interface ResolveSlotPropsArgs {
|
|
|
658
657
|
|
|
659
658
|
/**
|
|
660
659
|
* Resolve all parallel route slots for a layout, wrapping each in a
|
|
661
|
-
* SegmentOutlet for client-side caching. Slots whose
|
|
662
|
-
*
|
|
663
|
-
*
|
|
660
|
+
* SegmentOutlet for client-side caching. Slots whose content key matches
|
|
661
|
+
* one the client advertised are rendered with `skip=true` so the client
|
|
662
|
+
* keeps its cached content.
|
|
663
|
+
*
|
|
664
|
+
* The skip decision is key-based (TIM-1370): the server computes a content
|
|
665
|
+
* key per slot from the owning segment's URL parts, the slot name, the
|
|
666
|
+
* matched entry file, and the slot's own params. The client advertises
|
|
667
|
+
* the keys it has cached; the server checks membership. This replaces
|
|
668
|
+
* the previous approach of reconstructing the client's state from a
|
|
669
|
+
* departing URL.
|
|
664
670
|
*/
|
|
665
671
|
export async function resolveSlotProps({
|
|
666
672
|
segment,
|
|
@@ -669,7 +675,6 @@ export async function resolveSlotProps({
|
|
|
669
675
|
h,
|
|
670
676
|
interception,
|
|
671
677
|
parentTreePath,
|
|
672
|
-
departingUrl,
|
|
673
678
|
destinationUrl,
|
|
674
679
|
clientStateTree,
|
|
675
680
|
slotSkipInfo,
|
|
@@ -678,106 +683,51 @@ export async function resolveSlotProps({
|
|
|
678
683
|
const slotEntries = Object.entries(segment.slots ?? {});
|
|
679
684
|
if (slotEntries.length === 0) return slotProps;
|
|
680
685
|
|
|
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
686
|
const destinationParts = slotUrlParts(segment, match, destinationUrl);
|
|
705
|
-
const
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
//
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
}
|
|
687
|
+
const clientSlotKeys = clientStateTree?.slots ?? null;
|
|
688
|
+
|
|
689
|
+
// Owner URL parts — the URL values consumed through this segment.
|
|
690
|
+
// These are part of the content key because the slot's page may read
|
|
691
|
+
// parent params (e.g., via getSegmentParams).
|
|
692
|
+
const ownerParts = consumedPartsThroughSegment(segment, match);
|
|
716
693
|
|
|
717
694
|
for (const [slotName, slotNode] of slotEntries) {
|
|
718
695
|
const slotManifest = slotNode as ManifestSegmentNode;
|
|
719
696
|
const slotKey = computeSlotKey(segmentId, `@${slotName}`);
|
|
720
697
|
|
|
721
698
|
// Match the slot's sub-tree against the destination URL parts.
|
|
722
|
-
// Used for both the skip decision and eager access evaluation.
|
|
723
699
|
const destMatch = matchUrlParts(slotManifest, destinationParts);
|
|
724
700
|
|
|
725
|
-
//
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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
|
-
}
|
|
701
|
+
// Compute the content key for this slot at the destination URL.
|
|
702
|
+
const destLeaf = destMatch?.chain[destMatch.chain.length - 1];
|
|
703
|
+
const entryFile = destLeaf?.page?.filePath ?? null;
|
|
704
|
+
const contentKey = computeSlotContentKey(
|
|
705
|
+
slotKey,
|
|
706
|
+
ownerParts,
|
|
707
|
+
entryFile,
|
|
708
|
+
destMatch?.params ?? {}
|
|
709
|
+
);
|
|
742
710
|
|
|
743
|
-
//
|
|
744
|
-
//
|
|
745
|
-
//
|
|
746
|
-
//
|
|
711
|
+
// Auth-bearing slots are inherently request-dependent — their content
|
|
712
|
+
// changes based on user state. Skipping them would require eager
|
|
713
|
+
// evaluation of access.ts outside React.cache scope, causing double
|
|
714
|
+
// execution (TIM-1364). Instead, exclude them from skip candidates
|
|
715
|
+
// entirely and let SlotAccessGate handle access during render.
|
|
716
|
+
// The client filters these out via isRequestDependent, so their keys
|
|
717
|
+
// are never advertised — but checking here is defense-in-depth.
|
|
718
|
+
const hasAccessInChain =
|
|
719
|
+
!!slotManifest.access || (destMatch?.chain.some((seg) => seg.access) ?? false);
|
|
747
720
|
const hasInterceptingChildren = slotManifest.children.some(
|
|
748
721
|
(c) => c.segmentType === 'intercepting'
|
|
749
722
|
);
|
|
750
|
-
const
|
|
723
|
+
const canSkip =
|
|
751
724
|
!interception &&
|
|
752
725
|
!hasInterceptingChildren &&
|
|
753
|
-
!
|
|
754
|
-
|
|
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;
|
|
726
|
+
!hasAccessInChain &&
|
|
727
|
+
shouldSkipSlot(contentKey, clientSlotKeys);
|
|
777
728
|
|
|
778
729
|
if (canSkip) {
|
|
779
|
-
//
|
|
780
|
-
// No gate wrapper needed.
|
|
730
|
+
// No access.ts in this slot's chain (excluded above), so no gate needed.
|
|
781
731
|
slotProps[slotName] = h(SegmentOutlet, {
|
|
782
732
|
segmentPath: slotKey,
|
|
783
733
|
skip: true,
|
|
@@ -789,6 +739,7 @@ export async function resolveSlotProps({
|
|
|
789
739
|
isRequestDependent: false,
|
|
790
740
|
denied: false,
|
|
791
741
|
skipped: true,
|
|
742
|
+
contentKey,
|
|
792
743
|
});
|
|
793
744
|
} else {
|
|
794
745
|
const resolvedElement = await resolveSlotElement(
|
|
@@ -796,8 +747,7 @@ export async function resolveSlotProps({
|
|
|
796
747
|
match,
|
|
797
748
|
h,
|
|
798
749
|
interception,
|
|
799
|
-
parentTreePath
|
|
800
|
-
chainVerdicts
|
|
750
|
+
parentTreePath
|
|
801
751
|
);
|
|
802
752
|
slotProps[slotName] = h(SegmentOutlet, {
|
|
803
753
|
segmentPath: slotKey,
|
|
@@ -806,10 +756,13 @@ export async function resolveSlotProps({
|
|
|
806
756
|
// Static analysis: check if any file in the slot's RENDERED tree
|
|
807
757
|
// is request-dependent. Only check layouts when destMatch exists —
|
|
808
758
|
// unmatched slots render default.tsx directly without layouts.
|
|
809
|
-
|
|
810
|
-
|
|
759
|
+
// Slots with access.ts are inherently request-dependent — their
|
|
760
|
+
// content varies with user state (TIM-1364).
|
|
761
|
+
let slotRequestDep = hasAccessInChain;
|
|
811
762
|
const hasRenderedPage = destMatch && destLeaf?.page;
|
|
812
|
-
if (
|
|
763
|
+
if (slotRequestDep) {
|
|
764
|
+
// Already marked — skip static analysis
|
|
765
|
+
} else if (interception) {
|
|
813
766
|
// `destMatch` walks the slot's ordinary children against the
|
|
814
767
|
// destination URL; the interception resolver renders an intercepting
|
|
815
768
|
// child instead, which that walk never sees. So the files analyzed
|
|
@@ -854,7 +807,8 @@ export async function resolveSlotProps({
|
|
|
854
807
|
slotKey,
|
|
855
808
|
parentSegmentId: segmentId,
|
|
856
809
|
isRequestDependent: slotRequestDep,
|
|
857
|
-
denied:
|
|
810
|
+
denied: false,
|
|
811
|
+
contentKey,
|
|
858
812
|
});
|
|
859
813
|
}
|
|
860
814
|
}
|
|
@@ -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
|
|
127
|
-
*
|
|
128
|
-
*
|
|
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
|
-
*
|
|
132
|
-
*
|
|
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
|
-
|
|
139
|
-
|
|
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
|
/**
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"use-query-states-BbU5Ge1V.js","names":[],"sources":["../../src/search-params/parse-total.ts","../../src/client/use-query-states.ts"],"sourcesContent":["/**\n * parseTotal — invoke a codec over the FULL raw domain.\n *\n * `SearchParamCodec.parse` is documented to be total over\n * `string | string[] | undefined`, because that is exactly what a URL\n * hands it: a param can be absent (`undefined`) or repeated (`string[]`).\n * Timber's own codecs and the Standard Schema bridges honour that.\n *\n * nuqs parsers do not. Their `parse` expects a **present scalar string** —\n * nuqs checks presence itself before ever calling it — so `parseAsBoolean`\n * and `parseAsIsoDate` threw a render-phase 500 on an absent param and\n * `parseAsString` returned an array on a repeated one (TIM-1350).\n *\n * nuqs ships the missing adapter: every parser builder exposes\n * `parseServerSide(value: string | string[] | undefined)`, which maps\n * absent → `null` (or the parser's `withDefault` value), takes the FIRST\n * entry of a repeated param (matching `URLSearchParams.get()`), and wraps\n * the inner `parse` so a throw becomes `null`. That is precisely timber's\n * domain, so we call it in preference to `parse` rather than hand-rolling\n * a second normalization that could disagree with the client hook.\n *\n * Feature detection, not an instanceof check: any codec MAY publish\n * `parseServerSide` to declare \"this is my total entry point\" — it is an\n * optional member of `SearchParamCodec` — and a codec that does not is\n * assumed already total and called through `parse`. Timber codecs and\n * schema bridges take the second branch untouched; several of them rely on\n * `parse(undefined)` to produce their default.\n *\n * **Search params only.** Segment params (`server/param-coercion.ts`) call\n * `codec.parse` directly and must keep doing so: their domain is a value\n * the router matched, never absent, and a codec that REJECTS one is how a\n * route produces a 404. Routing a rejection through nuqs's `safeParse`\n * would turn that 404 into a silent `null` param. The two domains differ\n * in what \"no value\" means, not just in plumbing. Cookies are a third\n * domain (`cookies/define-cookie.ts`) and are likewise untouched.\n *\n * `parseServerSide` carries a `@deprecated` tag in nuqs (it steers users to\n * loaders, which timber does not use). It remains public, typed and\n * exercised; `tests/nuqs-codec-boundary.test.ts` asserts totality for every\n * parser through timber's own API, so a nuqs release that drops it fails\n * loudly rather than silently reinstating the 500s.\n *\n * Design doc: design/23-search-params.md §\"nuqs parsers, made total\"\n */\n\nimport type { Codec } from '../codec.js';\n\n/**\n * A codec that publishes a total entry point over the raw URL domain.\n *\n * The return is `T`, not `T | null`: this is the entry point timber calls,\n * so whatever it answers IS the field's type. A `null` for an absent param\n * belongs in `T` — a bare nuqs parser is a codec of `string | null`, and\n * `parseAsInteger.withDefault(1)` is a codec of `number`, because nuqs\n * narrows its own `parseServerSide` return to `NonNullable<T>`. Declaring\n * `T | null` here would let a codec annotated `SearchParamCodec<string>`\n * hand back `null` under a non-nullable type (TIM-1350 review).\n */\nexport interface TotalCodec<T> {\n parseServerSide(value: string | string[] | undefined): T;\n}\n\nfunction hasParseServerSide<T>(codec: Codec<T>): codec is Codec<T> & TotalCodec<T> {\n return typeof (codec as Partial<TotalCodec<T>>).parseServerSide === 'function';\n}\n\n/**\n * Parse a raw URL value through a codec, using the codec's total entry\n * point when it publishes one.\n *\n * Returns `T`, from both branches. A `null` for an absent param is part of\n * the codec's own `T` — see TotalCodec above — so this signature does not\n * widen it, and a caller that must handle \"no value\" (`withDefault`) sees\n * it because `T` carries it.\n */\nexport function parseTotal<T>(codec: Codec<T>, raw: string | string[] | undefined): T {\n return hasParseServerSide(codec) ? codec.parseServerSide(raw) : codec.parse(raw);\n}\n","/**\n * useQueryStates — client-side hook for URL-synced search params.\n *\n * Delegates to nuqs for URL synchronization, batching, React 19 transitions,\n * and throttled URL writes. Bridges timber's SearchParamCodec protocol to\n * nuqs-compatible parsers.\n *\n * Design doc: design/23-search-params.md §\"Codec Bridge\"\n */\n\n'use client';\n\nimport { useQueryStates as nuqsUseQueryStates } from 'nuqs';\nimport type { MultiParser } from 'nuqs';\nimport type {\n SearchParamCodec,\n SearchParamsDefinition,\n SetParams,\n QueryStatesOptions,\n} from '../search-params/define.js';\nimport { parseTotal } from '../search-params/parse-total.js';\n\n// ─── Codec Bridge ─────────────────────────────────────────────────\n\n// nuqs's parser contract conflates values timber codecs distinguish:\n// parse() returning null means \"unparseable, substitute defaultValue\",\n// and undefined entries are skipped entirely. Timber codecs can\n// legitimately produce both — bare z.string() yields undefined for absent\n// params (implicit optionality), and a codec may map a present value to\n// null. Wrap those two values in sentinels across the nuqs boundary and\n// unwrap them before handing values back to the caller, so the client\n// hook returns exactly what server-side parse() returns.\n// Unique object references compared by identity — a codec can never\n// produce these from URL input, so user-controlled strings cannot collide\n// with them (unlike string sentinels), and unlike Symbols they survive\n// nuqs's internal string coercion without throwing.\nconst NULL_SENTINEL: object = { timberSentinel: 'null' };\nconst UNDEFINED_SENTINEL: object = { timberSentinel: 'undefined' };\n\nfunction wrapNuqsValue(value: unknown): unknown {\n if (value === null) return NULL_SENTINEL;\n if (value === undefined) return UNDEFINED_SENTINEL;\n return value;\n}\n\nfunction unwrapNuqsValue(value: unknown): unknown {\n if (value === NULL_SENTINEL) return null;\n if (value === UNDEFINED_SENTINEL) return undefined;\n return value;\n}\n\n/**\n * Bridge a timber SearchParamCodec to a nuqs-compatible MultiParser.\n *\n * nuqs parsers: { parse(string) → T|null, serialize?(T) → string, eq?, defaultValue? }\n * timber codecs: { parse(string|string[]|undefined) → T, serialize(T) → string|null }\n *\n * The defaultValue is computed eagerly, through `parseTotal` — the same\n * entry point server-side `parse()` uses, so the hook and the server agree\n * on what an absent param means (a bare nuqs parser answers `null`, not\n * `undefined`; TIM-1350). Codecs are documented to return a default rather\n * than throw, but a throwing codec must not crash every component that\n * mounts the hook — treat its default as undefined and let its error\n * surface from server-side parse() instead.\n *\n * A `null` absent-value is NOT registered as the nuqs default. nuqs\n * already represents an absent key as `null`, so the hook reads the same\n * value either way — but registering it makes `clearOnDefault` fire on\n * `setParams({ q: null })` and delete the key before the bridged\n * `serialize` runs. For a codec that encodes `null` as a real query value\n * (`serialize(null) === 'none'`), that silently disagrees with\n * `buildSearchParams({ q: null })`, which writes it. Same reasoning as\n * `getDefaultSerialized` on the server: a codec with no value for an\n * absent param has no default to register.\n */\nfunction bridgeCodec<T>(codec: SearchParamCodec<T>): MultiParser<T> & { defaultValue: T } {\n let absent: unknown;\n try {\n absent = parseTotal(codec, undefined);\n } catch {\n absent = undefined;\n }\n\n const parser = {\n // `multi`, so nuqs reads the key with `searchParams.getAll()` and hands\n // us EVERY value. A single parser reads `.get()` — the first value only\n // — which is not the domain a timber codec is defined over. The server\n // parses `?tags=a&tags=b` as `['a','b']`; a single parser made the hook\n // answer `['a']` for the same URL, under a declared `string[]` that\n // admitted no such disagreement (TIM-1352). Scalar codecs are unaffected:\n // they receive the array and take `value[0]`, exactly as they do on the\n // server, so first-value-wins is preserved through the same code path\n // rather than through nuqs's reader.\n //\n // nuqs never calls this with an empty array — `isAbsentFromUrl` treats\n // `[]` as absent and answers `defaultValue` directly — which is what\n // keeps the absent case agreeing with the server's `undefined`.\n //\n // Reading every value is only half of it: the values must arrive in the\n // SAME SHAPE the server would have produced, or the divergence just\n // moves. `normalizeRaw` (search-params/define.ts) collapses a\n // single-valued key to a bare string and keeps an array only for a\n // repeated one, so this mirrors that rule exactly. Handing a codec\n // `['3']` where the server hands it `'3'` breaks every codec whose\n // `parse` is written for the scalar case — which is most hand-written\n // ones, contract or no contract.\n type: 'multi' as const,\n // Through parseTotal, not codec.parse. nuqs's own `.withDefault(d)`\n // overrides ONLY `parseServerSide`, so `parseAsInteger.withDefault(1)`\n // on `?page=abc` returned 1 from the server and null from the hook —\n // a divergence the declared non-nullable `number` did not admit.\n parse: (v: readonly string[]) =>\n wrapNuqsValue(parseTotal(codec, v.length === 1 ? v[0] : [...v])),\n serialize: (v: unknown) => {\n const value = unwrapNuqsValue(v);\n // Delegate null to the codec — some codecs encode null as a real\n // query value. undefined has no encoding; nuqs requires a string.\n //\n // ONE element, always. A multi parser may return several — nuqs\n // appends one key per entry — but `Codec.serialize` returns a single\n // string by construction, so timber cannot emit repeated keys here\n // any more than `buildSearchParams` can. Widening that protocol is\n // TIM-1353. Wrapping the same string keeps the URL the hook writes\n // byte-identical to the one `buildSearchParams` writes.\n return [value === undefined ? '' : (codec.serialize(value as T) ?? '')];\n },\n eq: (a: unknown, b: unknown) => {\n if (a === b) return true;\n try {\n return (\n codec.serialize(unwrapNuqsValue(a) as T) === codec.serialize(unwrapNuqsValue(b) as T)\n );\n } catch {\n return false;\n }\n },\n } as MultiParser<T> & { defaultValue: T };\n\n if (absent !== null) parser.defaultValue = wrapNuqsValue(absent) as T;\n return parser;\n}\n\n/**\n * Collect `withUrlKey` aliases off a codec map.\n *\n * `withUrlKey(codec, 'q')` returns a codec carrying `urlKey: 'q'`, so the map\n * alone is enough to reconstruct the aliases — `defineSearchParams` builds its\n * own `urlKeys` from exactly this property.\n */\nfunction deriveUrlKeys(codecs: Record<string, SearchParamCodec<unknown>>): Record<string, string> {\n const result: Record<string, string> = {};\n for (const key of Object.keys(codecs)) {\n const alias = codecs[key]?.urlKey;\n if (alias) result[key] = alias;\n }\n return result;\n}\n\n/**\n * Bridge an entire codec map to nuqs-compatible parsers.\n */\nfunction bridgeCodecs<T extends Record<string, unknown>>(codecs: {\n [K in keyof T]: SearchParamCodec<T[K]>;\n}) {\n const result: Record<string, MultiParser<unknown> & { defaultValue: unknown }> = {};\n for (const key of Object.keys(codecs)) {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n result[key] = bridgeCodec(codecs[key as keyof T]) as any;\n }\n return result as { [K in keyof T]: MultiParser<T[K]> & { defaultValue: T[K] } };\n}\n\n// ─── Hook ─────────────────────────────────────────────────────────\n\n/**\n * Read and write typed search params from/to the URL.\n *\n * Delegates to nuqs internally. The timber nuqs adapter (auto-injected in\n * browser-entry.ts) handles RSC navigation on non-shallow updates.\n *\n * Usage:\n * ```ts\n * // Via a SearchParamsDefinition imported from the route's params.ts\n * const [params, setParams] = definition.useQueryStates()\n *\n * // Standalone with inline codecs\n * const [params, setParams] = useQueryStates({\n * page: fromSchema(z.coerce.number().int().min(1).default(1)),\n * })\n * ```\n *\n * There is deliberately no route-string form (`useQueryStates('/products')`).\n * Importing the definition from `params.ts` is the documented way to reach\n * another route's codecs — it needs no runtime registry lookup and so has no\n * \"not registered yet\" failure mode. See design/23-search-params.md\n * §\"Client Access\".\n */\nexport function useQueryStates<T extends Record<string, unknown>>(\n codecs: { [K in keyof T]: SearchParamCodec<T[K]> },\n _options?: QueryStatesOptions,\n urlKeys?: Readonly<Record<string, string>>\n): [T, SetParams<T>] {\n const bridged = bridgeCodecs(codecs);\n\n // Forward hook-level options (shallow, scroll, history) to nuqs.\n // These become the default for all setter calls from this hook instance.\n // Per-call options in setParams(values, opts) override these defaults.\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const nuqsOptions: any = {};\n if (_options?.shallow !== undefined) nuqsOptions.shallow = _options.shallow;\n if (_options?.scroll !== undefined) nuqsOptions.scroll = _options.scroll;\n if (_options?.history !== undefined) nuqsOptions.history = _options.history;\n // `withUrlKey` attaches the alias to the codec itself — that is the design's\n // \"URL keys travel with codecs\" principle — so the aliases are derivable\n // here and must be, for the inline codec-map form: nobody passes `urlKeys`\n // on that path, and without this an aliased bundle silently read and wrote\n // the property name instead of the alias. `bindUseQueryStates` still passes\n // the definition's precomputed map, which wins on conflict; it is built from\n // these same codecs, so the two agree by construction rather than by luck.\n const resolvedUrlKeys = { ...deriveUrlKeys(codecs), ...urlKeys };\n if (Object.keys(resolvedUrlKeys).length > 0) {\n nuqsOptions.urlKeys = resolvedUrlKeys;\n }\n\n let values: Record<string, unknown>;\n let setValues: Function;\n try {\n [values, setValues] = nuqsUseQueryStates(bridged, nuqsOptions);\n } catch (err) {\n if (\n err instanceof Error &&\n /Invalid hook call|cannot be called|Cannot read properties of null/i.test(err.message)\n ) {\n throw new Error(\n 'useQueryStates is a client component hook and cannot be called outside a React component. ' +\n 'Use definition.parse(searchParams) in server components instead.'\n );\n }\n throw err;\n }\n\n // Unwrap the null/undefined sentinels the bridge injected (see Codec\n // Bridge above) so callers see exactly what server-side parse() returns.\n // Copy-on-write preserves the identity of nuqs's memoized values object\n // when nothing needs unwrapping.\n let normalized = values;\n for (const key of Object.keys(bridged)) {\n const value = normalized[key];\n if (value === NULL_SENTINEL || value === UNDEFINED_SENTINEL) {\n if (normalized === values) normalized = { ...values };\n normalized[key] = unwrapNuqsValue(value);\n }\n }\n\n // Wrap the nuqs setter to match timber's SetParams<T> signature.\n // nuqs's setter accepts Partial<Nullable<Values>> | UpdaterFn | null.\n // timber's setter accepts Partial<T> with optional SetParamsOptions.\n const setParams: SetParams<T> = (partial, setOptions?) => {\n const nuqsSetOptions: Record<string, unknown> = {};\n if (setOptions?.shallow !== undefined) nuqsSetOptions.shallow = setOptions.shallow;\n if (setOptions?.scroll !== undefined) nuqsSetOptions.scroll = setOptions.scroll;\n if (setOptions?.history !== undefined) nuqsSetOptions.history = setOptions.history;\n // nuqs's update loop skips undefined entries and treats null as a\n // key deletion before serialize runs. Timber semantics:\n // - setParams({ q: undefined }) must clear ?q= (absent = undefined),\n // so explicit undefined maps to a null deletion.\n // - setParams({ q: null }) clears the key only when the codec encodes\n // null as \"omit\" (serialize(null) === null). If the codec encodes\n // null as a real query value, forward the sentinel so the bridged\n // serialize writes it — matching definition.serialize({ q: null }).\n let forwarded: Record<string, unknown> = partial;\n for (const key of Object.keys(partial)) {\n const value = partial[key as keyof T];\n if (value === undefined) {\n if (forwarded === partial) forwarded = { ...partial };\n forwarded[key] = null;\n } else if (value === null) {\n let encoded: string | null = null;\n try {\n encoded = codecs[key as keyof T]?.serialize(null as T[keyof T]) ?? null;\n } catch {\n // Codec can't serialize null — treat as a deletion.\n }\n if (encoded !== null) {\n if (forwarded === partial) forwarded = { ...partial };\n forwarded[key] = NULL_SENTINEL;\n }\n }\n }\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n void setValues(forwarded as any, nuqsSetOptions);\n };\n\n return [normalized as T, setParams];\n}\n\n// ─── Definition binding ───────────────────────────────────────────\n\n/**\n * Create a useQueryStates binding for a SearchParamsDefinition.\n * This is used internally by SearchParamsDefinition.useQueryStates().\n */\nexport function bindUseQueryStates<T extends Record<string, unknown>>(\n definition: SearchParamsDefinition<T>\n): (options?: QueryStatesOptions) => [T, SetParams<T>] {\n return (options?: QueryStatesOptions) => {\n return useQueryStates<T>(definition.codecs, options, definition.urlKeys);\n };\n}\n"],"mappings":"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"}
|