@rangojs/router 0.21.0 → 0.22.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.
- package/dist/bin/rango.js +13 -1
- package/dist/types/browser/event-controller.d.ts +30 -13
- package/dist/types/browser/navigation-store.d.ts +9 -0
- package/dist/types/browser/partial-update.d.ts +2 -1
- package/dist/types/browser/prefetch/default-strategy.d.ts +16 -20
- package/dist/types/browser/react/NavigationProvider.d.ts +14 -1
- package/dist/types/browser/react/context.d.ts +14 -0
- package/dist/types/browser/rsc-router.d.ts +13 -2
- package/dist/types/browser/types.d.ts +10 -0
- package/dist/types/build/generate-manifest.d.ts +2 -0
- package/dist/types/cache/background-task.d.ts +22 -0
- package/dist/types/cache/cache-key-utils.d.ts +50 -5
- package/dist/types/cache/cache-policy.d.ts +17 -0
- package/dist/types/cache/cache-scope.d.ts +1 -1
- package/dist/types/cache/cf/cf-cache-store.d.ts +7 -1
- package/dist/types/cache/document-cache.d.ts +8 -5
- package/dist/types/cache/handle-snapshot.d.ts +23 -0
- package/dist/types/cache/index.d.ts +1 -1
- package/dist/types/cache/invalidation-order.d.ts +14 -0
- package/dist/types/cache/memory-segment-store.d.ts +33 -1
- package/dist/types/cache/shell-snapshot.d.ts +10 -1
- package/dist/types/cache/store-scope.d.ts +22 -0
- package/dist/types/cache/tag-invalidation.d.ts +7 -6
- package/dist/types/cache/types.d.ts +63 -14
- package/dist/types/cache/vercel/vercel-cache-store.d.ts +7 -1
- package/dist/types/errors.d.ts +6 -0
- package/dist/types/handles/deferred-resolution.d.ts +7 -0
- package/dist/types/internal-suspense-audit.d.ts +1 -0
- package/dist/types/loader-store.d.ts +4 -8
- package/dist/types/outlet-provider.d.ts +2 -1
- package/dist/types/prerender/cloudflare.d.ts +49 -0
- package/dist/types/prerender/create-prerender-trigger.d.ts +121 -0
- package/dist/types/prerender/index.d.ts +12 -0
- package/dist/types/prerender/memory-prerender-store.d.ts +29 -0
- package/dist/types/prerender/on-demand.d.ts +272 -0
- package/dist/types/prerender/producer-guard.d.ts +33 -0
- package/dist/types/prerender/shell-manifest-key.d.ts +1 -18
- package/dist/types/prerender/warm-request.d.ts +65 -0
- package/dist/types/prerender/warm.d.ts +65 -0
- package/dist/types/prerender/writable-store.d.ts +170 -0
- package/dist/types/prerender.d.ts +27 -0
- package/dist/types/reverse.d.ts +1 -1
- package/dist/types/route-content-wrapper.d.ts +3 -1
- package/dist/types/router/error-handling.d.ts +2 -0
- package/dist/types/router/handler-context.d.ts +1 -1
- package/dist/types/router/match-middleware/cache-lookup.d.ts +14 -0
- package/dist/types/router/pattern-matching.d.ts +2 -0
- package/dist/types/router/prefetch-default.d.ts +4 -4
- package/dist/types/router/prerender-match.d.ts +23 -1
- package/dist/types/router/route-trie-builder.d.ts +8 -1
- package/dist/types/router/router-context.d.ts +1 -0
- package/dist/types/router/router-interfaces.d.ts +30 -0
- package/dist/types/router/router-options.d.ts +24 -0
- package/dist/types/router/segment-resolution/fresh.d.ts +7 -0
- package/dist/types/router/segment-resolution/loader-cache.d.ts +2 -1
- package/dist/types/router/segment-resolution/revalidation.d.ts +5 -1
- package/dist/types/router/segment-resolution/streamed-handler-boundary.d.ts +43 -0
- package/dist/types/router/trie-matching.d.ts +2 -0
- package/dist/types/rsc/shell-build-manifest.d.ts +6 -4
- package/dist/types/rsc/shell-capture-constants.d.ts +20 -5
- package/dist/types/rsc/shell-capture.d.ts +14 -1
- package/dist/types/rsc/shell-serve.d.ts +2 -2
- package/dist/types/rsc/ssr-module-loader.d.ts +15 -0
- package/dist/types/rsc/types.d.ts +8 -0
- package/dist/types/segment-boundary-content.d.ts +17 -0
- package/dist/types/segment-loader-promise.d.ts +2 -6
- package/dist/types/server/build-version-table.d.ts +9 -0
- package/dist/types/server/context.d.ts +12 -4
- package/dist/types/server/handle-store.d.ts +2 -0
- package/dist/types/server/request-context.d.ts +63 -2
- package/dist/types/suspense-audit-react.d.ts +26 -0
- package/dist/types/suspense-audit.d.ts +157 -0
- package/dist/types/testing/action-form.d.ts +34 -0
- package/dist/types/testing/flight.entry.d.ts +2 -0
- package/dist/types/testing/index.d.ts +4 -0
- package/dist/types/testing/internal/server-modules.d.ts +8 -0
- package/dist/types/testing/serve-shell-request.d.ts +34 -4
- package/dist/types/testing/shell-status.d.ts +14 -6
- package/dist/types/testing/vitest-stubs/plugin-rsc.d.ts +9 -6
- package/dist/types/thenable-status.d.ts +6 -0
- package/dist/types/types/handler-context.d.ts +7 -0
- package/dist/types/types/route-entry.d.ts +5 -0
- package/dist/types/urls/path-helper.d.ts +18 -0
- package/dist/types/vite/discovery/state.d.ts +11 -0
- package/dist/types/vite/inject-client-debug.d.ts +6 -25
- package/dist/types/vite/utils/bundle-analysis.d.ts +6 -2
- package/dist/vite/index.js +180 -497
- package/package.json +13 -3
- package/skills/cache-guide/SKILL.md +18 -17
- package/skills/caching/SKILL.md +40 -4
- package/skills/catalog.json +1 -1
- package/skills/cloudflare/SKILL.md +131 -0
- package/skills/comparison/references/framework-comparison.md +1 -1
- package/skills/deployment-caching/SKILL.md +33 -12
- package/skills/document-cache/SKILL.md +16 -14
- package/skills/hooks/SKILL.md +22 -0
- package/skills/hooks/handle-and-actions.md +3 -4
- package/skills/hooks/navigation.md +10 -0
- package/skills/hooks/state.md +5 -4
- package/skills/host-router/SKILL.md +18 -0
- package/skills/loader/SKILL.md +6 -4
- package/skills/middleware/SKILL.md +1 -1
- package/skills/migrate-nextjs/SKILL.md +82 -9
- package/skills/ppr/SKILL.md +31 -8
- package/skills/prerender/SKILL.md +645 -8
- package/skills/rango/SKILL.md +1 -0
- package/skills/response-routes/SKILL.md +5 -5
- package/skills/route/SKILL.md +29 -0
- package/skills/testing/SKILL.md +2 -1
- package/skills/testing/cache-prerender.md +18 -17
- package/skills/testing/serve-shell-request.md +104 -8
- package/skills/use-cache/SKILL.md +9 -5
- package/skills/vercel/SKILL.md +2 -0
- package/skills/view-transitions/SKILL.md +9 -4
- package/src/browser/event-controller.ts +69 -25
- package/src/browser/link-interceptor.ts +2 -2
- package/src/browser/navigation-bridge.ts +75 -35
- package/src/browser/navigation-store.ts +26 -0
- package/src/browser/navigation-transaction.ts +9 -2
- package/src/browser/network-error-handler.ts +2 -0
- package/src/browser/partial-update.ts +48 -12
- package/src/browser/prefetch/default-strategy.ts +22 -42
- package/src/browser/react/Link.tsx +37 -42
- package/src/browser/react/NavigationProvider.tsx +63 -3
- package/src/browser/react/context.ts +17 -0
- package/src/browser/react/location-state.ts +5 -19
- package/src/browser/react/use-action.ts +4 -1
- package/src/browser/react/use-handle.ts +4 -20
- package/src/browser/react/use-link-status.ts +4 -3
- package/src/browser/react/use-navigation.ts +4 -1
- package/src/browser/react/use-params.ts +4 -1
- package/src/browser/react/use-pathname.ts +4 -1
- package/src/browser/react/use-search-params.ts +5 -3
- package/src/browser/react/use-segments.ts +36 -54
- package/src/browser/rsc-router.tsx +79 -39
- package/src/browser/segment-reconciler.ts +3 -3
- package/src/browser/server-action-bridge.ts +26 -6
- package/src/browser/types.ts +11 -0
- package/src/build/generate-manifest.ts +17 -0
- package/src/build/merge-full-manifests.ts +5 -0
- package/src/build/route-trie.ts +1 -0
- package/src/cache/background-task.ts +61 -0
- package/src/cache/cache-key-utils.ts +91 -12
- package/src/cache/cache-policy.ts +11 -2
- package/src/cache/cache-runtime.ts +23 -23
- package/src/cache/cache-scope.ts +38 -36
- package/src/cache/cf/cf-cache-store.ts +16 -11
- package/src/cache/document-cache.ts +35 -14
- package/src/cache/handle-snapshot.ts +50 -0
- package/src/cache/index.ts +1 -0
- package/src/cache/invalidation-order.ts +53 -0
- package/src/cache/memory-segment-store.ts +52 -1
- package/src/cache/shell-snapshot.ts +17 -0
- package/src/cache/store-scope.ts +36 -0
- package/src/cache/tag-invalidation.ts +7 -5
- package/src/cache/types.ts +69 -14
- package/src/cache/vercel/vercel-cache-store.ts +20 -6
- package/src/client.tsx +40 -3
- package/src/errors.ts +13 -6
- package/src/handles/deferred-resolution.ts +7 -2
- package/src/internal-suspense-audit.ts +12 -0
- package/src/loader-store.ts +4 -9
- package/src/outlet-provider.tsx +25 -1
- package/src/prerender/cloudflare.ts +130 -0
- package/src/prerender/create-prerender-trigger.ts +780 -0
- package/src/prerender/index.ts +39 -0
- package/src/prerender/memory-prerender-store.ts +115 -0
- package/src/prerender/on-demand.ts +339 -0
- package/src/prerender/producer-guard.ts +66 -0
- package/src/prerender/shell-manifest-key.ts +12 -17
- package/src/prerender/warm-request.ts +154 -0
- package/src/prerender/warm.ts +193 -0
- package/src/prerender/writable-store.ts +278 -0
- package/src/prerender.ts +29 -0
- package/src/reverse.ts +1 -1
- package/src/route-content-wrapper.tsx +107 -13
- package/src/router/error-handling.ts +12 -12
- package/src/router/find-match.ts +1 -0
- package/src/router/handler-context.ts +2 -0
- package/src/router/intercept-resolution.ts +15 -8
- package/src/router/loader-resolution.ts +2 -10
- package/src/router/match-middleware/background-revalidation.ts +2 -0
- package/src/router/match-middleware/cache-lookup.ts +230 -38
- package/src/router/pattern-matching.ts +14 -0
- package/src/router/prefetch-default.ts +4 -4
- package/src/router/prerender-match.ts +460 -352
- package/src/router/route-trie-builder.ts +9 -0
- package/src/router/router-context.ts +1 -0
- package/src/router/router-interfaces.ts +33 -0
- package/src/router/router-options.ts +30 -0
- package/src/router/segment-resolution/fresh.ts +20 -19
- package/src/router/segment-resolution/loader-cache.ts +15 -17
- package/src/router/segment-resolution/revalidation.ts +34 -24
- package/src/router/segment-resolution/streamed-handler-boundary.ts +133 -0
- package/src/router/trie-matching.ts +3 -0
- package/src/router.ts +107 -1
- package/src/rsc/handler.ts +51 -4
- package/src/rsc/progressive-enhancement.ts +5 -1
- package/src/rsc/response-cache-serve.ts +10 -4
- package/src/rsc/rsc-rendering.ts +53 -11
- package/src/rsc/shell-build-manifest.ts +10 -8
- package/src/rsc/shell-capture-constants.ts +39 -7
- package/src/rsc/shell-capture.ts +108 -19
- package/src/rsc/shell-serve.ts +12 -6
- package/src/rsc/ssr-module-loader.ts +26 -0
- package/src/rsc/types.ts +8 -0
- package/src/segment-boundary-content.ts +25 -0
- package/src/segment-loader-promise.ts +28 -38
- package/src/segment-system.tsx +117 -57
- package/src/server/build-version-table.ts +15 -0
- package/src/server/context.ts +40 -17
- package/src/server/cookie-store.ts +5 -0
- package/src/server/handle-store.ts +13 -0
- package/src/server/loader-registry.ts +6 -1
- package/src/server/request-context.ts +87 -1
- package/src/ssr/ssr-root.tsx +20 -13
- package/src/suspense-audit-react.tsx +98 -0
- package/src/suspense-audit.ts +1030 -0
- package/src/testing/action-form.ts +66 -0
- package/src/testing/flight-runtime.d.ts +27 -0
- package/src/testing/flight.entry.ts +3 -0
- package/src/testing/index.ts +19 -0
- package/src/testing/internal/server-modules.ts +32 -0
- package/src/testing/render-route.tsx +25 -12
- package/src/testing/serve-shell-request.ts +190 -34
- package/src/testing/shell-status.ts +17 -6
- package/src/testing/vitest-stubs/plugin-rsc.ts +37 -11
- package/src/thenable-status.ts +10 -0
- package/src/types/handler-context.ts +7 -0
- package/src/types/route-entry.ts +6 -0
- package/src/urls/path-helper.ts +69 -9
- package/src/use-loader.tsx +57 -30
- package/src/vite/discovery/bundle-postprocess.ts +4 -0
- package/src/vite/discovery/discover-routers.ts +15 -0
- package/src/vite/discovery/shell-prerender-phase.ts +5 -5
- package/src/vite/discovery/state.ts +17 -1
- package/src/vite/inject-client-debug.ts +42 -20
- package/src/vite/plugins/virtual-entries.ts +8 -1
- package/src/vite/router-discovery.ts +14 -0
- package/src/vite/utils/bundle-analysis.ts +39 -7
- package/dist/types/browser/react/subscribe-to-nothing.d.ts +0 -7
- package/dist/types/segment-content-promise.d.ts +0 -13
- package/src/browser/react/subscribe-to-nothing.ts +0 -7
- package/src/segment-content-promise.ts +0 -67
package/skills/rango/SKILL.md
CHANGED
|
@@ -116,6 +116,7 @@ stated, greppable contract.
|
|
|
116
116
|
| set meta/breadcrumbs from loader data | `ctx.use(Handle)` in the loader | /loader |
|
|
117
117
|
| guarantee loader output in the SSR HTML | `loader(L, { ssr: false })` | /loader |
|
|
118
118
|
| pre-render a route at build time | `Prerender(...)` wrapper | /prerender |
|
|
119
|
+
| make a route ready before traffic | `router.prerender({ env })(url)` | /prerender |
|
|
119
120
|
| feed live loaders from a cached shell | replayed handle + `ctx.rendered()` | /shell-manifest |
|
|
120
121
|
| cache HTML shell, live loaders as holes | `ppr` path option | /ppr |
|
|
121
122
|
| choose in-function vs CDN caching | deployment cache boundary | /deployment-caching |
|
|
@@ -422,11 +422,11 @@ path.json("/api/users", handler, { name: "users" }, () => [
|
|
|
422
422
|
Response-route `cache()` stores whole `Response`s. The serve leaf
|
|
423
423
|
(`serveResponseRouteWithCache`) applies the same safety gates as document cache:
|
|
424
424
|
|
|
425
|
-
| Gate | Behavior
|
|
426
|
-
| ---------------------- |
|
|
427
|
-
| **GET/HEAD only** | Non-GET/HEAD methods skip the cache (handler runs uncached).
|
|
428
|
-
| **Per-client signals** | A response with `Set-Cookie` or `x-rango-keep-cache` is returned live but **not** stored (MISS + SWR revalidation also skip put).
|
|
429
|
-
| **Default key** | `response:{type}:{
|
|
425
|
+
| Gate | Behavior |
|
|
426
|
+
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
427
|
+
| **GET/HEAD only** | Non-GET/HEAD methods skip the cache (handler runs uncached). |
|
|
428
|
+
| **Per-client signals** | A response with `Set-Cookie` or `x-rango-keep-cache` is returned live but **not** stored (MISS + SWR revalidation also skip put). |
|
|
429
|
+
| **Default key** | `response:{type}:{routerId}@{host}{path}[?search]` — the serving router's id, sorted search, reserved `_rsc*` / allowlisted `__*` params excluded. Custom `key()` / store `keyGenerator` still override. |
|
|
430
430
|
|
|
431
431
|
## CSRF
|
|
432
432
|
|
package/skills/route/SKILL.md
CHANGED
|
@@ -404,6 +404,35 @@ path("/checkout", CheckoutPage, { name: "checkout" }, () => [
|
|
|
404
404
|
]);
|
|
405
405
|
```
|
|
406
406
|
|
|
407
|
+
## Errors in a streamed handler
|
|
408
|
+
|
|
409
|
+
A handler under `loading()` (a route, or a parallel slot) streams: it settles
|
|
410
|
+
after the response started. When it rejects, the nearest `errorBoundary()`
|
|
411
|
+
renders in place of that segment; `notFound()` renders the nearest
|
|
412
|
+
`notFoundBoundary()`. A slot finds boundaries declared on its owning layout or
|
|
413
|
+
route. Layouts and nav stay, on the document load and on a client navigation.
|
|
414
|
+
|
|
415
|
+
```typescript
|
|
416
|
+
path("/product/:slug", ProductPage, { name: "product" }, () => [
|
|
417
|
+
loading(<ProductSkeleton />),
|
|
418
|
+
errorBoundary(({ error }) => <ProductError message={error.message} />),
|
|
419
|
+
notFoundBoundary(() => <ProductNotFound />),
|
|
420
|
+
]);
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- The HTTP status stays what the stream already sent (200), `notFound()`
|
|
424
|
+
included; a failure before the flush (no `loading()`, or
|
|
425
|
+
`loading(fallback, { ssr: false })` on a document load) still sets 500 / 404.
|
|
426
|
+
A loader's 404 is likewise only set when its rejection settles before the
|
|
427
|
+
response is built.
|
|
428
|
+
- `onError` fires once per failure.
|
|
429
|
+
- A failed render is never stored: `cache()` skips the write for the scope that
|
|
430
|
+
holds the failed segment, a `ppr` shell and the document cache refuse the
|
|
431
|
+
whole document, and a Prerender route still fails the build.
|
|
432
|
+
- With no declared boundary the rejection reaches the client and
|
|
433
|
+
`RootErrorBoundary` replaces the page. A `redirect()` thrown from a streamed
|
|
434
|
+
handler is not recovered either (see "Redirects").
|
|
435
|
+
|
|
407
436
|
## Redirects
|
|
408
437
|
|
|
409
438
|
`redirect(url, statusOrOptions?)` returns a `Response` (default status `302`).
|
package/skills/testing/SKILL.md
CHANGED
|
@@ -115,6 +115,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
|
|
|
115
115
|
| a loader's own `cache()` (HIT, `updateTag()` of a tag its body recorded) | RSC unit | [`runLoader` + `cache` + seeded `cacheStore`](./loader.md) | `@rangojs/router/testing` |
|
|
116
116
|
| cache hit/miss/stale, prerender (= a cache hit by design) | e2e + signal | [`assertCacheStatus` (header) / `assertCacheDecision` (telemetry sink)](./cache-prerender.md) | `@rangojs/router/testing[/e2e]` |
|
|
117
117
|
| a `ppr` route's shell: MISS -> capture -> HIT, what the shell froze vs what a HIT renders live, tag eviction, navigation replay | RSC unit | [`serveShellRequest`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
|
|
118
|
+
| a form submitted before hydration: a `useActionState` result the no-JS POST renders, the form state its payload carries | RSC unit | [`createActionForm` + `serveShellRequest({ form })`](./serve-shell-request.md) | `@rangojs/router/testing/flight` |
|
|
118
119
|
| PPR shell HIT/MISS in real HTML (`ppr` path option), shell store key / getShell after flush | e2e + signal | [`assertShellStatus` / `shellCacheKey` + store](./cache-prerender.md#ppr-shell-and-navigation-replay) | `@rangojs/router/testing[/e2e]` |
|
|
119
120
|
| generated route map drift vs runtime | unit (node) | [`assertGeneratedRoutesMatch`](./reverse-and-types.md) | `@rangojs/router/testing` |
|
|
120
121
|
| a platform binding (`env.DB` / Durable Object / `env.R2`) | unit/integr. | [your own double via `env`](./bindings.md) | (any primitive's `env` option) |
|
|
@@ -158,7 +159,7 @@ beside their function):
|
|
|
158
159
|
| `@rangojs/router/testing/flight` | `renderToFlightString`, `normalizeFlight`, `assertFlightRuntimeAvailable` | [`flight.md`](./flight.md) |
|
|
159
160
|
| | `renderServerTree`, `findClientBoundaries`, `findElements`, `textContent`, `assertFlightTreeRuntimeAvailable` | [`server-tree.md`](./server-tree.md) |
|
|
160
161
|
| | `renderHandler` | [`render-handler.md`](./render-handler.md) |
|
|
161
|
-
| | `serveShellRequest`, `resetShellTestState`
|
|
162
|
+
| | `serveShellRequest`, `resetShellTestState`, `createActionForm` | [`serve-shell-request.md`](./serve-shell-request.md) |
|
|
162
163
|
| `@rangojs/router/testing/flight-matchers` | `flightMatchers` (`toMatchFlight`, `toMatchFlightSnapshot`) | [`flight.md`](./flight.md) |
|
|
163
164
|
| `@rangojs/router/testing/vitest` | `rangoTestConfig`, `rangoTestAliases`, `rangoInlineDeps`, `rangoUseClientTransform`, `rangoUseCacheTransform` | [`setup.md`](./setup.md) |
|
|
164
165
|
| `@rangojs/router/testing/e2e` | `createRangoE2E` and its standalone helpers; the cache/shell status helpers above are re-exported here | [`e2e-parity.md`](./e2e-parity.md) |
|
|
@@ -121,25 +121,25 @@ expect(decision.segments?.[0].shouldRevalidate).toBe(true);
|
|
|
121
121
|
|
|
122
122
|
**DSL:** `ppr: true | PartialPrerenderProps` on a page route (see `/ppr`). **Not** the same header as `X-Rango-Cache` — the shell is cached separately from the segments.
|
|
123
123
|
|
|
124
|
-
| Helper
|
|
125
|
-
|
|
|
126
|
-
| `assertShellStatus(res, "HIT" \| "MISS")`
|
|
127
|
-
| `parseShellStatus(res)`
|
|
128
|
-
| `shellCacheKey(url, searchParams?, partition?)` | same | Production shell store key (`host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` partitions its record, pass that function's result for the request as `partition`: the shell is partitioned the same way, the result namespaced as production stores it (`key:` plus its URI encoding). Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. For a store `keyGenerator` that partitions it, pass `{ keys, generated }` (`ShellCachePartition`): `generated` lists the `keyGenerator` results one per store, outermost first: leave them out when every one returns the default key, else keep each position and pass `""` for one that returns it. |
|
|
129
|
-
| `SHELL_STATUS_HEADER`
|
|
130
|
-
| `assertPprReplayStatus(res, expected)`
|
|
131
|
-
| `parsePprReplayStatus(res)`
|
|
132
|
-
| `PPR_REPLAY_STATUS_HEADER`
|
|
124
|
+
| Helper | Import | Role |
|
|
125
|
+
| ------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
126
|
+
| `assertShellStatus(res, "HIT" \| "MISS")` | `@rangojs/router/testing` or `…/e2e` | Assert `x-rango-shell` on a **real document** Response |
|
|
127
|
+
| `parseShellStatus(res)` | same | `"HIT" \| "MISS" \| null` (null = header absent / unrecognized) |
|
|
128
|
+
| `shellCacheKey(router, url, searchParams?, partition?)` | same | Production shell store key (`routerId@host+pathname+sorted search+:shell`, then `\|partition`) for `store.getShell` / custom stores. `router` is the router that serves the URL, or `{ id }` where it cannot be imported (a Playwright test): a shell belongs to one router. Pass your router's `cache.searchParams` config as the second argument when it sets one — the production key applies it. For a route whose `cache({ key })` partitions its record, pass that function's result for the request as `partition`: the shell is partitioned the same way, the result namespaced as production stores it (`key:` plus its URI encoding). Under nested keyed `cache()` boundaries, pass their `key()` results as an array, outermost first. For a store `keyGenerator` that partitions it, pass `{ keys, generated }` (`ShellCachePartition`): `generated` lists the `keyGenerator` results one per store, outermost first: leave them out when every one returns the default key, else keep each position and pass `""` for one that returns it. |
|
|
129
|
+
| `SHELL_STATUS_HEADER` | same | `"x-rango-shell"` constant |
|
|
130
|
+
| `assertPprReplayStatus(res, expected)` | same | Assert fresh/stale replay or a bounded bypass decision |
|
|
131
|
+
| `parsePprReplayStatus(res)` | same | Structured replay/bypass status, or null for an absent/unrecognized header |
|
|
132
|
+
| `PPR_REPLAY_STATUS_HEADER` | same | `"x-rango-ppr-replay"` constant |
|
|
133
133
|
|
|
134
134
|
### What unit can prove vs e2e
|
|
135
135
|
|
|
136
|
-
| Layer | What you assert | How
|
|
137
|
-
| -------- | --------------------------- |
|
|
138
|
-
| **Unit** | store family + key identity | `MemorySegmentCacheStore` + `shellCacheKey(url)` + `putShell`/`getShell` / tag eviction — dogfood in `e2e/mini/test/shell-store-family.test.ts` and `src/testing/__tests__/shell-status.test.ts` |
|
|
139
|
-
| **Unit** | header helper contract | `assertShellStatus` on a Response that already carries the header (characterizes the helper; **never** invent a HIT to claim capture worked)
|
|
140
|
-
| **Unit** | real capture → HIT, replay | `serveShellRequest(router, url, { cacheStore })` (react-server project); only the HTML step is stubbed — [`./serve-shell-request.md`](./serve-shell-request.md)
|
|
141
|
-
| **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts`
|
|
142
|
-
| **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })`
|
|
136
|
+
| Layer | What you assert | How |
|
|
137
|
+
| -------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
138
|
+
| **Unit** | store family + key identity | `MemorySegmentCacheStore` + `shellCacheKey(router, url)` + `putShell`/`getShell` / tag eviction — dogfood in `e2e/mini/test/shell-store-family.test.ts` and `src/testing/__tests__/shell-status.test.ts` |
|
|
139
|
+
| **Unit** | header helper contract | `assertShellStatus` on a Response that already carries the header (characterizes the helper; **never** invent a HIT to claim capture worked) |
|
|
140
|
+
| **Unit** | real capture → HIT, replay | `serveShellRequest(router, url, { cacheStore })` (react-server project); only the HTML step is stubbed — [`./serve-shell-request.md`](./serve-shell-request.md) |
|
|
141
|
+
| **E2E** | live MISS → capture → HIT | document GET, poll until `x-rango-shell: HIT` (background capture) — `e2e/shell-cache.test.ts` |
|
|
142
|
+
| **E2E** | partial replay decision | soft navigation to a warmed shell and `assertPprReplayStatus(response, { outcome: "HIT", freshness: "fresh" })` |
|
|
143
143
|
|
|
144
144
|
`dispatch` is RSC-free: it never runs shell serve/capture. `renderHandler` only surfaces `ctx.dynamic()` opt-out, not bake/serve. The HIT contract — no handler runs, the handler output is the capture's copy, live loaders re-run — is asserted through `serveShellRequest`: count your handler's runs and a live loader's across a MISS and the HITs after it, and check that only the loader's moves ([`./serve-shell-request.md`](./serve-shell-request.md)). A route partitioned by `cache({ key })` or a store `keyGenerator` has a shell per partition: `serveShellRequest` reports the key the serve path resolved (`result.key`), and `shellCacheKey` takes the partition as its third argument.
|
|
145
145
|
|
|
@@ -175,10 +175,11 @@ parityDescribe("product shell", (f) => {
|
|
|
175
175
|
// Unit (Vitest): the key the serve path uses, against your own store instance
|
|
176
176
|
import { expect, it } from "vitest";
|
|
177
177
|
import { shellCacheKey } from "@rangojs/router/testing";
|
|
178
|
+
import { router } from "../src/router";
|
|
178
179
|
|
|
179
180
|
it("stores the shell under the production key", async () => {
|
|
180
181
|
// ...after your code under test captured a shell into `store`...
|
|
181
|
-
const key = shellCacheKey(new URL("http://localhost/products/1"));
|
|
182
|
+
const key = shellCacheKey(router, new URL("http://localhost/products/1"));
|
|
182
183
|
expect(await store.getShell(key)).not.toBeNull();
|
|
183
184
|
});
|
|
184
185
|
```
|
|
@@ -14,6 +14,8 @@ 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.
|
|
18
|
+
|
|
17
19
|
## API
|
|
18
20
|
|
|
19
21
|
### Options — `ServeShellRequestOptions`
|
|
@@ -41,7 +43,7 @@ So `prelude` is the shell as captured (Flight text, not HTML): a value the shell
|
|
|
41
43
|
|
|
42
44
|
### `resetShellTestState(): Promise<void>`
|
|
43
45
|
|
|
44
|
-
A worker keeps some PPR state across requests, and so does the test process across tests: the capture's stampede guard and backoff (a refused capture backs its URL off for later tests too), the capture's and the serve path's once-per-key warnings, the build-shell manifest memo, and `CFCacheStore`'s isolate memos (shells, tag markers, tag hints), which every `CFCacheStore` shares by namespace and URL — a later test's first request can be a HIT from an earlier test's shell. `resetShellTestState()` clears all of it. Call it in `beforeEach`, never while a request is in flight. `VercelCacheStore`'s memos live on the `cache` handle you pass it: a new handle per test starts empty.
|
|
46
|
+
A worker keeps some PPR state across requests, and so does the test process across tests: the capture's stampede guard and backoff (a refused capture backs its URL off for later tests too), the capture's and the serve path's once-per-key warnings, the build-shell manifest memo, the `Prerender` artifacts baked so far, and `CFCacheStore`'s isolate memos (shells, tag markers, tag hints), which every `CFCacheStore` shares by namespace and URL — a later test's first request can be a HIT from an earlier test's shell. `resetShellTestState()` clears all of it. Call it in `beforeEach`, never while a request is in flight. `VercelCacheStore`'s memos live on the `cache` handle you pass it: a new handle per test starts empty.
|
|
45
47
|
|
|
46
48
|
## Recipe
|
|
47
49
|
|
|
@@ -164,9 +166,12 @@ const silver = await serveShellRequest(router, "/pricing", tier("silver"));
|
|
|
164
166
|
expect(silver.shellStatus).toBe("MISS"); // its own capture, never gold's shell
|
|
165
167
|
const gold = await serveShellRequest(router, "/pricing", tier("gold"));
|
|
166
168
|
expect(gold.shellStatus).toBe("HIT");
|
|
167
|
-
expect(gold.key).toBe(
|
|
168
|
-
|
|
169
|
-
|
|
169
|
+
expect(gold.key).toBe(
|
|
170
|
+
shellCacheKey(router, "/pricing", undefined, "tier:gold"),
|
|
171
|
+
);
|
|
172
|
+
// "{router.id}@localhost/pricing:shell|key%3Atier%253Agold": the router the
|
|
173
|
+
// shell belongs to, then the key() result namespaced as production stores it
|
|
174
|
+
// (issue #975).
|
|
170
175
|
expect(await gold.readEntry()).not.toBeNull();
|
|
171
176
|
```
|
|
172
177
|
|
|
@@ -179,7 +184,7 @@ Under nested keyed `cache()` boundaries the partition is their `key()` results c
|
|
|
179
184
|
// ]),
|
|
180
185
|
// ])
|
|
181
186
|
expect(result.key).toBe(
|
|
182
|
-
shellCacheKey("/plans", undefined, ["tier:gold", "v:a"]),
|
|
187
|
+
shellCacheKey(router, "/plans", undefined, ["tier:gold", "v:a"]),
|
|
183
188
|
);
|
|
184
189
|
```
|
|
185
190
|
|
|
@@ -189,14 +194,33 @@ A store `keyGenerator` result that partitions the record partitions the shell to
|
|
|
189
194
|
// createRouter({ cache: { store: localeStore } }), where localeStore's
|
|
190
195
|
// keyGenerator returns `${defaultKey}|${locale}`
|
|
191
196
|
expect(result.key).toBe(
|
|
192
|
-
shellCacheKey("/pricing", undefined, {
|
|
193
|
-
generated: [
|
|
197
|
+
shellCacheKey(router, "/pricing", undefined, {
|
|
198
|
+
generated: [`doc:${router.id}@localhost/pricing|de`],
|
|
194
199
|
}),
|
|
195
200
|
);
|
|
196
201
|
```
|
|
197
202
|
|
|
198
203
|
A partial request has no HTML step, so its `key` is the URL's key without a partition: read a partitioned route's entry from a document request's result.
|
|
199
204
|
|
|
205
|
+
## Two routers on one cache store
|
|
206
|
+
|
|
207
|
+
A shell's key starts with its router's id (`shellCacheKey(router, url)`), so two routers that share a store and serve the same host and path each read and write their own (why and when: `/host-router`, "Shared cache store"). Serve both against one store to pin it:
|
|
208
|
+
|
|
209
|
+
```ts
|
|
210
|
+
const store = new MemorySegmentCacheStore();
|
|
211
|
+
// appA and appB: createRouter({ id: "app-a" | "app-b", cache: { store } }),
|
|
212
|
+
// each with path("/pricing", ..., { ppr: true })
|
|
213
|
+
const url = "http://preview.dev/pricing";
|
|
214
|
+
|
|
215
|
+
await serveShellRequest(appA, url); // MISS + capture
|
|
216
|
+
const fromB = await serveShellRequest(appB, url);
|
|
217
|
+
expect(fromB.shellStatus).toBe("MISS"); // never app A's shell
|
|
218
|
+
expect((await serveShellRequest(appA, url)).shellStatus).toBe("HIT");
|
|
219
|
+
expect((await serveShellRequest(appB, url)).shellStatus).toBe("HIT");
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
Vitest runs no Vite id transform, so give each router an explicit `id` when a test builds more than one, and do not reuse one `id` for routers with different routes in the same file: the route manifest is registered per id.
|
|
223
|
+
|
|
200
224
|
## Handles and loader data
|
|
201
225
|
|
|
202
226
|
`readHandles()` decodes the handle data the response carries, the way the browser reads it, with deferred values resolved. Each field is the raw handle data (`{ [handleId]: { [segmentId]: values[] } }`):
|
|
@@ -226,6 +250,78 @@ Loader data has no decoder: it rides the payload, so assert it as text in `prelu
|
|
|
226
250
|
expect(hit.flight).toContain('"stock":"in stock"');
|
|
227
251
|
```
|
|
228
252
|
|
|
253
|
+
## Warming a route: router.prerender()
|
|
254
|
+
|
|
255
|
+
`router.prerender()` warms a route that is not `Prerender(..., { onDemand })`
|
|
256
|
+
through `router.fetch` (`/prerender` → "Warm any route before traffic").
|
|
257
|
+
Importing `serveShellRequest` gives that handler the same HTML stub, so the
|
|
258
|
+
whole path runs here: warm, then serve the next visitor's request.
|
|
259
|
+
|
|
260
|
+
```tsx
|
|
261
|
+
class SharedMemoryStore extends MemorySegmentCacheStore {
|
|
262
|
+
// The shipped store is "local" and refused; a test store says it is shared.
|
|
263
|
+
readonly scope = "global" as const;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const router = createRouter({
|
|
267
|
+
cache: { store: new SharedMemoryStore() },
|
|
268
|
+
}).routes(
|
|
269
|
+
urls(({ path }) => [path("/product/:id", ProductPage, { ppr: true })]),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const result = await router.prerender({ env: {} })(
|
|
273
|
+
"http://localhost/product/1",
|
|
274
|
+
);
|
|
275
|
+
expect(result).toMatchObject({ ok: true, path: "warm", status: "warmed" });
|
|
276
|
+
|
|
277
|
+
// No visitor has requested the URL: its first document is a HIT.
|
|
278
|
+
const hit = await serveShellRequest(router, "/product/1");
|
|
279
|
+
expect(hit.shellStatus).toBe("HIT");
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
- Configure the store on the router. A warm writes to the store
|
|
283
|
+
`createRouter({ cache })` resolves, not to the `cacheStore` option of
|
|
284
|
+
`serveShellRequest`.
|
|
285
|
+
- Give the target the origin `serveShellRequest` uses (`http://localhost`), as
|
|
286
|
+
a full URL or with `router.prerender({ env, origin })`: cache keys carry the
|
|
287
|
+
host. A path target with neither returns `skipped-no-origin`, unless the
|
|
288
|
+
runner is called from inside a served request (a route handler that calls
|
|
289
|
+
`router.prerender()`), where it takes that request's origin.
|
|
290
|
+
- To assert a replace, count handler runs or render a per-run stamp: fill the
|
|
291
|
+
cache with one request, move the data, warm, then check the next request
|
|
292
|
+
shows the new value and ran no handler.
|
|
293
|
+
- `router.fetch` binds its handler's document version once, as a production
|
|
294
|
+
isolate does, so `setBuildVersions()` between two warms does not move it.
|
|
295
|
+
|
|
296
|
+
## A form submitted before hydration
|
|
297
|
+
|
|
298
|
+
`serveShellRequest(router, url, { form })` POSTs a form the way a browser does before the page hydrates, through the production no-JS path: the action runs, the page re-renders, and for a `useActionState` form the document payload carries the form state. Build the body with `createActionForm(action, { id })` from the same entry; it writes React's hidden fields and registers `action` under `id` (`<module id>#<export name>`, any unique string of that shape in a test).
|
|
299
|
+
|
|
300
|
+
```tsx
|
|
301
|
+
import {
|
|
302
|
+
createActionForm,
|
|
303
|
+
serveShellRequest,
|
|
304
|
+
} from "@rangojs/router/testing/flight";
|
|
305
|
+
|
|
306
|
+
const form = createActionForm(makeToken, { id: "actions/token#makeToken" });
|
|
307
|
+
const result = await serveShellRequest(router, "/token", { form });
|
|
308
|
+
|
|
309
|
+
// What the SSR render received, and what the browser entry hands hydrateRoot:
|
|
310
|
+
expect(result.formState).toEqual([
|
|
311
|
+
{ token: "tok-1" },
|
|
312
|
+
"k0",
|
|
313
|
+
"actions/token#makeToken",
|
|
314
|
+
0,
|
|
315
|
+
]);
|
|
316
|
+
expect(await result.readPayloadFormState()).toEqual(result.formState);
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
- `useActionState: false` builds a plain `<form action={fn}>` POST (`$ACTION_ID_`): the action runs as `(formData)` and no form state is carried. Default true runs `(prevState, formData)` with the hook's initial `null`. `fields` are the form's own fields (`formData.get("name")`).
|
|
320
|
+
- An action returning a state Flight cannot serialize (a class instance) still renders the document with status 200 and `formState` holds the raw value, but `readPayloadFormState()` rejects: the entry catches that and hydrates without it, as before the fix.
|
|
321
|
+
- A GET, a plain-form POST and a redirecting action carry no form state: `formState` and `readPayloadFormState()` are undefined, a redirect has no `flight`.
|
|
322
|
+
- Request headers come from `headers`. No `Origin` is sent by default, which the origin guard allows.
|
|
323
|
+
- Not with `partial`. The hydration itself (`hydrateRoot` taking the state, the hook not resetting) is e2e.
|
|
324
|
+
|
|
229
325
|
## Caveats
|
|
230
326
|
|
|
231
327
|
What the HTML stub cannot reproduce, so keep it in e2e:
|
|
@@ -241,7 +337,7 @@ Other caveats:
|
|
|
241
337
|
- Call `resetShellTestState()` in `beforeEach` (see above). Without it, a URL a previous test left backed off, or a shell memoized by another `CFCacheStore`, changes what the next test sees.
|
|
242
338
|
- `readEntry()` reads the document entry under `key`. A partial request that finds no entry captures a navigation-only entry, which is stored apart from it.
|
|
243
339
|
- `updateTag()` needs a request context with the store: call it through `runInRequestContext(fn, { cacheStore })` or your app's own endpoint (`dispatch`). A store refuses a capture that starts in the invalidation's millisecond (the invalidation wins) and backs the key off; `serveShellRequest` starts each request in a later millisecond than the call, so an invalidation made before the call cannot collide with it.
|
|
244
|
-
- GET
|
|
340
|
+
- GET, and one POST shape: a form submitted before hydration (see "A form submitted before hydration" above). Other server action requests are not served; test their effects with `runInRequestContext`.
|
|
245
341
|
- Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
|
|
246
342
|
|
|
247
343
|
## See also
|
|
@@ -151,11 +151,15 @@ argument. The same holds for a loader's own `cache()` (`/loader` → "Cache Key"
|
|
|
151
151
|
|
|
152
152
|
The tainted `ctx` object is excluded from arg serialization (see below), but
|
|
153
153
|
route-identifying fields read off it are extracted into `serializedArgs`:
|
|
154
|
-
`url.host
|
|
155
|
-
(`
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
154
|
+
the serving router's id and `url.host` (`{routerId}@{host}`), route name
|
|
155
|
+
(`_routeName`), `pathname`, `params`, response type (`_responseType`), and the
|
|
156
|
+
user-facing sorted search params (internal `_rsc*`/`__` params excluded). The
|
|
157
|
+
same cached function called with `ctx` on different routes, param combinations,
|
|
158
|
+
hosts, response types, or query variants therefore produces distinct cache
|
|
159
|
+
entries -- not one shared entry. Two routers that call it on one host and path
|
|
160
|
+
keep two entries as well: a `ctx` is one router's (`/host-router`, "Shared
|
|
161
|
+
cache store"). A bare `Request` argument folds in its URL only, so it names no
|
|
162
|
+
router.
|
|
159
163
|
|
|
160
164
|
## Request-Scoped Arguments (ctx, Request, env)
|
|
161
165
|
|
package/skills/vercel/SKILL.md
CHANGED
|
@@ -118,6 +118,8 @@ export const router = createRouter({ cache: resolveCache }).routes(/* ... */);
|
|
|
118
118
|
|
|
119
119
|
The cache factory receives `(env, ctx)`: on Vercel `env` is `process.env` and `ctx` is `{ waitUntil }`; off-platform `ctx` is `undefined`. Cache semantics and tag invalidation: `/caching`, `/cache-guide`.
|
|
120
120
|
|
|
121
|
+
`router.prerender()` can warm a route into this store before traffic (`/prerender` → "Warm any route before traffic"). The Runtime Cache is per region, so `VercelCacheStore` declares `scope: "regional"`: a warm fills the region the call runs in. That is all traffic when the project's functions run in one region (the default `iad1` for new projects, and every Hobby project); on a multi-region project it warms one region and the others fill on first use. The router does not detect the region count. The memory store used off-platform above is refused in production and allowed under the dev server.
|
|
122
|
+
|
|
121
123
|
`getCache()` takes no deployment id: `VercelCacheStore` puts a version in front of every key itself (data version for families `s`, `i`; document version for `r`, `h`; none for tag markers), computed per `createRouter()` from its built code. A deploy that does not change a router keeps its entries; a deploy that changes it reads under new keys. `version` on the store (or on `createRouter`) replaces both versions. To clear the cache on every deploy, set `version` to a per-deploy value or keep a deployment-scoped `getCache({ namespace: process.env.VERCEL_DEPLOYMENT_ID })`. Two environments built from the same code share entries if they share a cache namespace; give each its own namespace or `version`.
|
|
122
124
|
|
|
123
125
|
## Host routers (multi-app)
|
|
@@ -359,10 +359,15 @@ interface TransitionWhenContext {
|
|
|
359
359
|
the route or loses its state
|
|
360
360
|
([#995](https://github.com/rangojs/rango/issues/995)).
|
|
361
361
|
- That holds for every kind, actions and revalidations included: a gated-off
|
|
362
|
-
action or revalidation commit is urgent too,
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
362
|
+
action or revalidation commit is urgent and unanimated too, and a segment it
|
|
363
|
+
re-renders shows its `loading()` only while something in it is still pending
|
|
364
|
+
(a gated-off navigation whose data is still streaming shows it; an action
|
|
365
|
+
whose response already carries the data does not in a production build,
|
|
366
|
+
while the dev server still shows it briefly because it resolves each
|
|
367
|
+
response's client components asynchronously). The router commits an
|
|
368
|
+
action's result after the response arrives, outside the transition React
|
|
369
|
+
opens for a form action or `useActionState` call, so React does not hold it
|
|
370
|
+
either.
|
|
366
371
|
- A predicate that throws counts as `false` and is logged with
|
|
367
372
|
`console.error` (the conservative default: no hold).
|
|
368
373
|
- Intercept (modal) commits skip the evaluation, including a back/forward
|
|
@@ -142,6 +142,16 @@ export interface HandleState {
|
|
|
142
142
|
routeSegmentIds: string[];
|
|
143
143
|
}
|
|
144
144
|
|
|
145
|
+
/**
|
|
146
|
+
* What the document's HTML was rendered with, kept for the hydration window
|
|
147
|
+
* (EventController.lockHydration).
|
|
148
|
+
*/
|
|
149
|
+
export interface HydrationSnapshot {
|
|
150
|
+
state: DerivedNavigationState;
|
|
151
|
+
params: Record<string, string>;
|
|
152
|
+
handles: HandleState;
|
|
153
|
+
}
|
|
154
|
+
|
|
145
155
|
/**
|
|
146
156
|
* Result from starting a navigation
|
|
147
157
|
* Implements Disposable for use with `using` keyword
|
|
@@ -263,22 +273,30 @@ export interface EventController {
|
|
|
263
273
|
): void;
|
|
264
274
|
getHandleState(): HandleState;
|
|
265
275
|
/**
|
|
266
|
-
*
|
|
267
|
-
*
|
|
268
|
-
*
|
|
276
|
+
* Open the hydration window. Until releaseHydrationLock(), getHandleState()
|
|
277
|
+
* returns the state the document's HTML was rendered with, and handle writes
|
|
278
|
+
* (setHandleData, setRouteSegmentIds) wait in order. initBrowserApp opens it
|
|
279
|
+
* before hydrateRoot, once the document's handle snapshot is applied: a hook
|
|
280
|
+
* reading the handle state when its boundary hydrates, however late, reads
|
|
281
|
+
* what SSR rendered (issue #1035).
|
|
269
282
|
*/
|
|
270
|
-
|
|
283
|
+
lockHydration(): void;
|
|
271
284
|
/**
|
|
272
|
-
*
|
|
273
|
-
*
|
|
274
|
-
*
|
|
275
|
-
*
|
|
276
|
-
*
|
|
277
|
-
*
|
|
278
|
-
* boundary that hydrates after it, and by then the live state can hold
|
|
279
|
-
* values that boundary's HTML does not (issue #1035).
|
|
285
|
+
* While the hydration window is open, the state the document's HTML was
|
|
286
|
+
* rendered with; undefined once it closes. A router hook's first render
|
|
287
|
+
* reads it: a component hydrating in a boundary that streamed in late
|
|
288
|
+
* renders what SSR rendered, even after a navigation or an action started.
|
|
289
|
+
* The hook's effect then catches up with the live state, and its
|
|
290
|
+
* subscription stays live, so pending UI is not held back.
|
|
280
291
|
*/
|
|
281
|
-
|
|
292
|
+
getHydrationSnapshot(): HydrationSnapshot | undefined;
|
|
293
|
+
/**
|
|
294
|
+
* Close the hydration window: apply the writes that waited, in order, and
|
|
295
|
+
* notify handle subscribers. NavigationProvider calls it after the commit
|
|
296
|
+
* that made React hydrate every boundary still dehydrated (its barrier).
|
|
297
|
+
* Idempotent.
|
|
298
|
+
*/
|
|
299
|
+
releaseHydrationLock(): void;
|
|
282
300
|
/**
|
|
283
301
|
* Update ONLY `routeSegmentIds` (what `useSegments` reads) from `matched`,
|
|
284
302
|
* leaving `data` and `segmentOrder` (what `useHandle` collects over) untouched.
|
|
@@ -498,8 +516,10 @@ export function createEventController(
|
|
|
498
516
|
let handleData: HandleData = {};
|
|
499
517
|
let handleSegmentOrder: string[] = [];
|
|
500
518
|
let routeSegmentIds: string[] = [];
|
|
501
|
-
//
|
|
502
|
-
|
|
519
|
+
// The hydration window (lockHydration): what the document's HTML was
|
|
520
|
+
// rendered with, and the handle writes waiting for its release.
|
|
521
|
+
let hydrationSnapshot: HydrationSnapshot | undefined;
|
|
522
|
+
let writesAfterHydration: (() => void)[] = [];
|
|
503
523
|
|
|
504
524
|
let routeParams: Record<string, string> = {};
|
|
505
525
|
|
|
@@ -1028,6 +1048,12 @@ export function createEventController(
|
|
|
1028
1048
|
isPartial?: boolean,
|
|
1029
1049
|
resolvedIds?: string[],
|
|
1030
1050
|
): void {
|
|
1051
|
+
if (hydrationSnapshot) {
|
|
1052
|
+
writesAfterHydration.push(() =>
|
|
1053
|
+
setHandleData(data, matched, isPartial, resolvedIds),
|
|
1054
|
+
);
|
|
1055
|
+
return;
|
|
1056
|
+
}
|
|
1031
1057
|
const rawMatched = matched ?? [];
|
|
1032
1058
|
const newSegmentOrder = filterSegmentOrder(rawMatched);
|
|
1033
1059
|
// Separate list for useSegments(): "layouts and routes only" — strip
|
|
@@ -1036,9 +1062,8 @@ export function createEventController(
|
|
|
1036
1062
|
|
|
1037
1063
|
if (isPartial && newSegmentOrder.length > 0) {
|
|
1038
1064
|
// Partial update: merge new data with existing, into a copy of the
|
|
1039
|
-
// containers. The object a reader was handed is never written to:
|
|
1040
|
-
//
|
|
1041
|
-
// restored from the store's cache can be that object.
|
|
1065
|
+
// containers. The object a reader was handed is never written to: a
|
|
1066
|
+
// history entry restored from the store's cache can be that object.
|
|
1042
1067
|
handleData = cloneHandleData(handleData);
|
|
1043
1068
|
for (const handleName of Object.keys(data)) {
|
|
1044
1069
|
if (!handleData[handleName]) {
|
|
@@ -1076,6 +1101,7 @@ export function createEventController(
|
|
|
1076
1101
|
}
|
|
1077
1102
|
|
|
1078
1103
|
function getHandleState(): HandleState {
|
|
1104
|
+
if (hydrationSnapshot) return hydrationSnapshot.handles;
|
|
1079
1105
|
return {
|
|
1080
1106
|
data: handleData,
|
|
1081
1107
|
segmentOrder: handleSegmentOrder,
|
|
@@ -1083,7 +1109,29 @@ export function createEventController(
|
|
|
1083
1109
|
};
|
|
1084
1110
|
}
|
|
1085
1111
|
|
|
1112
|
+
function lockHydration(): void {
|
|
1113
|
+
if (hydrationSnapshot) return;
|
|
1114
|
+
hydrationSnapshot = {
|
|
1115
|
+
state: getState(),
|
|
1116
|
+
params: routeParams,
|
|
1117
|
+
handles: getHandleState(),
|
|
1118
|
+
};
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
function releaseHydrationLock(): void {
|
|
1122
|
+
if (!hydrationSnapshot) return;
|
|
1123
|
+
hydrationSnapshot = undefined;
|
|
1124
|
+
const writes = writesAfterHydration;
|
|
1125
|
+
writesAfterHydration = [];
|
|
1126
|
+
for (const write of writes) write();
|
|
1127
|
+
notifyHandles.flush();
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1086
1130
|
function setRouteSegmentIds(matched: string[]): void {
|
|
1131
|
+
if (hydrationSnapshot) {
|
|
1132
|
+
writesAfterHydration.push(() => setRouteSegmentIds(matched));
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1087
1135
|
const next = filterRouteSegmentIds(matched);
|
|
1088
1136
|
if (
|
|
1089
1137
|
next.length === routeSegmentIds.length &&
|
|
@@ -1177,13 +1225,9 @@ export function createEventController(
|
|
|
1177
1225
|
// Handles
|
|
1178
1226
|
setHandleData,
|
|
1179
1227
|
getHandleState,
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
getHydrationHandleState: () =>
|
|
1184
|
-
hydrationHandleState?.data === handleData
|
|
1185
|
-
? undefined
|
|
1186
|
-
: hydrationHandleState,
|
|
1228
|
+
lockHydration,
|
|
1229
|
+
getHydrationSnapshot: () => hydrationSnapshot,
|
|
1230
|
+
releaseHydrationLock,
|
|
1187
1231
|
setRouteSegmentIds,
|
|
1188
1232
|
|
|
1189
1233
|
// Params
|
|
@@ -4,7 +4,7 @@ import {
|
|
|
4
4
|
type EventController,
|
|
5
5
|
} from "./event-controller.js";
|
|
6
6
|
import {
|
|
7
|
-
|
|
7
|
+
ENVIRONMENT_DEFAULT_PREFETCH,
|
|
8
8
|
resolveAdaptiveStrategy,
|
|
9
9
|
subscribeToAdaptiveStrategyChange,
|
|
10
10
|
} from "./prefetch/default-strategy.js";
|
|
@@ -383,7 +383,7 @@ export function setupDelegatedLinkPrefetch(
|
|
|
383
383
|
options: DelegatedPrefetchOptions,
|
|
384
384
|
): () => void {
|
|
385
385
|
const defaultStrategy =
|
|
386
|
-
options.defaultPrefetch ??
|
|
386
|
+
options.defaultPrefetch ?? ENVIRONMENT_DEFAULT_PREFETCH;
|
|
387
387
|
if (defaultStrategy === "none") return () => {};
|
|
388
388
|
|
|
389
389
|
const basename = canonicalizeBasenamePathname(options.basename);
|