@rangojs/router 0.6.0 → 0.7.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rangojs/router",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Django-inspired RSC router with composable URL patterns",
5
5
  "keywords": [
6
6
  "react",
@@ -182,6 +182,7 @@
182
182
  "prepublishOnly": "pnpm build",
183
183
  "typecheck": "tsc --noEmit && tsc -p tsconfig.strict-check.json --noEmit && tsc -p tsconfig.augment-check.json --noEmit",
184
184
  "test": "playwright test",
185
+ "test:preload": "playwright test --config=playwright.preload.config.ts",
185
186
  "test:ui": "playwright test --ui",
186
187
  "test:hmr-local": "playwright test --project=dev-warmup --project=hmr-basename --project=hmr-prerender --no-deps --workers=1 && RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
187
188
  "test:hmr-routes-local": "RANGO_E2E_ROUTE_HMR_ONLY=1 playwright test --project=hmr-routes --no-deps --workers=1",
@@ -191,7 +192,7 @@
191
192
  },
192
193
  "dependencies": {
193
194
  "@types/debug": "^4.1.12",
194
- "@vitejs/plugin-rsc": "^0.5.30",
195
+ "@vitejs/plugin-rsc": "^0.5.31",
195
196
  "debug": "^4.4.1",
196
197
  "magic-string": "^0.30.17",
197
198
  "picomatch": "^4.0.4",
@@ -224,9 +225,9 @@
224
225
  "@playwright/test": "^1.49.1",
225
226
  "@testing-library/react": ">=16",
226
227
  "@vercel/functions": "^3.0.0",
227
- "@vitejs/plugin-rsc": "^0.5.30",
228
- "react": ">=19.2.6 <20",
229
- "react-dom": ">=19.2.6 <20",
228
+ "@vitejs/plugin-rsc": "^0.5.31",
229
+ "react": ">=19.2.8 <20",
230
+ "react-dom": ">=19.2.8 <20",
230
231
  "vite": "^8.0.16",
231
232
  "vitest": ">=3"
232
233
  },
@@ -118,15 +118,15 @@ typing work exactly as for server routes (`/typesafety`).
118
118
 
119
119
  ## Helpers: what exists inside clientUrls()
120
120
 
121
- | Helper | Notes |
122
- | -------------- | ------------------------------------------------------------------------------------- |
123
- | `path()` | Options are `name`, `search`, `trailingSlash` only (no `ppr`, no response variants) |
124
- | `layout()` | Must contain at least one `path()` |
125
- | `loader()` | `loader(Def, use?)` or `loader(Def, { stream: "navigation" }, use?)` — see below |
126
- | `loading()` | Route/layout-level pending UI; inline `<Suspense>` at read sites is usually better |
127
- | `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
128
- | `transition()` | Data-only ViewTransition config — no `when` (that is a server-executed predicate) |
129
- | `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
121
+ | Helper | Notes |
122
+ | -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- |
123
+ | `path()` | Options are `name`, `search`, `trailingSlash`, `ppr` (shell caching — see /ppr skill; loader routes need `loading()` or capture refuses); no response variants |
124
+ | `layout()` | Must contain at least one `path()` |
125
+ | `loader()` | `loader(Def, use?)` or `loader(Def, { stream: "navigation" }, use?)` — see below |
126
+ | `loading()` | Route/layout-level pending UI; inline `<Suspense>` at read sites is usually better |
127
+ | `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
128
+ | `transition()` | Data-only ViewTransition config — no `when` (that is a server-executed predicate) |
129
+ | `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
130
130
 
131
131
  `include`, `parallel`, `cache`, `middleware`, `errorBoundary`,
132
132
  `notFoundBoundary` **throw** — and that is a design position, not a gap.
@@ -31,6 +31,56 @@ const TRANSITION_CONFIG_KEYS = new Set<PropertyKey>([
31
31
  "viewTransition",
32
32
  ]);
33
33
 
34
+ const PPR_NUMBER_KEYS = new Set<PropertyKey>([
35
+ "ttl",
36
+ "swr",
37
+ "captureTimeout",
38
+ "maxSnapshotBytes",
39
+ ]);
40
+
41
+ /**
42
+ * DSL-time shape check for the projected `ppr` path option. Mirrors
43
+ * PartialPrerenderProps but validates by hand: the value must survive the
44
+ * JSON projection to the server, so only `true` or a plain object of finite
45
+ * numbers plus a string[] `tags` is legal. The projection serializer
46
+ * (server-projection.ts) re-validates independently — this copy exists so a
47
+ * bad value fails in the authoring module with a DSL-shaped message.
48
+ */
49
+ function validateClientPpr(value: unknown): void {
50
+ if (value === true) return;
51
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
52
+ throw new Error(
53
+ "clientUrls() path() ppr must be true or a PartialPrerenderProps object",
54
+ );
55
+ }
56
+ for (const key of Reflect.ownKeys(value)) {
57
+ if (PPR_NUMBER_KEYS.has(key)) {
58
+ const n = (value as Record<PropertyKey, unknown>)[key];
59
+ if (typeof n !== "number" || !Number.isFinite(n)) {
60
+ throw new Error(
61
+ `clientUrls() path() ppr.${String(key)} must be a finite number`,
62
+ );
63
+ }
64
+ continue;
65
+ }
66
+ if (key === "tags") {
67
+ const tags = (value as { tags: unknown }).tags;
68
+ if (
69
+ !Array.isArray(tags) ||
70
+ tags.some((tag) => typeof tag !== "string" || tag === "")
71
+ ) {
72
+ throw new Error(
73
+ "clientUrls() path() ppr.tags must be an array of non-empty strings",
74
+ );
75
+ }
76
+ continue;
77
+ }
78
+ throw new Error(
79
+ `clientUrls() path() ppr received unsupported key ${JSON.stringify(String(key))}`,
80
+ );
81
+ }
82
+ }
83
+
34
84
  const UNSUPPORTED_HELPERS = new Set([
35
85
  "include",
36
86
  "parallel",
@@ -173,6 +223,31 @@ function assertNamedComponent(
173
223
  component: unknown,
174
224
  helper: "path" | "layout" | "intercept",
175
225
  ): asserts component is ComponentType {
226
+ // Build-time handler wrappers get a TARGETED rejection ahead of the generic
227
+ // shape check: their handler code cannot live in a "use client" bundle and
228
+ // their render runs at build, so accepting one could only fail later and
229
+ // further from the mistake. Brand strings checked directly — importing
230
+ // static-handler.ts/prerender.ts here would pull server modules into the
231
+ // client graph for an error message.
232
+ const brand =
233
+ component !== null &&
234
+ (typeof component === "object" || typeof component === "function")
235
+ ? (component as { __brand?: unknown }).__brand
236
+ : undefined;
237
+ if (
238
+ brand === "staticHandler" ||
239
+ brand === "prerenderHandler" ||
240
+ brand === "prerenderPassthrough"
241
+ ) {
242
+ const wrapper = brand === "staticHandler" ? "Static()" : "Prerender()";
243
+ throw new Error(
244
+ `clientUrls() ${helper}() received a ${wrapper} handler. Build-time ` +
245
+ "handlers are server-DSL surface and cannot mount inside a client " +
246
+ "group — routes in a group render client components. For shell " +
247
+ "caching of a group route use the `ppr` path option; build-time " +
248
+ "prerendering belongs to the server tree around the include.",
249
+ );
250
+ }
176
251
  if (typeof component !== "function" || component.name.trim() === "") {
177
252
  throw new Error(
178
253
  `clientUrls() ${helper}() expects a named client component value`,
@@ -249,6 +324,9 @@ function createHelpers(): ClientUrlHelpers {
249
324
  'clientUrls() path() trailingSlash must be "always", "never", or "ignore"',
250
325
  );
251
326
  }
327
+ if (options?.ppr !== undefined) {
328
+ validateClientPpr(options.ppr);
329
+ }
252
330
 
253
331
  const items = use ? runUse(use, "path use") : [];
254
332
  rejectItems(items, new Set(["loader", "loading", "transition"]), "path");
@@ -7,7 +7,11 @@ import {
7
7
  type InterceptSelectorContext,
8
8
  } from "../server/context.js";
9
9
  import type { LoaderDefinition, TrailingSlashMode } from "../types.js";
10
- import type { PathOptions, UrlPatterns } from "../urls/pattern-types.js";
10
+ import type {
11
+ PartialPrerenderProps,
12
+ PathOptions,
13
+ UrlPatterns,
14
+ } from "../urls/pattern-types.js";
11
15
  import type { PathHelpers } from "../urls/path-helper-types.js";
12
16
  import type { AllUseItems } from "../route-types.js";
13
17
  import { urls } from "../urls/urls-function.js";
@@ -45,6 +49,14 @@ const PROJECTION_OPTIONS = new Set<PropertyKey>([
45
49
  "name",
46
50
  "search",
47
51
  "trailingSlash",
52
+ "ppr",
53
+ ]);
54
+
55
+ const PPR_NUMBER_KEYS = new Set<PropertyKey>([
56
+ "ttl",
57
+ "swr",
58
+ "captureTimeout",
59
+ "maxSnapshotBytes",
48
60
  ]);
49
61
 
50
62
  const clientUrlProjections = new Map<string, ClientUrlProjection>();
@@ -59,6 +71,15 @@ export type ClientUrlDefinitionSource = ClientUrlReference | ClientUrlPatterns;
59
71
  export interface ClientUrlProjectionOptions {
60
72
  readonly search?: Readonly<Record<string, SearchSchemaValue>>;
61
73
  readonly trailingSlash?: TrailingSlashMode;
74
+ /**
75
+ * The `ppr` path option, JSON-projected. Materialization passes it onto
76
+ * the group route's server `path()` verbatim, so the manifest entry
77
+ * carries it and the runtime shell capture/serve lanes engage exactly as
78
+ * for a hand-written server route (search is part of shell identity —
79
+ * the capture render seeds the key's own search, so group static parts
80
+ * may read useSearchParams).
81
+ */
82
+ readonly ppr?: true | Readonly<PartialPrerenderProps>;
62
83
  }
63
84
 
64
85
  export interface ClientUrlProjectionRoute {
@@ -138,9 +159,6 @@ function serializeOptions(
138
159
  if (!source) return Object.freeze({});
139
160
 
140
161
  for (const key of Reflect.ownKeys(source)) {
141
- if (key === "ppr") {
142
- throw projectionError(route, 'path option "ppr" is not supported');
143
- }
144
162
  if (!PROJECTION_OPTIONS.has(key)) {
145
163
  throw projectionError(
146
164
  route,
@@ -152,6 +170,7 @@ function serializeOptions(
152
170
  const options: {
153
171
  search?: Readonly<Record<string, SearchSchemaValue>>;
154
172
  trailingSlash?: TrailingSlashMode;
173
+ ppr?: true | Readonly<PartialPrerenderProps>;
155
174
  } = {};
156
175
  if (source.search !== undefined) {
157
176
  options.search = serializeSearchSchema(route, source.search);
@@ -159,9 +178,72 @@ function serializeOptions(
159
178
  if (source.trailingSlash !== undefined) {
160
179
  options.trailingSlash = source.trailingSlash;
161
180
  }
181
+ if (source.ppr !== undefined) {
182
+ options.ppr = serializePpr(route, source.ppr);
183
+ }
162
184
  return Object.freeze(options);
163
185
  }
164
186
 
187
+ /**
188
+ * Serialize the `ppr` path option for the projection. The DSL already
189
+ * validated shape in the authoring module (validateClientPpr in
190
+ * client-urls.ts); this re-validates independently and re-copies
191
+ * defensively — the projection must carry only JSON, and the copy keeps a
192
+ * later mutation of the consumer's options object from leaking into the
193
+ * frozen projection.
194
+ */
195
+ function serializePpr(
196
+ route: ClientUrlRouteRecord,
197
+ source: unknown,
198
+ ): true | Readonly<PartialPrerenderProps> {
199
+ if (source === true) return true;
200
+ if (typeof source !== "object" || source === null || Array.isArray(source)) {
201
+ throw projectionError(
202
+ route,
203
+ 'path option "ppr" must be true or a PartialPrerenderProps object',
204
+ );
205
+ }
206
+ const out: {
207
+ ttl?: number;
208
+ swr?: number;
209
+ captureTimeout?: number;
210
+ maxSnapshotBytes?: number;
211
+ tags?: readonly string[];
212
+ } = {};
213
+ for (const key of Reflect.ownKeys(source)) {
214
+ if (PPR_NUMBER_KEYS.has(key)) {
215
+ const value = (source as Record<PropertyKey, unknown>)[key];
216
+ if (typeof value !== "number" || !Number.isFinite(value)) {
217
+ throw projectionError(
218
+ route,
219
+ `path option "ppr.${String(key)}" must be a finite number`,
220
+ );
221
+ }
222
+ out[key as "ttl" | "swr" | "captureTimeout" | "maxSnapshotBytes"] = value;
223
+ continue;
224
+ }
225
+ if (key === "tags") {
226
+ const tags = (source as { tags: unknown }).tags;
227
+ if (
228
+ !Array.isArray(tags) ||
229
+ tags.some((tag) => typeof tag !== "string" || tag === "")
230
+ ) {
231
+ throw projectionError(
232
+ route,
233
+ 'path option "ppr.tags" must be an array of non-empty strings',
234
+ );
235
+ }
236
+ out.tags = Object.freeze([...tags]);
237
+ continue;
238
+ }
239
+ throw projectionError(
240
+ route,
241
+ `path option "ppr.${String(key)}" is not supported`,
242
+ );
243
+ }
244
+ return Object.freeze(out) as Readonly<PartialPrerenderProps>;
245
+ }
246
+
165
247
  function serializeTransition(
166
248
  route: ClientUrlRouteRecord,
167
249
  ): Readonly<ClientTransitionConfig> | undefined {
@@ -393,6 +475,15 @@ function materializedPathOptions(route: ClientUrlProjectionRoute): PathOptions {
393
475
  ...(route.options.trailingSlash
394
476
  ? { trailingSlash: route.options.trailingSlash }
395
477
  : {}),
478
+ // ppr rides onto the materialized server path() verbatim: path-helper
479
+ // stamps it on the manifest entry, so resolvePprConfig classifies the
480
+ // group route exactly like a hand-written ppr page. Shallow-copy the
481
+ // object form — path-helper receives a mutable PathOptions.
482
+ ...(route.options.ppr !== undefined
483
+ ? {
484
+ ppr: route.options.ppr === true ? true : { ...route.options.ppr },
485
+ }
486
+ : {}),
396
487
  };
397
488
  }
398
489
 
@@ -28,7 +28,10 @@ export type ClientUrlUse = () => ClientUrlItems;
28
28
  export type ClientPathOptions<
29
29
  TName extends string = string,
30
30
  TSearch extends SearchSchema = SearchSchema,
31
- > = Pick<PathOptions<TName, TSearch>, "name" | "search" | "trailingSlash">;
31
+ > = Pick<
32
+ PathOptions<TName, TSearch>,
33
+ "name" | "search" | "trailingSlash" | "ppr"
34
+ >;
32
35
 
33
36
  export type ClientPathFn = <
34
37
  const TPattern extends string,
@@ -141,7 +144,10 @@ export interface ClientUrlHelpers {
141
144
  * Pass `{ stream: "navigation" }` to await this loader before first flush
142
145
  * on DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders
143
146
  * whose data, handle pushes, or thrown notFound()/redirect() must be in the
144
- * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming.
147
+ * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming. Under a
148
+ * `ppr` group route the flag BAKES: the loader executes at shell capture
149
+ * and its settled return freezes into the shell (nested promises stay
150
+ * live holes).
145
151
  */
146
152
  readonly loader: <TData>(
147
153
  definition: LoaderDefinition<TData>,
@@ -148,7 +148,13 @@ export async function resolveLoaders<TEnv>(
148
148
  loaderEntry,
149
149
  ctx,
150
150
  ctx.pathname,
151
- bakeLane ? segmentId : null,
151
+ // The bake key rides for flagged loaders too: stream:"navigation"
152
+ // bakes at capture regardless of the entry's loading() lane
153
+ // (loader-cache.ts capture branch) and its HIT-tail seed
154
+ // overlay needs the same key.
155
+ bakeLane || loaderEntry.awaitBeforeFlush === true
156
+ ? segmentId
157
+ : null,
152
158
  ),
153
159
  ),
154
160
  entry,
@@ -191,7 +197,7 @@ export async function resolveLoaders<TEnv>(
191
197
  loaderEntry,
192
198
  ctx,
193
199
  ctx.pathname,
194
- bakeLane ? segmentId : null,
200
+ bakeLane || loaderEntry.awaitBeforeFlush === true ? segmentId : null,
195
201
  ),
196
202
  ),
197
203
  entry,
@@ -157,19 +157,40 @@ export function resolveLoaderData<TEnv>(
157
157
  // shell HIT the recorded container is overlaid onto the fresh run so the
158
158
  // payload matches the frozen prelude byte-for-byte.
159
159
  if (isShellCaptureActive(reqCtx)) {
160
- // EXPERIMENT (streaming useLoader, feat/useloader-suspense): route
161
- // loaders are LIVE at capture UNCONDITIONALLY — the loading()-keyed lane
162
- // trigger is suspended on this branch. Read-site suspension postpones
163
- // each masked stream at the consumer's own Suspense boundary, so holes
164
- // no longer require a route-level loading(); baking route data into a
165
- // shell is expressed via cache()/"use cache", not by omitting loading().
166
- // The former bake-at-capture path (execute + nested-thenable mask +
167
- // snapshot pinning via _shellCaptureLoaderRecords, see git history) is
168
- // bypassed: with no records registered the serve-side seed overlay is a
169
- // no-op and every HIT runs loaders fresh. A masked reader with NO
170
- // Suspense above it root-postpones and the capture's <body> sanity gate
171
- // refuses the shell (runtime render + eternal-MISS warning), which is
172
- // the intended degrade for boundary-less ppr routes.
160
+ // Capture lane, per LOADER (not per entry):
161
+ //
162
+ // - `stream: "navigation"` (awaitBeforeFlush) the BAKE lane. The flag's
163
+ // document promise is "this loader's data is in the HTML before first
164
+ // flush"; under ppr the pre-flush HTML IS the frozen prelude, so the
165
+ // loader executes at capture and its SETTLED return bakes into the
166
+ // shell. Nested-promise SHAPE stays the liveness declaration: nested
167
+ // thenables are masked so their subtrees postpone as holes (per-request
168
+ // material must be promise-shaped the #692 cross-session scar). The
169
+ // masked container registers on _shellCaptureLoaderRecords: it holds
170
+ // the capture gate (bounded by ppr.captureTimeout) and pins into the
171
+ // shell snapshot, so a HIT tail replays the baked value and the
172
+ // hydration payload matches the frozen prelude byte-for-byte.
173
+ // - Every other loader — LIVE unconditionally: masked with a
174
+ // never-resolving promise, postponed at its boundary (loading() or an
175
+ // inline Suspense), streamed fresh per request. A masked reader with NO
176
+ // boundary above it root-postpones and the <body> sanity gate refuses
177
+ // the shell (eternal-MISS warning) — the degrade for boundary-less ppr
178
+ // routes. A route whose loaders are ALL flagged therefore needs no
179
+ // loading() at all: nothing masks, the shell captures complete.
180
+ if (loaderEntry.awaitBeforeFlush && bakeSegmentKey) {
181
+ const containerPromise = executeLoaderData(loaderEntry, ctx, pathname);
182
+ // Pre-attach a no-op catch: a bake-lane rejection during capture must
183
+ // surface through the drain's refusal (and the wrapper's error
184
+ // boundary), never as an unhandled rejection that can kill the worker
185
+ // before the drain probes this record.
186
+ containerPromise.catch(() => {});
187
+ const maskedPromise = containerPromise.then((container: unknown) =>
188
+ maskNestedContainerThenables(container),
189
+ );
190
+ maskedPromise.catch(() => {});
191
+ reqCtx?._shellCaptureLoaderRecords?.set(bakeSegmentKey, maskedPromise);
192
+ return maskedPromise;
193
+ }
173
194
  return createMaskedLoaderPromise();
174
195
  }
175
196
 
@@ -42,15 +42,15 @@ export function isShellCaptureActive(
42
42
  export { createMaskedLoaderPromise } from "./mask-nested.js";
43
43
 
44
44
  /**
45
- * Lane decision for an entry's loaders under PPR (the loading() value decides;
46
- * docs/design/loader-container-bake.md):
47
- *
48
- * - RENDERABLE loading() (the LoaderBoundary Suspense fallback) — the LIVE
49
- * lane: masked at capture, guaranteed fresh on every serve. Returns true.
50
- * - No loading() (absent, or explicitly `false`, incl. `loading(x, { ssr:
51
- * false })` under the SSR manifest) the BAKE lane: the loader executes
52
- * during capture, its settled container bakes, nested pending promises hole
53
- * at the consumer's own Suspense. Returns false.
45
+ * Entry-level lane input for an entry's loaders under PPR (the loading()
46
+ * value; docs/design/loader-container-bake.md). The CAPTURE decision itself
47
+ * is per LOADER in loader-cache.ts: a `stream: "navigation"`
48
+ * (awaitBeforeFlush) loader BAKES at capture regardless of this value — the
49
+ * flag's document promise ("data in the HTML before first flush") maps to
50
+ * the frozen prelude while every other loader is LIVE (masked at capture,
51
+ * fresh on every serve), postponing at loading() or an inline Suspense.
52
+ * This helper still gates which entries pass a bake segment key for the
53
+ * legacy loading()-less shape and the HIT-tail seed overlay.
54
54
  *
55
55
  * Mirrors segment-system's isRenderableLoading so the mask decision and the
56
56
  * tree's boundary placement can never disagree.
@@ -240,7 +240,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
240
240
  loaderEntry,
241
241
  ctx,
242
242
  ctx.pathname,
243
- bakeLane ? segmentId : null,
243
+ bakeLane || loaderEntry.awaitBeforeFlush === true
244
+ ? segmentId
245
+ : null,
244
246
  ),
245
247
  ),
246
248
  entry,
@@ -350,7 +350,10 @@ export type PathHelpers<TEnv> = {
350
350
  * Pass `{ stream: "navigation" }` to await this loader before first flush
351
351
  * on DOCUMENT requests (see {@link LoaderOptions}) — the opt-in for loaders
352
352
  * whose data, handle pushes, or thrown notFound()/redirect() must be in the
353
- * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming.
353
+ * SSR'd HTML. Per-loader: a dynamic sibling keeps streaming. Under a
354
+ * `ppr` route the flag BAKES: the loader executes at shell capture and
355
+ * its settled return freezes into the shell (nested promises stay live
356
+ * holes) — the pre-flush promise applied to the prelude.
354
357
  */
355
358
  loader: <TData>(
356
359
  loaderDef: LoaderDefinition<TData>,