@rangojs/router 0.23.0 → 0.24.1

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 (132) hide show
  1. package/dist/bin/rango.js +4 -3
  2. package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
  3. package/dist/types/browser/partial-update.d.ts +6 -4
  4. package/dist/types/browser/react/NavigationProvider.d.ts +11 -7
  5. package/dist/types/browser/react/context.d.ts +3 -2
  6. package/dist/types/browser/types.d.ts +5 -1
  7. package/dist/types/build/route-types/scan-filter.d.ts +7 -1
  8. package/dist/types/cache/document-cache-policy.d.ts +24 -0
  9. package/dist/types/cache/document-cache.d.ts +2 -10
  10. package/dist/types/client-urls/types.d.ts +8 -0
  11. package/dist/types/context-var.d.ts +12 -0
  12. package/dist/types/errors.d.ts +10 -0
  13. package/dist/types/handles/pushed-by.d.ts +8 -0
  14. package/dist/types/index.d.ts +4 -0
  15. package/dist/types/index.rsc.d.ts +2 -0
  16. package/dist/types/response-utils.d.ts +7 -0
  17. package/dist/types/reverse.d.ts +8 -8
  18. package/dist/types/route-definition/rewrite.d.ts +56 -0
  19. package/dist/types/route-map-builder.d.ts +7 -2
  20. package/dist/types/router/basename.d.ts +7 -0
  21. package/dist/types/router/handler-context.d.ts +1 -5
  22. package/dist/types/router/internal-params.d.ts +8 -0
  23. package/dist/types/router/middleware-types.d.ts +15 -0
  24. package/dist/types/router/revalidation.d.ts +1 -2
  25. package/dist/types/router/route-trie-builder.d.ts +10 -0
  26. package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
  27. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +36 -13
  28. package/dist/types/rsc/full-payload.d.ts +11 -0
  29. package/dist/types/rsc/handler.d.ts +33 -0
  30. package/dist/types/rsc/helpers.d.ts +8 -0
  31. package/dist/types/rsc/loader-fetch.d.ts +16 -0
  32. package/dist/types/rsc/rewrite-passes.d.ts +74 -0
  33. package/dist/types/rsc/ssr-setup.d.ts +3 -1
  34. package/dist/types/server/request-context.d.ts +39 -2
  35. package/dist/types/server/rewrite-trail.d.ts +49 -0
  36. package/dist/types/server/slot-accessors.d.ts +29 -0
  37. package/dist/types/testing/index.d.ts +1 -1
  38. package/dist/types/testing/render-route.d.ts +8 -0
  39. package/dist/types/testing/run-middleware.d.ts +20 -0
  40. package/dist/types/testing/serve-shell-request.d.ts +2 -1
  41. package/dist/types/thenable-status.d.ts +6 -0
  42. package/dist/types/types/request-scope.d.ts +7 -0
  43. package/dist/types/vite/discovery/state.d.ts +12 -9
  44. package/dist/vite/index.js +164 -103
  45. package/package.json +1 -1
  46. package/skills/caching/SKILL.md +12 -0
  47. package/skills/catalog.json +1 -1
  48. package/skills/client-urls/SKILL.md +86 -1
  49. package/skills/hooks/SKILL.md +11 -6
  50. package/skills/hooks/data.md +3 -1
  51. package/skills/loader/SKILL.md +7 -1
  52. package/skills/middleware/SKILL.md +183 -2
  53. package/skills/ppr/SKILL.md +9 -0
  54. package/skills/rango/SKILL.md +1 -1
  55. package/skills/server-actions/SKILL.md +27 -0
  56. package/skills/testing/client-components.md +1 -0
  57. package/skills/testing/middleware.md +31 -10
  58. package/skills/testing/response-routes.md +1 -0
  59. package/skills/testing/serve-shell-request.md +19 -1
  60. package/skills/use-cache/SKILL.md +4 -2
  61. package/src/__augment-tests__/augmented.check.ts +20 -0
  62. package/src/browser/event-controller.ts +3 -0
  63. package/src/browser/merge-segment-loaders.ts +31 -18
  64. package/src/browser/navigation-bridge.ts +19 -3
  65. package/src/browser/partial-update.ts +27 -12
  66. package/src/browser/react/Link.tsx +7 -9
  67. package/src/browser/react/NavigationProvider.tsx +46 -26
  68. package/src/browser/react/context.ts +3 -2
  69. package/src/browser/react/use-router.ts +2 -4
  70. package/src/browser/server-action-bridge.ts +6 -0
  71. package/src/browser/types.ts +5 -1
  72. package/src/build/route-types/scan-filter.ts +11 -4
  73. package/src/cache/document-cache-policy.ts +131 -0
  74. package/src/cache/document-cache.ts +12 -119
  75. package/src/client-urls/client-root.tsx +3 -3
  76. package/src/client-urls/client-urls.ts +8 -1
  77. package/src/client-urls/types.ts +8 -0
  78. package/src/context-var.ts +32 -2
  79. package/src/errors.ts +19 -0
  80. package/src/handles/pushed-by.ts +26 -0
  81. package/src/index.rsc.ts +8 -0
  82. package/src/index.ts +7 -0
  83. package/src/response-utils.ts +19 -0
  84. package/src/reverse.ts +17 -14
  85. package/src/route-definition/redirect.ts +2 -15
  86. package/src/route-definition/rewrite.ts +146 -0
  87. package/src/route-map-builder.ts +14 -11
  88. package/src/router/basename.ts +20 -0
  89. package/src/router/handler-context.ts +3 -13
  90. package/src/router/intercept-resolution.ts +29 -12
  91. package/src/router/internal-params.ts +16 -0
  92. package/src/router/loader-resolution.ts +2 -0
  93. package/src/router/match-middleware/background-revalidation.ts +11 -9
  94. package/src/router/match-middleware/cache-lookup.ts +20 -15
  95. package/src/router/match-middleware/cache-store.ts +9 -11
  96. package/src/router/match-middleware/intercept-resolution.ts +4 -2
  97. package/src/router/middleware-types.ts +16 -0
  98. package/src/router/middleware.ts +127 -49
  99. package/src/router/revalidation.ts +1 -1
  100. package/src/router/route-trie-builder.ts +28 -1
  101. package/src/router/segment-resolution/loader-snapshot.ts +18 -4
  102. package/src/router/segment-resolution/mask-nested.ts +8 -4
  103. package/src/router/segment-resolution/prefetch-deferral.ts +57 -17
  104. package/src/router/segment-resolution/revalidation.ts +57 -33
  105. package/src/rsc/full-payload.ts +37 -5
  106. package/src/rsc/handler.ts +82 -55
  107. package/src/rsc/helpers.ts +24 -0
  108. package/src/rsc/loader-fetch.ts +79 -24
  109. package/src/rsc/response-cache-serve.ts +18 -2
  110. package/src/rsc/response-route-handler.ts +4 -7
  111. package/src/rsc/rewrite-passes.ts +203 -0
  112. package/src/rsc/rsc-rendering.ts +4 -2
  113. package/src/rsc/ssr-setup.ts +11 -5
  114. package/src/server/cookie-parse.ts +36 -12
  115. package/src/server/cookie-store.ts +7 -3
  116. package/src/server/handle-store.ts +48 -17
  117. package/src/server/request-context.ts +154 -63
  118. package/src/server/request-headers.ts +4 -14
  119. package/src/server/rewrite-trail.ts +190 -0
  120. package/src/server/slot-accessors.ts +57 -0
  121. package/src/ssr/index.tsx +118 -16
  122. package/src/testing/dispatch.ts +138 -67
  123. package/src/testing/index.ts +1 -0
  124. package/src/testing/render-route.tsx +16 -3
  125. package/src/testing/run-loader.ts +2 -0
  126. package/src/testing/run-middleware.ts +56 -11
  127. package/src/testing/serve-shell-request.ts +15 -2
  128. package/src/thenable-status.ts +11 -0
  129. package/src/types/request-scope.ts +8 -0
  130. package/src/vite/discovery/state.ts +21 -13
  131. package/src/vite/plugins/expose-internal-ids.ts +2 -3
  132. package/src/vite/router-discovery.ts +29 -27
@@ -285,7 +285,9 @@ See `/route` for the full handler-context contract (shared with loaders) and
285
285
 
286
286
  - `ctx.params` — merged route params + explicit loader params. For fetchable
287
287
  loaders called with `load(Loader, { params: { ... } })`, explicit params
288
- override route-matched params.
288
+ override route-matched params. Explicit params are string values; a request
289
+ with another value or a `__proto__`, `constructor` or `prototype` key is
290
+ rejected (400, `load()` fails with `Invalid loader params`).
289
291
  - `ctx.routeParams` — server-trusted route params from URL pattern matching.
290
292
  Cannot be overridden by client-provided params.
291
293
 
@@ -333,6 +335,10 @@ path("/product/:slug", ProductPage, { name: "product" }, () => [
333
335
  ]);
334
336
  ```
335
337
 
338
+ In a `clientUrls()` group the same `loader(Def, () => [revalidate(fn)])` form
339
+ runs on the server with these arguments; the build moves the predicate out of
340
+ the `"use client"` module (`/client-urls` → "Revalidation runs on the server").
341
+
336
342
  ### `revalidate()` return shapes
337
343
 
338
344
  > **Scope: `revalidate()` is a partial-render concern, not a cache concern.**
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: middleware
3
- description: Define global (router.use) and route (middleware()) middleware in @rangojs/router — auth gates, logging, response headers, context variables, redirects. Use when gating routes behind auth checks, logging requests, setting headers or ctx variables before a handler runs, or deciding whether middleware wraps server actions.
3
+ description: Define global (router.use) and route (middleware()) middleware in @rangojs/router — auth gates, logging, response headers, context variables, redirects, rewrites (throw rewrite() renders another route at the same URL). Use when gating routes behind auth checks, logging requests, setting headers or ctx variables before a handler runs, showing a maintenance page at the visitor's URL, or deciding whether middleware wraps server actions.
4
4
  argument-hint: [middleware-name]
5
5
  ---
6
6
 
@@ -19,7 +19,7 @@ There are two levels of middleware with different execution scopes:
19
19
 
20
20
  ### Global middleware (`router.use()`)
21
21
 
22
- Registered on the router instance. Wraps the **entire request**, including server actions, rendering, and progressive enhancement (PE) re-renders.
22
+ Registered on the router instance. Wraps the **entire request**, including server actions, rendering, and progressive enhancement (PE) re-renders. A request a middleware rewrites (`throw rewrite()`, below) runs the chain again for the target: once per pass.
23
23
 
24
24
  ```typescript
25
25
  const router = createRouter<AppEnv>({})
@@ -257,6 +257,8 @@ export const myMiddleware: Middleware = async (ctx, next) => {
257
257
  ctx.headers; // response Headers (stub before next(), the real response after)
258
258
  ctx.setLocationState(FlashMessage({ text: "Saved" })); // history state for the client
259
259
  ctx.reverse("home"); // URL for a global route name (no ".name", no param auto-fill)
260
+ ctx.rewrittenFrom; // the visitor's URL when this request was rewritten, else undefined
261
+ ctx.isRewrite(error); // true for a thrown rewrite() (rethrow it from a try/catch)
260
262
 
261
263
  // Opt the current request out of PPR shell lookup/capture.
262
264
  ctx.dynamic();
@@ -428,6 +430,185 @@ Read the flash on the target page with `useLocationState(FlashMessage)`. The `{
428
430
 
429
431
  A redirect is followed on a link click, `router.push()`, and Back/Forward to an entry the client has to fetch. On Back/Forward the redirect replaces the history entry it was triggered from: Back from the target lands on the entry before the redirecting one, not on the redirecting one again. The client follows at most 20 redirects in one navigation; a loop (two pages redirecting to each other) stops there with the error boundary and a `[rango] Server redirect loop` console error.
430
432
 
433
+ ## Rewrite: another route at the same URL
434
+
435
+ `throw rewrite(target)` renders another route while the address bar keeps the
436
+ visitor's URL: a maintenance or lock page, a deploy migration, a URL override,
437
+ an A/B or geo split. A redirect changes the URL; a rewrite does not.
438
+
439
+ ```typescript
440
+ import { getRequestContext, rewrite } from "@rangojs/router";
441
+ import type { Middleware } from "@rangojs/router";
442
+
443
+ export const maintenance: Middleware = async (ctx, next) => {
444
+ if (!locked || ctx.pathname === "/maintenance") return next();
445
+ // Mutations get a Response: a rewrite cannot answer a server action or a form post.
446
+ if (ctx.request.method !== "GET") {
447
+ return new Response("Maintenance", {
448
+ status: 503,
449
+ headers: { "Retry-After": "600" },
450
+ });
451
+ }
452
+ ctx.set(Lock, { until });
453
+ throw rewrite("maintenance");
454
+ };
455
+
456
+ // urls.tsx — the target sets its own status
457
+ path("/maintenance", MaintenancePage, { name: "maintenance" }, () => [
458
+ middleware(async (ctx, next) => {
459
+ getRequestContext().setStatus(503);
460
+ ctx.header("Retry-After", "600");
461
+ await next();
462
+ }),
463
+ ]);
464
+ ```
465
+
466
+ Targets, like `ctx.reverse` with a path form:
467
+
468
+ ```typescript
469
+ throw rewrite("/maintenance"); // a path (basename-prefixed like redirect())
470
+ throw rewrite("/maintenance?from=shop"); // a path with search
471
+ throw rewrite("maintenance"); // a global route name without params
472
+ throw rewrite("product", { slug }); // with params
473
+ throw rewrite("product", { slug }, { tab: "reviews" }); // and search
474
+ ```
475
+
476
+ **How it runs.** The throw unwinds the request to the router, which runs it
477
+ again as the target, from the top, as a direct visit would: global middleware
478
+ matched on the target's path (a `.use("/admin/*", guard)` or an inline
479
+ `ctx.pathname` check decides on the rendered route), the target's route
480
+ middleware, its render. Code after `await next()` in the pass that rewrote
481
+ never runs, and global middleware runs once per pass.
482
+
483
+ **Never skip a guard on a rewritten pass.** Authorization, auth and
484
+ rate-limit checks run on every pass, as written: the target's guards
485
+ deciding on the rendered route is what keeps a rewrite from becoming a way
486
+ around them. `if (!ctx.rewrittenFrom)` is only for side effects you want
487
+ once per request, such as a counter or a log line:
488
+
489
+ ```typescript
490
+ export const countVisit: Middleware = async (ctx, next) => {
491
+ if (!ctx.rewrittenFrom) visits.increment(); // once per request
492
+ return next();
493
+ };
494
+ ```
495
+
496
+ **Throw it before `await next()`.** Once your `next()` ran the route, the
497
+ page has rendered: a `throw rewrite()` after it (a "rewrite on 404" after
498
+ `const res = await next()`) is an error, "throw rewrite() before await
499
+ next()". Decide before calling `next()`. A rewrite thrown further down
500
+ passes through your `await next()` as usual, and in a `catch` around it you
501
+ may throw another rewrite in its place; after any other failure of `next()`
502
+ (the route may have rendered before it failed) a rewrite is an error too.
503
+
504
+ **Same request context.** Values you `ctx.set()` before the throw are
505
+ readable in the target's middleware, handlers and loaders; cookies, headers
506
+ and the status you set stay on the response; location state, theme and
507
+ `ctx.dynamic()` carry over. The request, URLs, params and route name are the
508
+ target's.
509
+
510
+ **Old and new URL.** `ctx.url`, `ctx.pathname` and `ctx.searchParams` are
511
+ the target's; `ctx.rewrittenFrom` is the visitor's URL (search included; the
512
+ first URL after several rewrites; `undefined` when nothing rewrote).
513
+ `usePathname()` and `useSearchParams()` show the visitor's URL in the server
514
+ HTML and after hydration, except in a render other visitors are served (see
515
+ Caches); `useParams()` and the route name are the target's. The response
516
+ carries `x-rango-rewrite: <target pathname>`.
517
+
518
+ **`ctx.rewrittenFrom` is request identity**, like `cookies()`: reading it
519
+ inside a `cache()` boundary or a `"use cache"` function throws (pass what
520
+ you need in from middleware), and a render other visitors may be served (a
521
+ PPR shell capture, a page the document cache stores) reads `undefined`, as a
522
+ direct visit does.
523
+
524
+ **Middleware only, by design.** `rewrite()` is a middleware tool. A route
525
+ handler or a loader (any loader, `ssr: false` included) cannot rewrite: its
526
+ throw is an error whose message points you to route middleware. A rewrite
527
+ that depends on data goes in the route's middleware, reading the data
528
+ through a cached function the loader shares, so the lookup runs once:
529
+
530
+ ```typescript
531
+ path("/product/:slug", ProductPage, { name: "product" }, () => [
532
+ middleware(async (ctx, next) => {
533
+ const product = await getProduct(ctx.params.slug); // cached, shared with the loader
534
+ if (product.discontinued) {
535
+ throw rewrite("discontinued", { slug: ctx.params.slug });
536
+ }
537
+ await next();
538
+ }),
539
+ loader(productLoader), // calls getProduct(slug): a cache hit
540
+ ]);
541
+ ```
542
+
543
+ **Throw it, don't return it.** A returned rewrite is an error. Where a
544
+ rewrite cannot be honored it is an error that names the rule: a route
545
+ handler or a loader (see above), a server action (use `redirect()`),
546
+ intercept middleware, a loader fetch (`useFetchLoader`, `load()`: it
547
+ rejects with the rule) or its middleware, and middleware handling a non-GET
548
+ request (a JS action or a no-JS form post: return a `Response`).
549
+
550
+ **try/catch around next().** A thrown rewrite passes through your
551
+ middleware's `catch`. Rethrow it:
552
+
553
+ ```typescript
554
+ export const errorPage: Middleware = async (ctx, next) => {
555
+ try {
556
+ return await next();
557
+ } catch (error) {
558
+ if (ctx.isRewrite(error)) throw error;
559
+ return new Response("Something went wrong", { status: 500 });
560
+ }
561
+ };
562
+ ```
563
+
564
+ **Loops.** More than 5 rewrites in one request, or a rewrite to the URL the
565
+ request is on, answers 508 and reports a `RewriteLoopError` (its message has
566
+ the trail, `/a -> /b -> /a`) through `onError` with phase `"routing"`.
567
+
568
+ **Caches.**
569
+
570
+ - The document cache keys a rewritten request by the target's URL, as a
571
+ direct visit. Register the rewriting middleware **before**
572
+ `createDocumentCacheMiddleware()`: a cache hit for the visitor's URL would
573
+ be served before your middleware runs. A page the document cache stores,
574
+ and a PPR shell of the target, are rendered as a direct visit to the
575
+ target: they carry the target's pathname and `ctx.rewrittenFrom` is
576
+ `undefined` there, so a component that renders `usePathname()` can
577
+ mismatch on hydration at the visitor's URL when such a page is served to
578
+ a rewritten request.
579
+ - The client's prefetch and history caches keep a rewritten page under the
580
+ visitor's URL, and nothing tells them about the rewrite: a page prefetched
581
+ during a lock shows the lock page for up to `prefetchCacheTTL` after the
582
+ lock lifts. `invalidateClientCache()` drops a client's caches, for the
583
+ client that receives the response; mark the clients that saw the lock and
584
+ call it on their first response after it lifts:
585
+
586
+ ```typescript
587
+ import { cookies, invalidateClientCache, rewrite } from "@rangojs/router";
588
+
589
+ export const maintenance: Middleware = async (ctx, next) => {
590
+ if (!locked) {
591
+ if (cookies().get("saw-lock")) {
592
+ cookies().delete("saw-lock");
593
+ invalidateClientCache();
594
+ }
595
+ return next();
596
+ }
597
+ cookies().set("saw-lock", "1", { path: "/" });
598
+ // ...the lock as above
599
+ throw rewrite("maintenance");
600
+ };
601
+ ```
602
+
603
+ **Testing.** `runMiddleware` reports a thrown rewrite as `result.rewrite`
604
+ (`{ pathname, search, routeName? }`). `serveShellRequest` takes a rewritten
605
+ page request through the full production pipeline (document or `partial`
606
+ navigation): the target's middleware, render, status and `x-rango-rewrite`.
607
+ `dispatch` re-dispatches too, for response routes only: it matches the
608
+ visitor's URL first and throws on a component (RSC) route.
609
+ `renderRoute(..., { request: "/shop", rewrite: "/maintenance" })` renders
610
+ the target's route at the visitor's URL. See `/testing`.
611
+
431
612
  ## Authentication Middleware
432
613
 
433
614
  ```typescript
@@ -149,6 +149,15 @@ material is shared per host+URL and request partition. `cache(false)`, or a
149
149
  `condition()` that returns false, on a ppr route now renders the request like a
150
150
  cache miss (no shell).
151
151
 
152
+ ### A request middleware rewrote
153
+
154
+ A request middleware rewrote (`throw rewrite()`, `/middleware`) to a `ppr`
155
+ route is served the target's shell under the target's key. The capture
156
+ renders the target as a direct visit: `ctx.rewrittenFrom` reads `undefined`
157
+ there and the payload carries the target's pathname, so the shell is the
158
+ same for direct and rewritten visitors (a component that renders
159
+ `usePathname()` shows the target's URL to a rewritten visitor on a HIT).
160
+
152
161
  ### Request-partitioned shells
153
162
 
154
163
  A route whose `cache()` record is partitioned by the request partitions its
@@ -311,7 +311,7 @@ Grouped by concern — read when you need to…
311
311
  | `/layout` | Layouts that wrap child routes |
312
312
  | `/parallel` | Multi-column layouts and sidebars |
313
313
  | `/intercept` | Modal/slide-over patterns for soft navigation |
314
- | `/middleware` | Request processing and authentication |
314
+ | `/middleware` | Request processing, authentication, `throw rewrite()` (same-URL rewrites) |
315
315
  | `/host-router` | Multi-app host routing with domain/subdomain patterns |
316
316
  | `/links` | URL generation: ctx.reverse, href, useHref, useMount, scopedReverse |
317
317
  | `/response-routes` | JSON/text/HTML/XML/stream endpoints with `path.json()`, `path.text()` |
@@ -492,6 +492,11 @@ file-path-based revalidation predicates, define the action in a module-level
492
492
  `"use server"` file rather than inline. See `/loader` for the full revalidation
493
493
  contract (deferred returns, soft suggestions).
494
494
 
495
+ A loader in a `clientUrls()` group takes the same predicate with the same
496
+ arguments, `actionResult` and `formData` included: the build moves it out of
497
+ the `"use client"` module and the server runs it (`/client-urls` →
498
+ "Revalidation runs on the server").
499
+
495
500
  ### Cross-segment dependencies
496
501
 
497
502
  If a loader reads `ctx.get()` data set by an outer layout/handler, that
@@ -680,6 +685,28 @@ export async function deletePost(id: string): Promise<void> {
680
685
  For a recoverable "already gone" case, return a result the form can show
681
686
  (`{ error: "Post not found" }`) instead of throwing.
682
687
 
688
+ ### A loader that fails on the revalidation render
689
+
690
+ A loader that throws while it reruns after the action does not undo the
691
+ action, which has already run and returned. What follows depends on the route:
692
+
693
+ - **No `loading()` on the route (and any `clientUrls()` route):** the action
694
+ call rejects with the loader's error, nothing of that revalidation commits,
695
+ and the page keeps its data and its React state. A `<form action>`,
696
+ `useActionState` or `useTransition` call hands that rejection to the nearest
697
+ React error boundary above the form.
698
+ - **A `urls()` route with `loading()`:** the call resolves with the action's
699
+ result, the page renders, and the loader's read throws to the nearest React
700
+ error boundary.
701
+ - **An `errorBoundary()` above the route:** its fallback replaces the route.
702
+
703
+ For a failure the user can retry, return it from the loader as data
704
+ (`{ ok: false, reason }`) and render it where the data goes. A failure returned
705
+ as data is a value like any other, so a `cache()` on the loader (or a `ppr`
706
+ bake, or a prerender) stores and serves it; a thrown error is never stored.
707
+ See `/client-urls` → "A loader that fails on its rerun" for the table and an
708
+ example.
709
+
683
710
  ### Authorization in actions
684
711
 
685
712
  Route middleware does **not** wrap action execution — only global
@@ -26,6 +26,7 @@
26
26
  | `nonce` | `string` | CSP nonce to seed via `NonceContext`, so a component calling `useNonce()` (e.g. an analytics/GTM head script) sees it — mirroring SSR. Defaults to `undefined` (the browser default). |
27
27
  | `defaultPrefetch` | `PrefetchStrategy` | Router default for `<Link>` and eligible plain anchors. `data-prefetch="false"`/`"none"` opts out one anchor; ancestor `data-prefetch-scope="false"`/`"none"` hard-disables the subtree; `"true"` permits routed resource suffixes elsewhere. |
28
28
  | `hydrate` | `boolean` | Render the same tree to HTML first (`renderToString`, no `window`/`document`), then hydrate that HTML instead of mounting fresh. The result gains `serverHtml` and `recoverableErrors`. See [Hydration](#hydration). |
29
+ | `rewrite` | `string` | The path middleware rewrote `request` to (`throw rewrite()`): the leaf is matched against it for params, while the location (`usePathname`, `useSearchParams`) stays `request`, as a rewritten document renders and hydrates. |
29
30
 
30
31
  `RenderRouteSpec = { path, Component, layout?, loaderIds?, name?, transition? }` — one node of the route definition. The array is the layout chain root-to-leaf; the LAST entry is the leaf route (its pattern is matched against `request` to extract params; layout patterns are informational). `loaderIds` attaches seeded loaders to THIS node's segment; `layout` on the leaf wraps it; `name` is informational. `transition` is the `transition()` config this node declares (`{}` for a bare `transition()`), attached to its segment exactly as the DSL does — see [Held navigation](#held-navigation).
31
32
 
@@ -44,16 +44,17 @@ The `ctx` your middleware reads. Notable fields:
44
44
 
45
45
  ### Returns — `RunMiddlewareResult<TEnv>`
46
46
 
47
- | Field | Type | Meaning |
48
- | ----------------- | ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
49
- | `response` | `Response` | The final Response: the downstream response, or a middleware short-circuit. |
50
- | `ctx` | `RequestContext<TEnv>` | The underlying RequestContext (NOT a per-middleware `MiddlewareContext`). Use `ctx.get(...)` for anything the envelope above doesn't surface. |
51
- | `nextCalled` | `number` | Times the terminal handler ran: `0` on short-circuit, `1` on pass-through. |
52
- | `dynamic` | `boolean` | Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). |
53
- | `cookies` | `Record<string, string>` | Effective cookie view: request cookies merged with chain sets/deletes (last-write-wins), as `{ name: value }`. |
54
- | `headers` | `Record<string, string>` | Final response headers as `{ name: value }`, lowercased, EXCLUDING `set-cookie` (use `cookies`). |
55
- | `locationState` | `Record<string, unknown>` | Flat `{ key: value }` state set via `setLocationState()` / `redirect({ state })` (empty when none). |
56
- | `stateCookieName` | `string` | The resolved rango state cookie name seeded for the run (default `rango-state_router_0`). Assert a middleware's `invalidateClientCache()` rotation against it without recomputing — parity with `runInRequestContext` / `runLoaderResult` / `renderHandler`. |
47
+ | Field | Type | Meaning |
48
+ | ----------------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
49
+ | `response` | `Response` | The final Response: the downstream response, or a middleware short-circuit. |
50
+ | `ctx` | `RequestContext<TEnv>` | The underlying RequestContext (NOT a per-middleware `MiddlewareContext`). Use `ctx.get(...)` for anything the envelope above doesn't surface. |
51
+ | `nextCalled` | `number` | Times the terminal handler ran: `0` on short-circuit, `1` on pass-through. |
52
+ | `rewrite` | `RunMiddlewareRewrite?` | The target of a `throw rewrite(...)`: `{ pathname, search, routeName? }`, resolved as production resolves it (a path basename-prefixed, a name through `routeMap`). `undefined` when nothing rewrote. On a rewrite, `response` carries what the chain set before the throw. |
53
+ | `dynamic` | `boolean` | Whether the chain called `ctx.dynamic()` (the PPR shell opt-out). |
54
+ | `cookies` | `Record<string, string>` | Effective cookie view: request cookies merged with chain sets/deletes (last-write-wins), as `{ name: value }`. |
55
+ | `headers` | `Record<string, string>` | Final response headers as `{ name: value }`, lowercased, EXCLUDING `set-cookie` (use `cookies`). |
56
+ | `locationState` | `Record<string, unknown>` | Flat `{ key: value }` state set via `setLocationState()` / `redirect({ state })` (empty when none). |
57
+ | `stateCookieName` | `string` | The resolved rango state cookie name seeded for the run (default `rango-state_router_0`). Assert a middleware's `invalidateClientCache()` rotation against it without recomputing — parity with `runInRequestContext` / `runLoaderResult` / `renderHandler`. |
57
58
 
58
59
  ## Recipe
59
60
 
@@ -89,6 +90,26 @@ describe("requireUser", () => {
89
90
 
90
91
  Pass an array to run several in order. Cookies set inside middleware via the standalone `cookies().set(...)` (imported from `@rangojs/router`, NOT a `ctx` method) surface on the result's `cookies` and on the merged response `Set-Cookie`.
91
92
 
93
+ ### A middleware that rewrites
94
+
95
+ A `throw rewrite(target)` (see `/middleware` → "Rewrite") does not fail the run: `result.rewrite` names the target, and the values and cookies the chain set before the throw are on `result.ctx` and `result.cookies`, as production keeps them for the target. `runMiddleware` does not run the target. To test what a rewritten page renders, use `serveShellRequest` (`./serve-shell-request.md` → "A page middleware rewrites"): it takes the request through the full production pipeline, the target's middleware, render, status and `x-rango-rewrite` included. `dispatch` re-dispatches only between response routes: it matches the visitor's URL before middleware runs and throws on a component (RSC) route.
96
+
97
+ ```ts
98
+ it("rewrites a locked page to the maintenance route", async () => {
99
+ const result = await runMiddleware(maintenance, {
100
+ request: "/shop",
101
+ routeMap: { maintenance: "/maintenance" },
102
+ });
103
+ expect(result.rewrite).toEqual({
104
+ pathname: "/maintenance",
105
+ search: "",
106
+ routeName: "maintenance",
107
+ });
108
+ expect(result.nextCalled).toBe(0);
109
+ expect(result.ctx.get(Lock)).toEqual({ until: "12:00" });
110
+ });
111
+ ```
112
+
92
113
  ## Caveats
93
114
 
94
115
  - No `handles`/`rendered` option by design: the middleware context has no handle APIs in production — no push (`ctx.use(Handle)` is a handler/loader API) and no post-barrier read (`ctx.get(handle)` is loader-only, and middleware runs BEFORE the render barrier anyway). Read handle data in a loader and test it with `runLoader`; assert loader pushes via `runLoaderResult(...).handlePushes` (see `./handles.md`).
@@ -91,6 +91,7 @@ describe("api routes via dispatch", () => {
91
91
  - JSON serialization is bare, applied in `response-route-handler.ts`: a `path.json` handler that returns a value is serialized verbatim (`JSON.stringify(value)`, status 200, `application/json`) — no envelope. Returning or throwing a `Response` (e.g. `Response.json(x)`) uses the same control-flow path. Any other thrown error takes the error's status (`RouterError.status`, else 500, or a non-200 status already set upstream in the request pipeline). On a `path.json` route the body is an RFC 9457 problem+json object `{ title, status, detail, code }` (`application/problem+json;charset=utf-8`), where `code` is the `RouterError.code`, else `"INTERNAL"`, and the `type` member is omitted. On text/html/xml/md routes the body is `text/plain`: the `RouterError` message, the error message in dev, or `"Internal Server Error"` in production. Assert the shape matching what your handler returns.
92
92
  - Setup: needs the preset (alias + virtual stubs) or a Vite-RSC env (see `./setup.md`); a bare router import throws on Vite virtuals.
93
93
  - A router using `Prerender()`/`createLoader()`/`Static()` now constructs in a bare test (each assigns a runtime fallback `$$id`). Importing the whole router _file_ may still need the plugin (its page modules pull app deps / `virtual:` modules) — build from a focused include (your API routes) for whole-router dispatch.
94
+ - A `throw rewrite(target)` from global or route middleware re-dispatches as the router does: global middleware matched on the target's path, the target's route middleware and handler, the same request context, `x-rango-rewrite` on the response, 508 on a loop. The visitor's URL is matched before middleware runs, so it must be a response route (or a redirect or 404) too; a rewrite target that is a component route throws the same error as a direct request.
94
95
  - A `_rsc_partial` request to a response route runs global middleware first (an auth gate can still 401/redirect), then returns `X-RSC-Reload` — route-level middleware is skipped, exactly like production.
95
96
  - `dispatch` does NOT execute server actions (`?_rsc_action`), but it DOES run the global middleware chain on an action request — middleware can still 401/redirect it, and any 3xx redirect on a partial OR action request becomes a `204` + `X-RSC-Redirect` (fetch-safe interception), the raw `Location` dropped. Location state carried on such a redirect is dropped too (production embeds it in a Flight payload); cover that at e2e.
96
97
  - `createRouter({ onError })` is NOT invoked for a thrown non-Response HANDLER error here (the typed error Response still matches production), and `cache.decision` / `loader.*` telemetry never fires (no RSC render runs). Cover both at e2e.
@@ -14,7 +14,7 @@ The one stub is the HTML step (the SSR module), because `react-dom/server` does
14
14
 
15
15
  So `prelude` is the shell as captured (Flight text, not HTML): a value the shell froze appears there, and a hole (a live loader under `loading()`) does not.
16
16
 
17
- A `Prerender` route is served as in production: from the artifact `router.matchForPrerender` bakes for the URL (on its first request, kept until `resetShellTestState()`), through the production prerender store, so its handler runs once, at the bake, and never on a request. Its first `ppr` request is a MISS with a runtime capture, as a URL without a build-time shell is in production. The shell `vite build` bakes (served first in production for a URL without a query string) is not reproduced: keep that in e2e. The `env` option doubles as the bake's `buildEnv`. An on-demand route (`Prerender(..., { onDemand })`) is baked only when it is wrapped in `Passthrough()` and its `getParams()` lists the param, as a build bakes it; a plain on-demand route is not baked here, so its pages come from the prerender store alone (`router.prerender()`), and "a removed page the build baked" is a case for a `Passthrough` route or for e2e.
17
+ A `Prerender` route is served as in production: from the artifact `router.matchForPrerender` bakes for the URL (on its first request, kept until `resetShellTestState()`), through the production prerender store, so its handler runs once, at the bake, and never on a request. A partial navigation an `intercept()` of the route answers is served from the intercept variant the same bake keeps, as the build writes it: the intercept's handler output is the bake's, its loaders run per request. Its first `ppr` request is a MISS with a runtime capture, as a URL without a build-time shell is in production. The shell `vite build` bakes (served first in production for a URL without a query string) is not reproduced: keep that in e2e. The `env` option doubles as the bake's `buildEnv`. An on-demand route (`Prerender(..., { onDemand })`) is baked only when it is wrapped in `Passthrough()` and its `getParams()` lists the param, as a build bakes it; a plain on-demand route is not baked here, so its pages come from the prerender store alone (`router.prerender()`), and "a removed page the build baked" is a case for a `Passthrough` route or for e2e.
18
18
 
19
19
  ## API
20
20
 
@@ -392,6 +392,24 @@ const result = await serveShellRequest(router, "/shop", {
392
392
 
393
393
  This is the request a `clientUrls()` group's `revalidate()` sees on an action: the action's return value as `actionResult`, its last `FormData` argument as `formData`. The pinned example is `src/testing/__tests__/client-urls-revalidate.rsc-test.tsx`. Not with `partial` or `form`.
394
394
 
395
+ ## A page middleware rewrites
396
+
397
+ A request a middleware rewrites (`throw rewrite()`, see `/middleware`) runs through the same production handler: the visitor's pass, then the target's, with one request context. Assert what the visitor gets: the target's body and status, `x-rango-rewrite`, the visitor's pathname in the payload, and on a `ppr` target the target's shell key.
398
+
399
+ ```ts
400
+ it("a locked page renders the maintenance route at its own URL", async () => {
401
+ const result = await serveShellRequest(router, "/shop?lock=on");
402
+ expect(result.response.status).toBe(503); // set by the target's middleware
403
+ expect(result.response.headers.get("x-rango-rewrite")).toBe("/maintenance");
404
+ expect(result.body).toContain("Down for maintenance");
405
+
406
+ const nav = await serveShellRequest(router, "/shop?lock=on", {
407
+ partial: { from: "/" },
408
+ });
409
+ expect(nav.flight).toContain("Down for maintenance"); // the target's segments
410
+ });
411
+ ```
412
+
395
413
  ## Caveats
396
414
 
397
415
  What the HTML stub cannot reproduce, so keep it in e2e:
@@ -236,8 +236,10 @@ this guard, one user's data would be served to another.
236
236
  The raw reads throw the same way: `ctx.request.headers` on any `ctx` passed in
237
237
  (handler, loader, middleware; it is one `Request` object, also
238
238
  `getRequestContext().request`), and `getRequestContext().cookie()` /
239
- `.cookies()`. A `ctx` or a `Request` argument keys the entry by its route and
240
- URL only, never by its headers.
239
+ `.cookies()`. `ctx.rewrittenFrom` (the visitor's URL of a request middleware
240
+ rewrote) throws too, on a request nothing rewrote as well: it is not in the
241
+ key. A `ctx` or a `Request` argument keys the entry by its route and URL
242
+ only, never by its headers.
241
243
 
242
244
  Extract the value before the cached function and pass it as an argument:
243
245
 
@@ -19,6 +19,7 @@ import type {
19
19
  import type { DefaultRouteName } from "../types/global-namespace.js";
20
20
  import type { InterceptWhenFn } from "../server/context.js";
21
21
  import { href } from "../href-client.js";
22
+ import { rewrite, type Rewrite } from "../route-definition/rewrite.js";
22
23
  import type { Money, TestBindings } from "./augment.js";
23
24
 
24
25
  type Expect<T extends true> = T;
@@ -68,6 +69,25 @@ function middlewareReverse(ctx: MiddlewareContext): void {
68
69
  }
69
70
  void middlewareReverse;
70
71
 
72
+ // rewrite() takes a path, or a global name with the params and search
73
+ // ctx.reverse takes; ctx.isRewrite narrows a caught value to a Rewrite.
74
+ function middlewareRewrite(ctx: MiddlewareContext, caught: unknown): void {
75
+ void rewrite("/maintenance?from=shop");
76
+ void rewrite("home");
77
+ void rewrite("blog.post", { slug: "hello" });
78
+ void rewrite("search", {}, { q: "hats" });
79
+ // @ts-expect-error - a route with params needs them
80
+ void rewrite("blog.post");
81
+ // @ts-expect-error - unknown global name is rejected
82
+ void rewrite("nope");
83
+ // @ts-expect-error - dot-local name; a rewrite resolves global names only
84
+ void rewrite(".home");
85
+ if (ctx.isRewrite(caught)) {
86
+ type _narrowed = Expect<Equal<typeof caught, Rewrite>>;
87
+ }
88
+ }
89
+ void middlewareRewrite;
90
+
71
91
  // routeName narrows to the generated route names.
72
92
  type _routeName = Expect<
73
93
  Equal<DefaultRouteName, "home" | "blog.post" | "search">
@@ -1037,6 +1037,9 @@ export function createEventController(
1037
1037
  // If not settled and not completed, this is an error case - force finalize
1038
1038
  if (!settled && inflightActions.has(id)) {
1039
1039
  actionCompleted = true;
1040
+ // Done, as complete()/fail() would mark it: an action started in its
1041
+ // settle window is sequential, not concurrent (#675).
1042
+ entry.completed = true;
1040
1043
  streamingEnded = true;
1041
1044
  tryFinalize();
1042
1045
  }
@@ -1,12 +1,16 @@
1
1
  import type { ResolvedSegment } from "./types.js";
2
2
  import { debugLog, IS_BROWSER_DEBUG } from "./logging.js";
3
+ import { settledValue } from "../thenable-status.js";
3
4
 
4
5
  /**
5
6
  * Merge partial loader data from server with cached loader data.
6
7
  *
7
8
  * During partial revalidation (stale or action), the server may return only
8
- * some loaders that pass the revalidation check. The component still needs
9
- * all loader data, so we merge fresh data with cached data.
9
+ * some loaders that pass the revalidation check, none when every one is held.
10
+ * The component still needs all loader data, so we merge fresh data with
11
+ * cached data. Two settled aggregates merge into the array itself: a fresh
12
+ * Promise.all over settled parts is a promise React has not read, and content
13
+ * on screen handed one suspends (docs/internal/suspense-contract.md, rule 2).
10
14
  *
11
15
  * @param fromServer - Segment returned from server with partial loaders
12
16
  * @param fromCache - Cached segment with full loader data
@@ -25,24 +29,33 @@ export function mergeSegmentLoaders(
25
29
  );
26
30
  }
27
31
 
32
+ // Fresh data for the loaders the server re-ran, cached data for the rest.
33
+ const merge = (newData: any[], cachedData: any[]): any[] =>
34
+ cachedLoaderIds.map((id: string, i: number) => {
35
+ const newIndex = serverLoaderIds.indexOf(id);
36
+ return newIndex !== -1 ? newData[newIndex] : cachedData[i];
37
+ });
38
+ const settledNew = settledValue(fromServer.loaderDataPromise!);
39
+ const settledCached = settledValue(fromCache.loaderDataPromise!);
40
+
28
41
  return {
29
42
  ...fromCache,
30
- // Keep cached component (server's might be a fresh Promise that needs the loaders)
31
- component: fromCache.component,
32
- // Merge loader data - await both and combine
33
- loaderDataPromise: Promise.all([
34
- fromServer.loaderDataPromise!,
35
- fromCache.loaderDataPromise!,
36
- ]).then(([newData, cachedData]) => {
37
- // Build merged array: use new data for updated loaders, cached for rest
38
- return cachedLoaderIds.map((id: string, i: number) => {
39
- const newIndex = serverLoaderIds.indexOf(id);
40
- if (newIndex !== -1) {
41
- return (newData as any[])[newIndex]; // Use fresh data
42
- }
43
- return (cachedData as any[])[i]; // Use cached data
44
- });
45
- }),
43
+ // The server re-ran the slot's handler and layout: their render applies,
44
+ // as it does when every loader re-runs. Only the loaders it held keep
45
+ // their cached data.
46
+ component: fromServer.component,
47
+ layout: fromServer.layout,
48
+ // Every loader held: the cached aggregate itself, so a boundary still
49
+ // waiting on it keeps its one thenable (suspense-contract.md, rule 1).
50
+ loaderDataPromise:
51
+ serverLoaderIds.length === 0
52
+ ? fromCache.loaderDataPromise
53
+ : settledNew && settledCached
54
+ ? merge(settledNew, settledCached)
55
+ : Promise.all([
56
+ fromServer.loaderDataPromise!,
57
+ fromCache.loaderDataPromise!,
58
+ ]).then(([newData, cachedData]) => merge(newData, cachedData)),
46
59
  // Keep all loader IDs from cache
47
60
  loaderIds: fromCache.loaderIds,
48
61
  // The rebuilt aggregate is the source of truth. Cached per-loader
@@ -21,6 +21,7 @@ import {
21
21
  pushHistoryWithIdx,
22
22
  } from "./history-state.js";
23
23
  import { consumeHandles } from "./react/NavigationProvider.js";
24
+ import { withSegmentHandles } from "../handles/pushed-by.js";
24
25
  import {
25
26
  handleNavigationStart,
26
27
  handleNavigationEnd,
@@ -386,7 +387,9 @@ export function createNavigationBridge(
386
387
  // since it may have fresher data after an action revalidation.
387
388
  // This avoids unnecessary server round-trips for shared layout loaders.
388
389
  let cachedSegments = cached?.segments;
389
- const cachedHandleData = cached?.handleData;
390
+ // The segments taken from the page on screen: their pushes come from
391
+ // it too (targetCacheHandleData below).
392
+ const fromSource = new Set<string>();
390
393
  if (cachedSegments && sourceCached?.segments) {
391
394
  const sourceSegmentMap = new Map(
392
395
  sourceCached.segments.map((s) => [s.id, s]),
@@ -396,7 +399,9 @@ export function createNavigationBridge(
396
399
  // Use source (current page) version for shared segments - it's
397
400
  // fresher. A placeholder waiting for its fill (prefetch: false) is
398
401
  // not a copy of the segment: the target keeps its own.
399
- return sourceSeg && !isPlaceholder(sourceSeg) ? sourceSeg : targetSeg;
402
+ if (!sourceSeg || isPlaceholder(sourceSeg)) return targetSeg;
403
+ fromSource.add(targetSeg.id);
404
+ return sourceSeg;
400
405
  });
401
406
  }
402
407
 
@@ -498,7 +503,18 @@ export function createNavigationBridge(
498
503
  ...(hasUsableCache
499
504
  ? {
500
505
  targetCacheSegments: cachedSegments,
501
- targetCacheHandleData: cachedHandleData,
506
+ // Read at commit: a push the page on screen gets while
507
+ // the request is in flight is in it.
508
+ targetCacheHandleData: () =>
509
+ withSegmentHandles(
510
+ cached?.handleData ?? {},
511
+ liveHandleDataForCache(
512
+ eventController,
513
+ store,
514
+ sourceHistoryKey,
515
+ ),
516
+ fromSource,
517
+ ),
502
518
  }
503
519
  : {}),
504
520
  ...(clientUrlPresentation && {