@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.
Files changed (48) hide show
  1. package/README.md +2 -1
  2. package/dist/types/cache/document-cache.d.ts +3 -1
  3. package/dist/types/cache/types.d.ts +4 -3
  4. package/dist/types/route-definition/helpers-types.d.ts +6 -6
  5. package/dist/types/router/match-handlers.d.ts +2 -3
  6. package/dist/types/router/segment-resolution/view-transition-default.d.ts +3 -4
  7. package/dist/types/router/transition-when.d.ts +13 -0
  8. package/dist/types/rsc/shell-serve.d.ts +2 -0
  9. package/dist/types/rsc/transition-gate.d.ts +10 -14
  10. package/dist/types/server/request-context.d.ts +5 -1
  11. package/dist/types/testing/e2e/index.d.ts +1 -1
  12. package/dist/types/testing/index.d.ts +2 -2
  13. package/dist/types/testing/run-transition-when.d.ts +6 -5
  14. package/dist/types/testing/shell-status.d.ts +23 -3
  15. package/dist/types/types/segments.d.ts +27 -22
  16. package/dist/types/urls/path-helper-types.d.ts +4 -3
  17. package/dist/vite/index.js +1 -1
  18. package/package.json +20 -21
  19. package/skills/cache-guide/SKILL.md +6 -3
  20. package/skills/catalog.json +7 -1
  21. package/skills/deployment-caching/SKILL.md +176 -0
  22. package/skills/document-cache/SKILL.md +30 -3
  23. package/skills/ppr/SKILL.md +57 -25
  24. package/skills/prerender/SKILL.md +15 -8
  25. package/skills/rango/SKILL.md +20 -17
  26. package/skills/testing/SKILL.md +1 -1
  27. package/skills/testing/cache-prerender.md +5 -1
  28. package/skills/vercel/SKILL.md +22 -1
  29. package/skills/view-transitions/SKILL.md +12 -8
  30. package/src/cache/cache-scope.ts +6 -1
  31. package/src/cache/document-cache.ts +4 -2
  32. package/src/cache/types.ts +4 -3
  33. package/src/route-definition/helpers-types.ts +6 -6
  34. package/src/router/match-handlers.ts +44 -6
  35. package/src/router/match-middleware/cache-lookup.ts +10 -3
  36. package/src/router/segment-resolution/view-transition-default.ts +9 -5
  37. package/src/router/transition-when.ts +76 -0
  38. package/src/rsc/progressive-enhancement.ts +9 -14
  39. package/src/rsc/rsc-rendering.ts +142 -31
  40. package/src/rsc/shell-serve.ts +3 -0
  41. package/src/rsc/transition-gate.ts +37 -40
  42. package/src/server/request-context.ts +6 -0
  43. package/src/testing/e2e/index.ts +5 -0
  44. package/src/testing/index.ts +9 -1
  45. package/src/testing/run-transition-when.ts +42 -9
  46. package/src/testing/shell-status.ts +92 -3
  47. package/src/types/segments.ts +27 -22
  48. package/src/urls/path-helper-types.ts +4 -3
@@ -2,7 +2,8 @@
2
2
  * PPR shell-status testing primitives for @rangojs/router consumers.
3
3
  *
4
4
  * Companion to `cache-status.ts` (segment/document cache): this module covers
5
- * the **shell axis** (`ppr` path option → `x-rango-shell: HIT | MISS`).
5
+ * the **shell axis** (`ppr` path option → `x-rango-shell: HIT | MISS`) and
6
+ * partial-navigation segment replay (`x-rango-ppr-replay: HIT`).
6
7
  *
7
8
  * ## Spike conclusions (plan 009)
8
9
  *
@@ -11,8 +12,9 @@
11
12
  * + `ppr: true` on a response route is a no-op for shell serve/capture. The
12
13
  * production path lives in `rsc/rsc-rendering.ts` + `rsc/shell-capture.ts`.
13
14
  *
14
- * 2. **Smallest HIT signal:** response header `x-rango-shell` (`HIT` | `MISS`),
15
- * always on document GETs to a ppr route that the serve path considered.
15
+ * 2. **Smallest status signals:** `x-rango-shell` (`HIT` | `MISS`) on document
16
+ * GETs, and `x-rango-ppr-replay` on partial requests to ppr routes. A replay
17
+ * HIT is reported only when matching consumes the captured segment record.
16
18
  * Secondary unit signal: `store.getShell(shellCacheKey(url))` after a real
17
19
  * capture flush (background `putShell`). There is no Flight flag for shell HIT.
18
20
  *
@@ -31,9 +33,37 @@ import { sortedSearchString } from "../cache/cache-key-utils.js";
31
33
  /** Production header name (`rsc/shell-serve.ts` `SHELL_STATUS_HEADER`). */
32
34
  export const SHELL_STATUS_HEADER: string = "x-rango-shell";
33
35
 
36
+ /** Partial-navigation replay decision header from `rsc/rsc-rendering.ts`. */
37
+ export const PPR_REPLAY_STATUS_HEADER: string = "x-rango-ppr-replay";
38
+
34
39
  /** Values the serve path writes on `x-rango-shell`. */
35
40
  export type ShellStatus = "HIT" | "MISS";
36
41
 
42
+ const PPR_REPLAY_BYPASS_REASONS = [
43
+ "method",
44
+ "dynamic",
45
+ "nonce",
46
+ "store-unavailable",
47
+ "passive-read-unsupported",
48
+ "read-error",
49
+ "no-entry",
50
+ "invalid-version",
51
+ "corrupt-entry",
52
+ "handler-live-holes",
53
+ "transition-when",
54
+ "no-segment-snapshot",
55
+ "snapshot-miss",
56
+ "stale-build-entry",
57
+ ] as const;
58
+
59
+ /** Bounded reasons a PPR partial request can fall open to ordinary matching. */
60
+ export type PprReplayBypassReason = (typeof PPR_REPLAY_BYPASS_REASONS)[number];
61
+
62
+ /** Parsed `x-rango-ppr-replay` value. */
63
+ export type PprReplayStatus =
64
+ | { outcome: "HIT"; freshness: "fresh" | "stale" }
65
+ | { outcome: "BYPASS"; reason: PprReplayBypassReason };
66
+
37
67
  /** A target carrying response headers (a Response or a `{ headers }` object). */
38
68
  export type ShellStatusTarget = Response | { headers: Headers };
39
69
 
@@ -58,6 +88,8 @@ function getHeaders(target: ShellStatusTarget): Headers {
58
88
  return target.headers;
59
89
  }
60
90
 
91
+ const PPR_REPLAY_BYPASS_REASON_SET = new Set<string>(PPR_REPLAY_BYPASS_REASONS);
92
+
61
93
  /**
62
94
  * Read `x-rango-shell` from a response. Returns `null` when the header is
63
95
  * absent (axis-1 / non-ppr / ineligible request).
@@ -98,3 +130,60 @@ export function assertShellStatus(
98
130
  );
99
131
  }
100
132
  }
133
+
134
+ /** Parse the dedicated PPR partial-navigation replay decision header. */
135
+ export function parsePprReplayStatus(
136
+ target: ShellStatusTarget,
137
+ ): PprReplayStatus | null {
138
+ const raw = getHeaders(target).get(PPR_REPLAY_STATUS_HEADER)?.trim();
139
+ if (!raw) return null;
140
+
141
+ const [outcome, detail, ...extra] = raw.split(";").map((part) => part.trim());
142
+ if (extra.length > 0 || !detail) return null;
143
+
144
+ if (outcome === "HIT") {
145
+ const freshness = detail.match(/^freshness=(fresh|stale)$/)?.[1];
146
+ if (freshness === "fresh" || freshness === "stale") {
147
+ return { outcome, freshness };
148
+ }
149
+ return null;
150
+ }
151
+
152
+ if (outcome === "BYPASS") {
153
+ const reason = detail.match(/^reason=(.+)$/)?.[1] as
154
+ | PprReplayBypassReason
155
+ | undefined;
156
+ if (reason && PPR_REPLAY_BYPASS_REASON_SET.has(reason)) {
157
+ return { outcome, reason };
158
+ }
159
+ }
160
+
161
+ return null;
162
+ }
163
+
164
+ /** Assert a partial response's PPR replay outcome and freshness/reason. */
165
+ export function assertPprReplayStatus(
166
+ target: ShellStatusTarget,
167
+ expected: PprReplayStatus,
168
+ ): void {
169
+ const raw = getHeaders(target).get(PPR_REPLAY_STATUS_HEADER);
170
+ if (raw === null) {
171
+ throw new Error(
172
+ `assertPprReplayStatus: response has no ${PPR_REPLAY_STATUS_HEADER} header. ` +
173
+ "The header is set on partial requests to ppr-declared routes.",
174
+ );
175
+ }
176
+
177
+ const actual = parsePprReplayStatus(target);
178
+ if (!actual) {
179
+ throw new Error(
180
+ `assertPprReplayStatus: unrecognized ${PPR_REPLAY_STATUS_HEADER} value "${raw}".`,
181
+ );
182
+ }
183
+
184
+ if (JSON.stringify(actual) !== JSON.stringify(expected)) {
185
+ throw new Error(
186
+ `assertPprReplayStatus: expected ${JSON.stringify(expected)} but got ${JSON.stringify(actual)}.`,
187
+ );
188
+ }
189
+ }
@@ -14,11 +14,12 @@ export type ViewTransitionClass = Record<string, string> | string;
14
14
  *
15
15
  * It mirrors the {@link ShouldRevalidateFn} args a `revalidate()` predicate
16
16
  * gets — the same navigation/action metadata — so the two read the same shape,
17
- * plus `get`/`env` for post-handler reads. There is no full `HandlerContext`
17
+ * plus `get`/`env` for request-context reads. There is no full `HandlerContext`
18
18
  * here: the gate runs at the RSC-payload layer with the request context, not a
19
19
  * handler context, so handler-only sugar (`search`/`build`/`dev`/`headers`) is
20
- * absent by design. `get` is the way to read what the handler/middleware set
21
- * via `ctx.set(...)` this request.
20
+ * absent by design. On ordinary routes, `get` can read what handlers or
21
+ * middleware set via `ctx.set(...)`. On `ppr` routes the gate runs before route
22
+ * handlers, so only middleware-established values are available.
22
23
  *
23
24
  * Field availability (all source fields are optional — never fabricated):
24
25
  * - `currentUrl` / `currentParams` / `fromRouteName` (the navigation SOURCE) are
@@ -43,11 +44,12 @@ export type ViewTransitionClass = Record<string, string> | string;
43
44
  * time, so `currentUrl`/`currentParams`/`fromRouteName` reflect the page the
44
45
  * prefetch fired from, NOT necessarily the page the user actually navigates from
45
46
  * — the decision is baked into the stored Flight payload and replayed verbatim.
46
- * A `cache()`/prerender hit replays the stored transition with the predicate NOT
47
- * re-run at all. So a source-sensitive predicate can be frozen to prefetch-time
48
- * or store-time state. This is accepted (~99% of navigations match), but if your
49
- * gate must reflect the exact click-time source, source-scope the prefetch
50
- * (`<Link prefetchKey=":source">`) and do not `cache()` that segment.
47
+ * A non-PPR `cache()`/prerender hit replays the stored transition with the
48
+ * predicate NOT re-run. A `ppr` route instead evaluates the predicate before
49
+ * handlers on every match, including cache, prerender, and PPR replay. Prefetch
50
+ * still freezes the server decision into its Flight payload. If the gate must
51
+ * reflect the exact click-time source, source-scope the prefetch
52
+ * (`<Link prefetchKey=":source">`).
51
53
  */
52
54
  export type TransitionWhenContext<
53
55
  TParams = Record<string, string>,
@@ -74,17 +76,19 @@ export type TransitionWhenContext<
74
76
  /**
75
77
  * Predicate that gates whether a transition() applies for the current request.
76
78
  *
77
- * Evaluated server-side AFTER the route's handler runs (so `get(...)` can read
78
- * handler/middleware-set state) and outside any cache scope. Return false to
79
- * drop this segment's transition for the request; return true to apply it. The
79
+ * Evaluated server-side outside any cache scope. On ordinary routes it runs
80
+ * AFTER the route's handler, so `get(...)` can read handler/middleware state. A
81
+ * route with `ppr` automatically hoists it before route handlers and reevaluates
82
+ * it on every cache/prerender/PPR replay; there `get(...)` can read middleware
83
+ * state, but not values set by route handlers. Return false to drop this
84
+ * segment's transition for the request; return true to apply it. The
80
85
  * context ({@link TransitionWhenContext}) carries the same navigation/action
81
86
  * metadata a `revalidate()` predicate sees plus `get`/`env`. If it throws, the
82
87
  * error is reported to the router's onError (phase "rendering") and the
83
88
  * transition is dropped (the navigation does not hold).
84
89
  *
85
90
  * Distinct from intercept()'s `when` config selector, which runs at MATCH time
86
- * over `{ from, to, params, segments, … }`; a transition `when` runs
87
- * post-handler over the resolved payload.
91
+ * over `{ from, to, params, segments, … }`.
88
92
  *
89
93
  * Scope: dropping a transition removes only THIS segment's contribution to the
90
94
  * navigation's hold. The startTransition hold is navigation-wide — it engages if
@@ -92,10 +96,10 @@ export type TransitionWhenContext<
92
96
  * navigation stream its loading fallback only when no other matched segment
93
97
  * keeps a transition (the common case: a single transition on the route).
94
98
  *
95
- * Evaluated on every fresh (cache-miss) resolution; it is NOT re-run when a
96
- * segment is replayed from the runtime cache or a build-time prerender, and a
97
- * prefetched navigation freezes it to prefetch-time state see the caveat on
98
- * {@link TransitionWhenContext}.
99
+ * On non-PPR routes it runs only during fresh resolution. On PPR routes it runs
100
+ * before handlers for every match, including runtime cache, build-time
101
+ * prerender, and PPR segment replay. A prefetched navigation still freezes the
102
+ * result to prefetch time; see {@link TransitionWhenContext}.
99
103
  */
100
104
  export type TransitionWhenFn = (ctx: TransitionWhenContext) => boolean;
101
105
 
@@ -129,11 +133,12 @@ export interface TransitionConfig {
129
133
  viewTransition?: "auto" | false;
130
134
  /**
131
135
  * Optional server-side predicate that gates this transition per request. When
132
- * present and it returns false (evaluated post-handler), the router drops this
133
- * segment's transition for the request, so the navigation streams its loading
134
- * fallback instead of holding. The predicate is server-only and never
135
- * serialized to the client; only its resolved effect (transition kept or
136
- * dropped) crosses. See {@link TransitionWhenFn}.
136
+ * present and it returns false, the router drops this segment's transition for
137
+ * the request, so the navigation streams its loading fallback instead of
138
+ * holding. PPR routes evaluate it before route handlers and on every replay;
139
+ * other routes evaluate it after handlers on fresh resolution. The predicate
140
+ * is server-only and never serialized to the client; only its resolved effect
141
+ * crosses. See {@link TransitionWhenFn}.
137
142
  */
138
143
  when?: TransitionWhenFn;
139
144
  }
@@ -394,9 +394,10 @@ export type PathHelpers<TEnv> = {
394
394
  * transition cannot fire without a startTransition. See
395
395
  * skills/view-transitions for the startTransition x ViewTransition matrix.
396
396
  *
397
- * Pass `when: (ctx) => boolean` to gate the transition per request: it runs
398
- * server-side after the route handler (can read `ctx.get(...)`), and returning
399
- * false drops the transition so the navigation streams its loading() skeleton.
397
+ * Pass `when: (ctx) => boolean` to gate the transition per request. It normally
398
+ * runs after the route handler; `ppr` routes automatically run it before route
399
+ * handlers and on every replay, where `ctx.get(...)` sees middleware state but
400
+ * not handler-set values.
400
401
  */
401
402
  transition: {
402
403
  (): TransitionItem;