@rangojs/router 0.1.1 → 0.2.0
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/README.md +2 -1
- package/dist/types/cache/document-cache.d.ts +3 -1
- package/dist/types/cache/types.d.ts +4 -3
- package/dist/types/route-definition/helpers-types.d.ts +6 -6
- package/dist/types/router/match-handlers.d.ts +2 -3
- package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
- package/dist/types/router/transition-when.d.ts +13 -0
- package/dist/types/rsc/shell-serve.d.ts +2 -0
- package/dist/types/rsc/transition-gate.d.ts +10 -14
- package/dist/types/server/request-context.d.ts +5 -1
- package/dist/types/testing/e2e/index.d.ts +1 -1
- package/dist/types/testing/index.d.ts +2 -2
- package/dist/types/testing/run-transition-when.d.ts +6 -5
- package/dist/types/testing/shell-status.d.ts +23 -3
- package/dist/types/types/segments.d.ts +27 -22
- package/dist/types/urls/path-helper-types.d.ts +4 -3
- package/dist/vite/index.js +1 -1
- package/package.json +20 -21
- package/skills/cache-guide/SKILL.md +6 -3
- package/skills/catalog.json +7 -1
- package/skills/deployment-caching/SKILL.md +176 -0
- package/skills/document-cache/SKILL.md +30 -3
- package/skills/ppr/SKILL.md +57 -25
- package/skills/prerender/SKILL.md +15 -8
- package/skills/rango/SKILL.md +20 -17
- package/skills/testing/SKILL.md +1 -1
- package/skills/testing/cache-prerender.md +5 -1
- package/skills/vercel/SKILL.md +22 -1
- package/skills/view-transitions/SKILL.md +12 -8
- package/src/cache/cache-scope.ts +6 -1
- package/src/cache/document-cache.ts +4 -2
- package/src/cache/types.ts +4 -3
- package/src/route-definition/helpers-types.ts +6 -6
- package/src/router/match-handlers.ts +44 -6
- package/src/router/match-middleware/cache-lookup.ts +10 -3
- package/src/router/segment-resolution/view-transition-default.ts +9 -5
- package/src/router/transition-when.ts +76 -0
- package/src/rsc/progressive-enhancement.ts +9 -14
- package/src/rsc/rsc-rendering.ts +142 -31
- package/src/rsc/shell-serve.ts +3 -0
- package/src/rsc/transition-gate.ts +37 -40
- package/src/server/request-context.ts +6 -0
- package/src/testing/e2e/index.ts +5 -0
- package/src/testing/index.ts +9 -1
- package/src/testing/run-transition-when.ts +42 -9
- package/src/testing/shell-status.ts +92 -3
- package/src/types/segments.ts +27 -22
- package/src/urls/path-helper-types.ts +4 -3
package/src/rsc/rsc-rendering.ts
CHANGED
|
@@ -40,6 +40,7 @@ import {
|
|
|
40
40
|
type ShellCaptureDescriptor,
|
|
41
41
|
} from "./shell-capture.js";
|
|
42
42
|
import {
|
|
43
|
+
PPR_REPLAY_STATUS_HEADER,
|
|
43
44
|
SHELL_STATUS_HEADER,
|
|
44
45
|
resolvePprConfig,
|
|
45
46
|
buildShellKey,
|
|
@@ -69,6 +70,42 @@ import { reportCacheError } from "../cache/cache-error.js";
|
|
|
69
70
|
import { INTERNAL_RANGO_DEBUG } from "../internal-debug.js";
|
|
70
71
|
import type { ShellCacheEntry, ShellSnapshotRecord } from "../cache/types.js";
|
|
71
72
|
|
|
73
|
+
type PprReplayBypassReason =
|
|
74
|
+
| "method"
|
|
75
|
+
| "dynamic"
|
|
76
|
+
| "nonce"
|
|
77
|
+
| "store-unavailable"
|
|
78
|
+
| "passive-read-unsupported"
|
|
79
|
+
| "read-error"
|
|
80
|
+
| "no-entry"
|
|
81
|
+
| "invalid-version"
|
|
82
|
+
| "corrupt-entry"
|
|
83
|
+
| "handler-live-holes"
|
|
84
|
+
| "transition-when"
|
|
85
|
+
| "no-segment-snapshot"
|
|
86
|
+
| "snapshot-miss"
|
|
87
|
+
| "stale-build-entry";
|
|
88
|
+
|
|
89
|
+
type PprReplayStatus =
|
|
90
|
+
| { outcome: "HIT"; freshness: "fresh" | "stale" }
|
|
91
|
+
| { outcome: "BYPASS"; reason: PprReplayBypassReason };
|
|
92
|
+
|
|
93
|
+
type ShellReplayDecision =
|
|
94
|
+
| { snapshot: ShellSnapshotRecord[] }
|
|
95
|
+
| { reason: PprReplayBypassReason };
|
|
96
|
+
|
|
97
|
+
function serializePprReplayStatus(status: PprReplayStatus): string {
|
|
98
|
+
return status.outcome === "HIT"
|
|
99
|
+
? `HIT; freshness=${status.freshness}`
|
|
100
|
+
: `BYPASS; reason=${status.reason}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function describePprReplayStatus(status: PprReplayStatus): string {
|
|
104
|
+
return status.outcome === "HIT"
|
|
105
|
+
? status.freshness
|
|
106
|
+
: `bypass:${status.reason}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
72
109
|
function resolveDevShellLookup(
|
|
73
110
|
reqCtx: RequestContext<any>,
|
|
74
111
|
pprConfig: ResolvedPprConfig,
|
|
@@ -86,17 +123,15 @@ function resolveDevShellLookup(
|
|
|
86
123
|
}
|
|
87
124
|
|
|
88
125
|
function replayableShellSnapshot(
|
|
89
|
-
entry: ShellCacheEntry
|
|
126
|
+
entry: ShellCacheEntry,
|
|
90
127
|
buildVersion: string,
|
|
91
|
-
):
|
|
92
|
-
if (
|
|
93
|
-
|
|
94
|
-
!isValidShellHit(entry, buildVersion) ||
|
|
95
|
-
entry.handlerLiveHoles ||
|
|
96
|
-
entry.transitionWhen
|
|
97
|
-
) {
|
|
98
|
-
return undefined;
|
|
128
|
+
): ShellReplayDecision {
|
|
129
|
+
if (!isValidShellHit(entry, buildVersion)) {
|
|
130
|
+
return { reason: "invalid-version" };
|
|
99
131
|
}
|
|
132
|
+
if (!hasIntactShellPayload(entry)) return { reason: "corrupt-entry" };
|
|
133
|
+
if (entry.handlerLiveHoles) return { reason: "handler-live-holes" };
|
|
134
|
+
if (entry.transitionWhen) return { reason: "transition-when" };
|
|
100
135
|
const snapshot = entry.snapshot;
|
|
101
136
|
const hasSegments = snapshot?.some((record) => {
|
|
102
137
|
if (
|
|
@@ -111,7 +146,9 @@ function replayableShellSnapshot(
|
|
|
111
146
|
const segments = (record.value as { segments?: unknown }).segments;
|
|
112
147
|
return Array.isArray(segments) && segments.length > 0;
|
|
113
148
|
});
|
|
114
|
-
return hasSegments
|
|
149
|
+
return hasSegments && snapshot
|
|
150
|
+
? { snapshot }
|
|
151
|
+
: { reason: "no-segment-snapshot" };
|
|
115
152
|
}
|
|
116
153
|
|
|
117
154
|
export function handleRscRendering<TEnv>(
|
|
@@ -156,6 +193,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
156
193
|
|
|
157
194
|
let payload: RscPayload;
|
|
158
195
|
let hasInterceptSlots = false;
|
|
196
|
+
let pprReplayStatus: PprReplayStatus | undefined;
|
|
159
197
|
|
|
160
198
|
// --- Axis 2: integrated PPR shell serve (docs/design/ppr-shell-resume.md) ---
|
|
161
199
|
//
|
|
@@ -359,7 +397,7 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
359
397
|
|
|
360
398
|
if (isPartial) {
|
|
361
399
|
// Partial render (navigation)
|
|
362
|
-
const
|
|
400
|
+
const replay = await matchPartialWithPprReplay(
|
|
363
401
|
ctx,
|
|
364
402
|
request,
|
|
365
403
|
env,
|
|
@@ -367,6 +405,8 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
367
405
|
reqCtx,
|
|
368
406
|
nonce,
|
|
369
407
|
);
|
|
408
|
+
const result = replay.result;
|
|
409
|
+
pprReplayStatus = replay.status;
|
|
370
410
|
|
|
371
411
|
if (!result) {
|
|
372
412
|
// Fall back to full render
|
|
@@ -378,10 +418,17 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
378
418
|
// perform SPA navigation. A raw 308 would be auto-followed by
|
|
379
419
|
// fetch, hitting the target without _rsc_partial. Resolve the
|
|
380
420
|
// target server-side (same open-redirect policy as 3xx).
|
|
381
|
-
|
|
421
|
+
const redirectResponse = createSimpleRedirectResponse(match.redirect, {
|
|
382
422
|
requestOrigin: url.origin,
|
|
383
423
|
basename: ctx.router.basename,
|
|
384
424
|
});
|
|
425
|
+
if (pprReplayStatus) {
|
|
426
|
+
redirectResponse.headers.set(
|
|
427
|
+
PPR_REPLAY_STATUS_HEADER,
|
|
428
|
+
serializePprReplayStatus(pprReplayStatus),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
return redirectResponse;
|
|
385
432
|
}
|
|
386
433
|
|
|
387
434
|
payload = buildFullPayload(match, ctx, url, reqCtx, handleStore);
|
|
@@ -484,6 +531,10 @@ async function handleRscRenderingInner<TEnv>(
|
|
|
484
531
|
// are deliberately NOT stamped. See browser/response-adapter.ts.
|
|
485
532
|
"X-RSC-Router-Id": ctx.router.id,
|
|
486
533
|
};
|
|
534
|
+
if (pprReplayStatus) {
|
|
535
|
+
rscHeaders[PPR_REPLAY_STATUS_HEADER] =
|
|
536
|
+
serializePprReplayStatus(pprReplayStatus);
|
|
537
|
+
}
|
|
487
538
|
// Tell the client's prefetch cache to scope this response to its source
|
|
488
539
|
// URL (instead of the default source-agnostic wildcard). Intercept
|
|
489
540
|
// responses depend on the source page matching an intercept rule, so
|
|
@@ -575,20 +626,45 @@ async function matchPartialWithPprReplay<TEnv>(
|
|
|
575
626
|
reqCtx: RequestContext<any>,
|
|
576
627
|
nonce: string | undefined,
|
|
577
628
|
) {
|
|
578
|
-
const
|
|
629
|
+
const replayStart = reqCtx._metricsStore ? performance.now() : 0;
|
|
630
|
+
const recordReplayStatus = (status: PprReplayStatus): void => {
|
|
631
|
+
if (!reqCtx._metricsStore) return;
|
|
632
|
+
appendMetric(
|
|
633
|
+
reqCtx._metricsStore,
|
|
634
|
+
"ppr:navigation-replay",
|
|
635
|
+
replayStart,
|
|
636
|
+
performance.now() - replayStart,
|
|
637
|
+
undefined,
|
|
638
|
+
describePprReplayStatus(status),
|
|
639
|
+
);
|
|
640
|
+
};
|
|
641
|
+
const runMatch = async (status?: PprReplayStatus) => {
|
|
642
|
+
const result = await ctx.router.matchPartial(request, { env });
|
|
643
|
+
if (status) recordReplayStatus(status);
|
|
644
|
+
return { result, status };
|
|
645
|
+
};
|
|
579
646
|
const pprConfig = resolvePprConfig(reqCtx._classifiedRoute?.manifestEntry);
|
|
580
647
|
const activeNonce = nonce ?? contextGet(reqCtx._variables, nonceToken);
|
|
581
648
|
const store = reqCtx._cacheStore;
|
|
582
649
|
|
|
583
|
-
if (
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
) {
|
|
591
|
-
return runMatch();
|
|
650
|
+
if (!pprConfig) return runMatch();
|
|
651
|
+
if (request.method !== "GET") {
|
|
652
|
+
return runMatch({ outcome: "BYPASS", reason: "method" });
|
|
653
|
+
}
|
|
654
|
+
if (reqCtx._dynamic) {
|
|
655
|
+
return runMatch({ outcome: "BYPASS", reason: "dynamic" });
|
|
656
|
+
}
|
|
657
|
+
if (activeNonce !== undefined) {
|
|
658
|
+
return runMatch({ outcome: "BYPASS", reason: "nonce" });
|
|
659
|
+
}
|
|
660
|
+
if (!hasShellFamily(store)) {
|
|
661
|
+
return runMatch({ outcome: "BYPASS", reason: "store-unavailable" });
|
|
662
|
+
}
|
|
663
|
+
if (store.supportsPassiveShellReads !== true) {
|
|
664
|
+
return runMatch({
|
|
665
|
+
outcome: "BYPASS",
|
|
666
|
+
reason: "passive-read-unsupported",
|
|
667
|
+
});
|
|
592
668
|
}
|
|
593
669
|
|
|
594
670
|
const key = buildShellKey(url);
|
|
@@ -597,35 +673,70 @@ async function matchPartialWithPprReplay<TEnv>(
|
|
|
597
673
|
cached = await store.getShell(key, { claimRevalidation: false });
|
|
598
674
|
} catch (error) {
|
|
599
675
|
reportCacheError(error, "cache-read", "[NavigationPPR] getShell");
|
|
600
|
-
return runMatch();
|
|
676
|
+
return runMatch({ outcome: "BYPASS", reason: "read-error" });
|
|
601
677
|
}
|
|
602
678
|
|
|
603
|
-
let
|
|
604
|
-
|
|
605
|
-
|
|
679
|
+
let bypassReason: PprReplayBypassReason | undefined;
|
|
680
|
+
let snapshot: ShellSnapshotRecord[] | undefined;
|
|
681
|
+
let freshness: "fresh" | "stale" = "fresh";
|
|
682
|
+
if (cached) {
|
|
683
|
+
const decision = replayableShellSnapshot(cached.entry, ctx.version);
|
|
684
|
+
if ("snapshot" in decision) {
|
|
685
|
+
snapshot = decision.snapshot;
|
|
686
|
+
freshness = cached.shouldRevalidate ? "stale" : "fresh";
|
|
687
|
+
} else {
|
|
688
|
+
bypassReason = decision.reason;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
606
691
|
|
|
607
692
|
if (!snapshot) {
|
|
608
693
|
// Production build manifests are local module data. In dev, resolving a
|
|
609
694
|
// missing build shell would foreground-fetch /__rsc_shell and block an
|
|
610
695
|
// otherwise ordinary navigation on capture, so replay remains runtime-only.
|
|
611
696
|
const buildHit = await lookupBuildShell(url, ctx.version, store);
|
|
612
|
-
if (
|
|
613
|
-
|
|
697
|
+
if (buildHit?.stale) {
|
|
698
|
+
bypassReason ??= "stale-build-entry";
|
|
699
|
+
} else if (buildHit) {
|
|
700
|
+
const buildDecision = replayableShellSnapshot(
|
|
701
|
+
buildHit.entry,
|
|
702
|
+
ctx.version,
|
|
703
|
+
);
|
|
704
|
+
if ("snapshot" in buildDecision) {
|
|
705
|
+
snapshot = buildDecision.snapshot;
|
|
706
|
+
} else {
|
|
707
|
+
bypassReason ??= buildDecision.reason;
|
|
708
|
+
}
|
|
614
709
|
}
|
|
615
710
|
}
|
|
616
711
|
|
|
617
|
-
if (!snapshot)
|
|
712
|
+
if (!snapshot) {
|
|
713
|
+
return runMatch({
|
|
714
|
+
outcome: "BYPASS",
|
|
715
|
+
reason: bypassReason ?? "no-entry",
|
|
716
|
+
});
|
|
717
|
+
}
|
|
618
718
|
|
|
619
719
|
const previousImplicitCache = reqCtx._shellImplicitCache;
|
|
720
|
+
let segmentReplayHit = false;
|
|
620
721
|
reqCtx._shellImplicitCache = {
|
|
621
722
|
ttl: pprConfig.ttl,
|
|
622
723
|
swr: pprConfig.swr,
|
|
623
|
-
store: new SeededShellStore(store, snapshot, {
|
|
724
|
+
store: new SeededShellStore(store, snapshot, {
|
|
725
|
+
segmentsOnly: true,
|
|
726
|
+
}),
|
|
624
727
|
keyPrefix: "doc",
|
|
728
|
+
onHit: () => {
|
|
729
|
+
segmentReplayHit = true;
|
|
730
|
+
},
|
|
625
731
|
};
|
|
626
732
|
|
|
627
733
|
try {
|
|
628
|
-
|
|
734
|
+
const result = await ctx.router.matchPartial(request, { env });
|
|
735
|
+
const status: PprReplayStatus = segmentReplayHit
|
|
736
|
+
? { outcome: "HIT", freshness }
|
|
737
|
+
: { outcome: "BYPASS", reason: "snapshot-miss" };
|
|
738
|
+
recordReplayStatus(status);
|
|
739
|
+
return { result, status };
|
|
629
740
|
} finally {
|
|
630
741
|
reqCtx._shellImplicitCache = previousImplicitCache;
|
|
631
742
|
}
|
package/src/rsc/shell-serve.ts
CHANGED
|
@@ -24,6 +24,9 @@ import type { ShellCacheEntry, SegmentCacheStore } from "../cache/types.js";
|
|
|
24
24
|
/** Debug/status header the browser (and e2e assertions) can read: HIT | MISS. */
|
|
25
25
|
export const SHELL_STATUS_HEADER = "x-rango-shell";
|
|
26
26
|
|
|
27
|
+
/** Partial-navigation status header set only after captured PPR segments are consumed. */
|
|
28
|
+
export const PPR_REPLAY_STATUS_HEADER = "x-rango-ppr-replay";
|
|
29
|
+
|
|
27
30
|
/**
|
|
28
31
|
* Default shell ttl (seconds) for `ppr: true` and for a PartialPrerenderProps
|
|
29
32
|
* that omits `ttl`.
|
|
@@ -1,41 +1,58 @@
|
|
|
1
1
|
import type { MatchResult } from "../types.js";
|
|
2
|
-
import type { TransitionWhenContext } from "../types/segments.js";
|
|
3
2
|
import type { getRequestContext } from "../server/request-context.js";
|
|
4
3
|
import { invokeOnError } from "../router/error-handling.js";
|
|
5
4
|
import type { OnErrorCallback } from "../types/error-types.js";
|
|
5
|
+
import { createTransitionWhenContext } from "../router/transition-when.js";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Apply transition({ when }) gates to a payload's segments.
|
|
9
9
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* to the router's onError (phase "rendering") and then treated as "do not hold"
|
|
16
|
-
* (conservative), so a buggy predicate degrades to no transition rather than
|
|
17
|
-
* failing the response.
|
|
10
|
+
* PPR predicates were evaluated from the manifest before cache lookup and route
|
|
11
|
+
* handlers; their request-specific decisions are projected onto a copy so they
|
|
12
|
+
* never mutate reusable cache/prerender/shell records. Ordinary-route predicates
|
|
13
|
+
* were collected during fresh resolution and are evaluated here after handlers.
|
|
14
|
+
* Both paths drop the segment transition when the predicate does not hold.
|
|
18
15
|
*
|
|
19
16
|
* Mutating the segments here is safe: the segment cache stores a serialized copy
|
|
20
17
|
* (segment-codec), written during match() BEFORE this gate runs, so dropping a
|
|
21
|
-
* transition never corrupts a cache entry.
|
|
18
|
+
* transition never corrupts a cache entry. On an ordinary route, a cache hit
|
|
22
19
|
* skips resolution, collects no predicate, and replays the cached transition
|
|
23
|
-
* as-is (it was serialized before the gate)
|
|
24
|
-
*
|
|
25
|
-
* avoid caching a route whose transition decision is request-dependent.
|
|
20
|
+
* as-is (it was serialized before the gate). PPR routes are the exception: their
|
|
21
|
+
* manifest predicates were evaluated before lookup and apply to replayed data.
|
|
26
22
|
*
|
|
27
|
-
* Returns the same array
|
|
28
|
-
*
|
|
23
|
+
* Returns the same array for the ordinary post-handler path. A PPR drop returns a
|
|
24
|
+
* copy containing only the request-specific transition changes.
|
|
29
25
|
*/
|
|
30
26
|
export function gateTransitions(
|
|
31
27
|
segments: MatchResult["segments"],
|
|
32
28
|
ctx: ReturnType<typeof getRequestContext>,
|
|
33
29
|
onError?: OnErrorCallback,
|
|
34
30
|
): MatchResult["segments"] {
|
|
31
|
+
const pprDecisions = ctx._pprTransitionDecisions;
|
|
32
|
+
let gatedSegments = segments;
|
|
33
|
+
|
|
34
|
+
const dropTransition = (id: string): void => {
|
|
35
|
+
const index = gatedSegments.findIndex((segment) => segment.id === id);
|
|
36
|
+
if (index === -1) return;
|
|
37
|
+
if (!pprDecisions) {
|
|
38
|
+
gatedSegments[index].transition = undefined;
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (gatedSegments === segments) gatedSegments = [...segments];
|
|
42
|
+
gatedSegments[index] = {
|
|
43
|
+
...gatedSegments[index],
|
|
44
|
+
transition: undefined,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (pprDecisions) {
|
|
49
|
+
for (const [id, keep] of pprDecisions) {
|
|
50
|
+
if (!keep) dropTransition(id);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
35
53
|
const predicates = ctx._transitionWhen;
|
|
36
|
-
if (predicates
|
|
54
|
+
if (predicates) {
|
|
37
55
|
for (const { id, when } of predicates) {
|
|
38
|
-
let drop: boolean;
|
|
39
56
|
try {
|
|
40
57
|
// Assemble the ShouldRevalidateFn-shaped predicate context from the
|
|
41
58
|
// request context. Source fields (currentUrl/currentParams/fromRouteName)
|
|
@@ -44,28 +61,11 @@ export function gateTransitions(
|
|
|
44
61
|
// method/get/env come straight off ctx (setRequestContextParams ran
|
|
45
62
|
// before the gate). Source/action fields are undefined when absent —
|
|
46
63
|
// never fabricated (see TransitionWhenContext).
|
|
47
|
-
|
|
48
|
-
currentUrl: ctx._gateCurrentUrl,
|
|
49
|
-
currentParams: ctx._gateCurrentParams,
|
|
50
|
-
fromRouteName:
|
|
51
|
-
ctx._prevRouteKey as TransitionWhenContext["fromRouteName"],
|
|
52
|
-
nextUrl: ctx.url,
|
|
53
|
-
nextParams: ctx.params,
|
|
54
|
-
toRouteName: ctx.routeName,
|
|
55
|
-
actionId: ctx._gateActionId,
|
|
56
|
-
actionUrl: ctx._gateActionUrl,
|
|
57
|
-
actionResult: ctx._gateActionResult,
|
|
58
|
-
formData: ctx._gateFormData,
|
|
59
|
-
method: ctx.request.method,
|
|
60
|
-
get: ctx.get,
|
|
61
|
-
env: ctx.env,
|
|
62
|
-
};
|
|
63
|
-
drop = when(whenCtx) === false;
|
|
64
|
+
if (when(createTransitionWhenContext(ctx)) !== false) continue;
|
|
64
65
|
} catch (error) {
|
|
65
66
|
// A throwing predicate must not fail the response: report it and treat
|
|
66
67
|
// the transition as gated off (do not hold). invokeOnError no-ops when
|
|
67
68
|
// onError is undefined.
|
|
68
|
-
drop = true;
|
|
69
69
|
invokeOnError(
|
|
70
70
|
onError,
|
|
71
71
|
error,
|
|
@@ -79,11 +79,8 @@ export function gateTransitions(
|
|
|
79
79
|
"RSC",
|
|
80
80
|
);
|
|
81
81
|
}
|
|
82
|
-
|
|
83
|
-
const seg = segments.find((s) => s.id === id);
|
|
84
|
-
if (seg) seg.transition = undefined;
|
|
85
|
-
}
|
|
82
|
+
dropTransition(id);
|
|
86
83
|
}
|
|
87
84
|
}
|
|
88
|
-
return
|
|
85
|
+
return gatedSegments;
|
|
89
86
|
}
|
|
@@ -208,6 +208,9 @@ export interface RequestContext<
|
|
|
208
208
|
*/
|
|
209
209
|
_transitionWhen?: Array<{ id: string; when: TransitionWhenFn }>;
|
|
210
210
|
|
|
211
|
+
/** @internal PPR transition decisions evaluated before cache lookup/handlers. */
|
|
212
|
+
_pprTransitionDecisions?: Map<string, boolean>;
|
|
213
|
+
|
|
211
214
|
/** @internal Cache store for segment caching (optional, used by CacheScope) */
|
|
212
215
|
_cacheStore?: SegmentCacheStore;
|
|
213
216
|
|
|
@@ -276,6 +279,8 @@ export interface RequestContext<
|
|
|
276
279
|
* segment record even when the triggering request is partial.
|
|
277
280
|
*/
|
|
278
281
|
keyPrefix?: "doc";
|
|
282
|
+
/** @internal Called only after the implicit cache hit decodes successfully. */
|
|
283
|
+
onHit?: () => void;
|
|
279
284
|
};
|
|
280
285
|
|
|
281
286
|
/**
|
|
@@ -650,6 +655,7 @@ export type PublicRequestContext<
|
|
|
650
655
|
| "deleteCookie"
|
|
651
656
|
| "_handleStore"
|
|
652
657
|
| "_transitionWhen"
|
|
658
|
+
| "_pprTransitionDecisions"
|
|
653
659
|
| "_cacheStore"
|
|
654
660
|
| "_shellCaptureRun"
|
|
655
661
|
| "_shellImplicitCache"
|
package/src/testing/e2e/index.ts
CHANGED
|
@@ -41,10 +41,15 @@ export {
|
|
|
41
41
|
type CacheStatusTarget,
|
|
42
42
|
} from "../cache-status.js";
|
|
43
43
|
export {
|
|
44
|
+
assertPprReplayStatus,
|
|
44
45
|
assertShellStatus,
|
|
46
|
+
parsePprReplayStatus,
|
|
45
47
|
parseShellStatus,
|
|
48
|
+
PPR_REPLAY_STATUS_HEADER,
|
|
46
49
|
shellCacheKey,
|
|
47
50
|
SHELL_STATUS_HEADER,
|
|
51
|
+
type PprReplayBypassReason,
|
|
52
|
+
type PprReplayStatus,
|
|
48
53
|
type ShellStatus,
|
|
49
54
|
type ShellStatusTarget,
|
|
50
55
|
} from "../shell-status.js";
|
package/src/testing/index.ts
CHANGED
|
@@ -77,12 +77,20 @@ export type {
|
|
|
77
77
|
} from "../router/telemetry.js";
|
|
78
78
|
|
|
79
79
|
export {
|
|
80
|
+
assertPprReplayStatus,
|
|
80
81
|
assertShellStatus,
|
|
82
|
+
parsePprReplayStatus,
|
|
81
83
|
parseShellStatus,
|
|
84
|
+
PPR_REPLAY_STATUS_HEADER,
|
|
82
85
|
shellCacheKey,
|
|
83
86
|
SHELL_STATUS_HEADER,
|
|
84
87
|
} from "./shell-status.js";
|
|
85
|
-
export type {
|
|
88
|
+
export type {
|
|
89
|
+
PprReplayBypassReason,
|
|
90
|
+
PprReplayStatus,
|
|
91
|
+
ShellStatus,
|
|
92
|
+
ShellStatusTarget,
|
|
93
|
+
} from "./shell-status.js";
|
|
86
94
|
|
|
87
95
|
export { collectHandle } from "./collect-handle.js";
|
|
88
96
|
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* runTransitionWhen — unit-test a transition({ when }) predicate in isolation.
|
|
3
3
|
*
|
|
4
|
-
* Runs the SAME
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* TransitionWhenContext and evaluates the predicate post-handler). So the
|
|
4
|
+
* Runs the SAME server functions the router uses — the PPR pre-handler evaluator
|
|
5
|
+
* when `ppr: true`, applyViewTransitionDefault (which strips the server-only
|
|
6
|
+
* function), and gateTransitions. So the
|
|
8
7
|
* predicate sees exactly the navigation/action metadata it would at runtime
|
|
9
8
|
* (currentUrl/currentParams/fromRouteName, nextUrl/nextParams/toRouteName,
|
|
10
9
|
* actionId/actionUrl/actionResult/formData/method, get/env), and `kept` reflects
|
|
@@ -33,6 +32,9 @@ import type {
|
|
|
33
32
|
TransitionWhenContext,
|
|
34
33
|
} from "../types/segments.js";
|
|
35
34
|
import type { OnErrorCallback } from "../types/error-types.js";
|
|
35
|
+
import type { EntryData } from "../server/context.js";
|
|
36
|
+
import { evaluatePprTransitionWhen } from "../router/transition-when.js";
|
|
37
|
+
import { invokeOnError } from "../router/error-handling.js";
|
|
36
38
|
|
|
37
39
|
const toURL = (v: string | URL, base: URL): URL =>
|
|
38
40
|
typeof v === "string" ? new URL(v, base.origin) : v;
|
|
@@ -53,7 +55,7 @@ export interface RunTransitionWhenOptions<TEnv = any> {
|
|
|
53
55
|
toRouteName?: string;
|
|
54
56
|
/** Environment bindings surfaced as `env` (and `ctx.env`). */
|
|
55
57
|
env?: TEnv;
|
|
56
|
-
/** Variables
|
|
58
|
+
/** Variables readable via the predicate's `get()`. With `ppr`, these model pre-handler middleware/input state. */
|
|
57
59
|
vars?: VarsInit;
|
|
58
60
|
/** Navigation SOURCE url (`currentUrl`): a URL or path string. */
|
|
59
61
|
currentUrl?: string | URL;
|
|
@@ -71,6 +73,8 @@ export interface RunTransitionWhenOptions<TEnv = any> {
|
|
|
71
73
|
formData?: FormData;
|
|
72
74
|
/** Receives an error thrown by the predicate (the gate reports to `router.onError`, phase `"rendering"`). */
|
|
73
75
|
onError?: OnErrorCallback;
|
|
76
|
+
/** Model a route with `ppr`, where the predicate runs before route handlers and cache lookup. */
|
|
77
|
+
ppr?: boolean;
|
|
74
78
|
}
|
|
75
79
|
|
|
76
80
|
/**
|
|
@@ -138,8 +142,37 @@ export function runTransitionWhen<TEnv = any>(
|
|
|
138
142
|
: config;
|
|
139
143
|
|
|
140
144
|
return runWithRequestContext(reqCtx, () => {
|
|
141
|
-
|
|
142
|
-
|
|
145
|
+
if (opts.ppr) {
|
|
146
|
+
const entry = {
|
|
147
|
+
type: "route",
|
|
148
|
+
id: "tx-when-entry",
|
|
149
|
+
shortCode: "tx-when-seg",
|
|
150
|
+
parent: null,
|
|
151
|
+
transition: configForGate,
|
|
152
|
+
layout: [],
|
|
153
|
+
parallel: {},
|
|
154
|
+
} as unknown as EntryData;
|
|
155
|
+
evaluatePprTransitionWhen(
|
|
156
|
+
[entry],
|
|
157
|
+
reqCtx,
|
|
158
|
+
{ params: reqCtx.params, routeName: opts.toRouteName },
|
|
159
|
+
(error, segmentId) =>
|
|
160
|
+
invokeOnError(
|
|
161
|
+
opts.onError,
|
|
162
|
+
error,
|
|
163
|
+
"rendering",
|
|
164
|
+
{
|
|
165
|
+
request: reqCtx.request,
|
|
166
|
+
url: reqCtx.url,
|
|
167
|
+
params: reqCtx.params,
|
|
168
|
+
segmentId,
|
|
169
|
+
},
|
|
170
|
+
"RSC",
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
// Ordinary predicates are collected here and evaluated by the gate. PPR
|
|
175
|
+
// predicates keep the decision already evaluated before this step.
|
|
143
176
|
const serialized = applyViewTransitionDefault(
|
|
144
177
|
configForGate,
|
|
145
178
|
undefined,
|
|
@@ -153,12 +186,12 @@ export function runTransitionWhen<TEnv = any>(
|
|
|
153
186
|
component: null,
|
|
154
187
|
transition: serialized,
|
|
155
188
|
} as ResolvedSegment;
|
|
156
|
-
gateTransitions(
|
|
189
|
+
const [gatedSegment] = gateTransitions(
|
|
157
190
|
[segment],
|
|
158
191
|
reqCtx as Parameters<typeof gateTransitions>[1],
|
|
159
192
|
opts.onError,
|
|
160
193
|
);
|
|
161
|
-
const kept =
|
|
194
|
+
const kept = gatedSegment.transition !== undefined;
|
|
162
195
|
return { kept, dropped: !kept, whenContext, ctx: reqCtx };
|
|
163
196
|
});
|
|
164
197
|
}
|