@rangojs/router 0.22.1 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/rango.js +10 -4
- package/dist/testing/vitest.js +31 -1
- package/dist/types/browser/defer.d.ts +48 -0
- package/dist/types/browser/document-revealed.d.ts +17 -0
- package/dist/types/browser/event-controller.d.ts +7 -1
- package/dist/types/browser/merge-segment-loaders.d.ts +5 -2
- package/dist/types/browser/navigation-transaction.d.ts +21 -7
- package/dist/types/browser/partial-update.d.ts +39 -8
- package/dist/types/browser/react/NavigationProvider.d.ts +19 -5
- package/dist/types/browser/react/context.d.ts +3 -2
- package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
- package/dist/types/browser/response-adapter.d.ts +7 -0
- package/dist/types/browser/types.d.ts +24 -10
- package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
- package/dist/types/build/generate-route-types.d.ts +1 -1
- package/dist/types/build/route-types/router-processing.d.ts +1 -1
- package/dist/types/build/route-types/scan-filter.d.ts +22 -2
- package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
- package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
- package/dist/types/cache/document-cache-policy.d.ts +24 -0
- package/dist/types/cache/document-cache.d.ts +2 -10
- package/dist/types/client-urls/client-urls.d.ts +1 -1
- package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
- package/dist/types/client-urls/navigation.d.ts +0 -37
- package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
- package/dist/types/client-urls/server-projection.d.ts +29 -1
- package/dist/types/client-urls/types.d.ts +34 -69
- package/dist/types/client.d.ts +0 -1
- package/dist/types/context-var.d.ts +12 -0
- package/dist/types/errors.d.ts +15 -0
- package/dist/types/handles/deferred-resolution.d.ts +2 -1
- package/dist/types/handles/pushed-by.d.ts +31 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/index.rsc.d.ts +2 -0
- package/dist/types/loader-store.d.ts +8 -0
- package/dist/types/prefetch-markers.d.ts +11 -0
- package/dist/types/response-utils.d.ts +7 -0
- package/dist/types/reverse.d.ts +8 -8
- package/dist/types/route-definition/helpers-types.d.ts +27 -0
- package/dist/types/route-definition/rewrite.d.ts +56 -0
- package/dist/types/route-map-builder.d.ts +7 -2
- package/dist/types/router/basename.d.ts +7 -0
- package/dist/types/router/handler-context.d.ts +1 -5
- package/dist/types/router/internal-params.d.ts +8 -0
- package/dist/types/router/is-action.d.ts +2 -1
- package/dist/types/router/middleware-types.d.ts +15 -0
- package/dist/types/router/revalidation.d.ts +1 -2
- package/dist/types/router/route-trie-builder.d.ts +10 -0
- package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
- package/dist/types/router/segment-resolution/mask-nested.d.ts +4 -2
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +264 -0
- package/dist/types/rsc/full-payload.d.ts +11 -0
- package/dist/types/rsc/handler.d.ts +33 -0
- package/dist/types/rsc/helpers.d.ts +8 -0
- package/dist/types/rsc/loader-fetch.d.ts +16 -0
- package/dist/types/rsc/rewrite-passes.d.ts +74 -0
- package/dist/types/rsc/ssr-setup.d.ts +3 -1
- package/dist/types/server/context.d.ts +21 -1
- package/dist/types/server/handle-store.d.ts +9 -1
- package/dist/types/server/request-context.d.ts +52 -2
- package/dist/types/server/rewrite-trail.d.ts +49 -0
- package/dist/types/server/slot-accessors.d.ts +29 -0
- package/dist/types/server.d.ts +1 -0
- package/dist/types/testing/flight.entry.d.ts +1 -1
- package/dist/types/testing/index.d.ts +4 -4
- package/dist/types/testing/internal/action-id.d.ts +10 -1
- package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
- package/dist/types/testing/internal/test-action.d.ts +8 -0
- package/dist/types/testing/render-route.d.ts +8 -0
- package/dist/types/testing/run-loader.d.ts +2 -1
- package/dist/types/testing/run-middleware.d.ts +20 -0
- package/dist/types/testing/run-revalidate.d.ts +74 -0
- package/dist/types/testing/run-transition-when.d.ts +2 -3
- package/dist/types/testing/serve-shell-request.d.ts +40 -1
- package/dist/types/testing/vitest.d.ts +11 -0
- package/dist/types/thenable-status.d.ts +6 -0
- package/dist/types/types/boundaries.d.ts +9 -0
- package/dist/types/types/handler-context.d.ts +14 -0
- package/dist/types/types/loader-types.d.ts +30 -0
- package/dist/types/types/request-scope.d.ts +7 -0
- package/dist/types/types/segments.d.ts +21 -4
- package/dist/types/urls/path-helper-types.d.ts +25 -4
- package/dist/types/urls/type-extraction.d.ts +6 -2
- package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
- package/dist/types/vite/discovery/state.d.ts +14 -4
- package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
- package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
- package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
- package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
- package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
- package/dist/types/vite/router-discovery.d.ts +10 -0
- package/dist/vite/index.js +974 -379
- package/package.json +1 -1
- package/skills/caching/SKILL.md +54 -5
- package/skills/catalog.json +3 -3
- package/skills/client-urls/SKILL.md +197 -24
- package/skills/cloudflare/SKILL.md +14 -9
- package/skills/hooks/SKILL.md +16 -3
- package/skills/hooks/data.md +3 -1
- package/skills/links/SKILL.md +24 -0
- package/skills/loader/SKILL.md +199 -2
- package/skills/middleware/SKILL.md +183 -2
- package/skills/migrate-nextjs/SKILL.md +2 -2
- package/skills/migrate-react-router/SKILL.md +10 -7
- package/skills/migrate-react-router/data-and-actions.md +1 -1
- package/skills/ppr/SKILL.md +22 -0
- package/skills/prerender/SKILL.md +22 -8
- package/skills/rango/SKILL.md +13 -7
- package/skills/route/SKILL.md +139 -0
- package/skills/server-actions/SKILL.md +27 -0
- package/skills/testing/SKILL.md +3 -2
- package/skills/testing/client-components.md +2 -1
- package/skills/testing/middleware.md +31 -10
- package/skills/testing/predicates.md +67 -32
- package/skills/testing/response-routes.md +1 -0
- package/skills/testing/serve-shell-request.md +107 -19
- package/skills/testing/setup.md +2 -2
- package/skills/typesafety/route-types.md +2 -2
- package/skills/use-cache/SKILL.md +4 -2
- package/skills/view-transitions/SKILL.md +26 -0
- package/src/__augment-tests__/augmented.check.ts +20 -0
- package/src/browser/defer.ts +82 -0
- package/src/browser/document-revealed.ts +52 -0
- package/src/browser/event-controller.ts +46 -32
- package/src/browser/merge-segment-loaders.ts +31 -18
- package/src/browser/navigation-bridge.ts +82 -16
- package/src/browser/navigation-client.ts +48 -36
- package/src/browser/navigation-store.ts +13 -0
- package/src/browser/navigation-transaction.ts +25 -8
- package/src/browser/partial-update.ts +554 -146
- package/src/browser/prefetch/fetch.ts +2 -1
- package/src/browser/react/Link.tsx +7 -9
- package/src/browser/react/NavigationProvider.tsx +88 -49
- package/src/browser/react/context.ts +3 -2
- package/src/browser/react/filter-segment-order.ts +13 -2
- package/src/browser/react/use-router.ts +2 -4
- package/src/browser/response-adapter.ts +20 -0
- package/src/browser/rsc-router.tsx +17 -45
- package/src/browser/server-action-bridge.ts +25 -63
- package/src/browser/types.ts +24 -10
- package/src/browser/validate-redirect-origin.ts +16 -0
- package/src/build/generate-route-types.ts +1 -0
- package/src/build/route-types/router-processing.ts +3 -6
- package/src/build/route-types/scan-filter.ts +45 -11
- package/src/cache/cf/cf-cache-store.ts +59 -21
- package/src/cache/cf/cf-cache-types.ts +24 -2
- package/src/cache/document-cache-policy.ts +131 -0
- package/src/cache/document-cache.ts +21 -107
- package/src/client-urls/client-urls.ts +22 -6
- package/src/client-urls/hoisted-revalidate.ts +25 -0
- package/src/client-urls/navigation.ts +0 -96
- package/src/client-urls/revalidate-registry.ts +30 -0
- package/src/client-urls/server-projection.ts +151 -65
- package/src/client-urls/types.ts +39 -71
- package/src/client.tsx +9 -5
- package/src/context-var.ts +32 -2
- package/src/errors.ts +34 -0
- package/src/handles/deferred-resolution.ts +28 -5
- package/src/handles/pushed-by.ts +148 -0
- package/src/index.rsc.ts +8 -0
- package/src/index.ts +7 -0
- package/src/loader-store.ts +18 -7
- package/src/prefetch-markers.ts +13 -0
- package/src/response-utils.ts +19 -0
- package/src/reverse.ts +17 -14
- package/src/route-content-wrapper.tsx +18 -4
- package/src/route-definition/dsl-helpers.ts +27 -4
- package/src/route-definition/helpers-types.ts +27 -1
- package/src/route-definition/redirect.ts +2 -15
- package/src/route-definition/rewrite.ts +146 -0
- package/src/route-map-builder.ts +14 -11
- package/src/router/basename.ts +20 -0
- package/src/router/handler-context.ts +3 -13
- package/src/router/intercept-resolution.ts +35 -16
- package/src/router/internal-params.ts +16 -0
- package/src/router/is-action.ts +2 -1
- package/src/router/loader-resolution.ts +85 -4
- package/src/router/match-api.ts +36 -1
- package/src/router/match-middleware/background-revalidation.ts +16 -4
- package/src/router/match-middleware/cache-lookup.ts +35 -7
- package/src/router/match-middleware/cache-store.ts +15 -1
- package/src/router/match-middleware/intercept-resolution.ts +4 -2
- package/src/router/match-result.ts +14 -2
- package/src/router/middleware-types.ts +16 -0
- package/src/router/middleware.ts +127 -49
- package/src/router/navigation-snapshot.ts +2 -1
- package/src/router/revalidation.ts +1 -1
- package/src/router/route-trie-builder.ts +28 -1
- package/src/router/segment-resolution/fresh.ts +22 -18
- package/src/router/segment-resolution/loader-cache.ts +2 -1
- package/src/router/segment-resolution/loader-mask.ts +5 -3
- package/src/router/segment-resolution/loader-snapshot.ts +18 -4
- package/src/router/segment-resolution/mask-nested.ts +8 -4
- package/src/router/segment-resolution/prefetch-deferral.ts +583 -0
- package/src/router/segment-resolution/revalidation.ts +215 -82
- package/src/rsc/full-payload.ts +37 -5
- package/src/rsc/handler.ts +82 -55
- package/src/rsc/helpers.ts +24 -0
- package/src/rsc/loader-fetch.ts +79 -24
- package/src/rsc/response-cache-serve.ts +18 -2
- package/src/rsc/response-route-handler.ts +4 -7
- package/src/rsc/rewrite-passes.ts +203 -0
- package/src/rsc/rsc-rendering.ts +54 -9
- package/src/rsc/shell-capture.ts +2 -1
- package/src/rsc/ssr-setup.ts +11 -5
- package/src/segment-loader-promise.ts +23 -20
- package/src/segment-system.tsx +60 -26
- package/src/server/context.ts +39 -5
- package/src/server/cookie-parse.ts +36 -12
- package/src/server/cookie-store.ts +7 -3
- package/src/server/handle-store.ts +152 -25
- package/src/server/request-context.ts +175 -64
- package/src/server/request-headers.ts +4 -14
- package/src/server/rewrite-trail.ts +190 -0
- package/src/server/slot-accessors.ts +57 -0
- package/src/server.ts +4 -0
- package/src/ssr/index.tsx +118 -16
- package/src/testing/action-form.ts +2 -11
- package/src/testing/dispatch.ts +138 -67
- package/src/testing/e2e/page-helpers.ts +3 -1
- package/src/testing/flight.entry.ts +1 -0
- package/src/testing/index.ts +7 -3
- package/src/testing/internal/action-id.ts +16 -1
- package/src/testing/internal/client-urls-projection.ts +70 -0
- package/src/testing/internal/test-action.ts +25 -0
- package/src/testing/render-route.tsx +16 -3
- package/src/testing/run-loader.ts +4 -1
- package/src/testing/run-middleware.ts +56 -11
- package/src/testing/run-revalidate.ts +142 -0
- package/src/testing/run-transition-when.ts +7 -8
- package/src/testing/serve-shell-request.ts +131 -8
- package/src/testing/vitest.ts +67 -1
- package/src/thenable-status.ts +11 -0
- package/src/types/boundaries.ts +12 -0
- package/src/types/handler-context.ts +14 -0
- package/src/types/loader-types.ts +30 -0
- package/src/types/request-scope.ts +8 -0
- package/src/types/segments.ts +26 -4
- package/src/urls/path-helper-types.ts +27 -5
- package/src/urls/type-extraction.ts +30 -10
- package/src/use-loader.tsx +18 -13
- package/src/vite/discovery/client-urls-projection.ts +20 -21
- package/src/vite/discovery/discover-routers.ts +94 -26
- package/src/vite/discovery/state.ts +28 -4
- package/src/vite/discovery/virtual-module-codegen.ts +22 -17
- package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
- package/src/vite/plugins/expose-internal-ids.ts +2 -3
- package/src/vite/plugins/hoist/literal.ts +417 -0
- package/src/vite/plugins/hoist/scope.ts +370 -0
- package/src/vite/plugins/hoist/source.ts +84 -0
- package/src/vite/plugins/transition-when-hoist.ts +113 -779
- package/src/vite/rango.ts +2 -0
- package/src/vite/router-discovery.ts +116 -53
- package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
- package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
- package/dist/types/testing/run-client-revalidate.d.ts +0 -43
- package/src/client-urls/revalidate-chain.ts +0 -83
- package/src/client-urls/revalidation-protocol.ts +0 -56
- package/src/testing/run-client-revalidate.ts +0 -92
|
@@ -14,32 +14,34 @@ 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
|
|
|
21
21
|
### Options — `ServeShellRequestOptions`
|
|
22
22
|
|
|
23
|
-
| Field | Type
|
|
24
|
-
| ------------ |
|
|
25
|
-
| `cacheStore` | `SegmentCacheStore`
|
|
26
|
-
| `env` | `TEnv`
|
|
27
|
-
| `headers` | `HeadersInit`
|
|
28
|
-
| `partial` | `true \| { from?: string; segments?: string[] }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none).
|
|
23
|
+
| Field | Type | Meaning |
|
|
24
|
+
| ------------ | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
|
|
26
|
+
| `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
|
|
27
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
|
|
28
|
+
| `partial` | `true \| { from?: string; segments?: string[]; prefetch?: boolean; fill?: boolean }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends it as a `<Link>` prefetch (`X-Rango-Prefetch`), so `prefetch: false` work is deferred. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. |
|
|
29
|
+
| `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[] }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none). Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
|
|
29
30
|
|
|
30
31
|
### Returns — `ServeShellRequestResult`
|
|
31
32
|
|
|
32
|
-
| Field | Type | Meaning
|
|
33
|
-
| -------------- | ------------------------------------------------- |
|
|
34
|
-
| `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request).
|
|
35
|
-
| `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`.
|
|
36
|
-
| `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`.
|
|
37
|
-
| `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response).
|
|
38
|
-
| `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition.
|
|
39
|
-
| `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count.
|
|
40
|
-
| `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered.
|
|
41
|
-
| `
|
|
42
|
-
| `
|
|
33
|
+
| Field | Type | Meaning |
|
|
34
|
+
| -------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
35
|
+
| `shellStatus` | `"HIT" \| "MISS" \| null` | `x-rango-shell`; null when the serve path did not consider the request (not a `ppr` route, a nonce'd request, a partial request). |
|
|
36
|
+
| `replayStatus` | `PprReplayStatus \| null` | `x-rango-ppr-replay` on a partial request: `{ outcome: "HIT", freshness }` or `{ outcome: "BYPASS", reason }`. |
|
|
37
|
+
| `prelude` | `string \| undefined` | The prelude a HIT served (the capture's Flight text). `undefined` unless `shellStatus` is `"HIT"`. |
|
|
38
|
+
| `flight` | `string \| undefined` | The Flight payload this request rendered: a HIT's tail, a document render, or a partial response. `undefined` when no Flight rendered (a redirect, a middleware response). |
|
|
39
|
+
| `key` | `string` | The shell key the serve path resolved for a document it read (MISS or HIT), request partition included. Otherwise (no `ppr`, a partial request) the URL's key without a partition. |
|
|
40
|
+
| `readEntry` | `() => Promise<ShellCacheEntry \| null>` | Reads the document entry under `key` from the request's store (a passive `getShell`). A read: on a store with a shell memo it warms the memo, so call it after the reads you count. |
|
|
41
|
+
| `readHandles` | `() => Promise<ShellRequestHandles \| undefined>` | Decodes the response's handle data as the browser reads it: `{ hydration, late }` (see "Handles and loader data"). `undefined` when no Flight rendered. |
|
|
42
|
+
| `readDeferred` | `() => Promise<string[] \| undefined>` | The ids of the segments the payload marks deferred (a prefetch skipped them; the fill renders them), in payload order. `[]` when nothing was deferred, `undefined` when no Flight rendered. |
|
|
43
|
+
| `response` | `Response` | Status and headers. Its body is already read. |
|
|
44
|
+
| `body` | `string` | The body text. |
|
|
43
45
|
|
|
44
46
|
### `resetShellTestState(): Promise<void>`
|
|
45
47
|
|
|
@@ -250,6 +252,53 @@ Loader data has no decoder: it rides the payload, so assert it as text in `prelu
|
|
|
250
252
|
expect(hit.flight).toContain('"stock":"in stock"');
|
|
251
253
|
```
|
|
252
254
|
|
|
255
|
+
## Testing `prefetch: false`
|
|
256
|
+
|
|
257
|
+
Send the prefetch with `partial: { prefetch: true }` and read which segments it deferred, then send the fill the browser would send:
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
const prefetch = await serveShellRequest(router, "/product/1", {
|
|
261
|
+
cacheStore,
|
|
262
|
+
partial: { from: "/", prefetch: true },
|
|
263
|
+
});
|
|
264
|
+
// One id per skipped segment: here the flagged loader's. A flagged loading()
|
|
265
|
+
// lists its entry, every entry and slot its fallback covers, and their loaders.
|
|
266
|
+
expect(await prefetch.readDeferred()).toHaveLength(1);
|
|
267
|
+
|
|
268
|
+
// The ids the prefetch delivered, minus the deferred ones: what the page holds.
|
|
269
|
+
const held = deliveredIds.filter((id) => !deferredIds.includes(id));
|
|
270
|
+
const fill = await serveShellRequest(router, "/product/1", {
|
|
271
|
+
cacheStore,
|
|
272
|
+
partial: { from: "/product/1", segments: held, fill: true },
|
|
273
|
+
});
|
|
274
|
+
expect(await fill.readDeferred()).toEqual([]); // a fill defers nothing
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
`deliveredIds` is the payload's `metadata.matched`, read from `prefetch.flight`. Count the loader's runs in your own fixture: the prefetch ran it zero times, the fill once.
|
|
278
|
+
|
|
279
|
+
### `clientUrls()` routes
|
|
280
|
+
|
|
281
|
+
A router that mounts a `clientUrls()` group serves its routes here as in your app. The group is the default export of a `"use client"` module, a client reference in this project, and `rangoUseClientTransform()` ([`./setup.md`](./setup.md)) installs its server projection as the rango plugin's discovery does. Mount the module itself:
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
import shopUrls from "../src/shop/urls"; // "use client"; export default clientUrls(...)
|
|
285
|
+
|
|
286
|
+
const router = createRouter().routes(
|
|
287
|
+
urls(({ include }) => [include("/shop", shopUrls, { name: "shop" })]),
|
|
288
|
+
);
|
|
289
|
+
|
|
290
|
+
// /shop/product/:id declares loader(PriceLoader) and
|
|
291
|
+
// loader(ReviewsLoader, { prefetch: false }).
|
|
292
|
+
const prefetch = await serveShellRequest(router, "/shop/product/1", {
|
|
293
|
+
partial: { from: "/shop", segments: held, prefetch: true },
|
|
294
|
+
});
|
|
295
|
+
expect(await prefetch.readDeferred()).toHaveLength(1); // ReviewsLoader's segment
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
`held` is the `metadata.matched` of a navigation to `/shop`. On a group route only a loader's own flag defers (the group's, and a server layout's above the group); a flagged `loading()` defers nothing there. Loaders inside the group's `intercept()` ignore the flag, as in `urls()`. `dispatch` matches the group's routes through the same projection.
|
|
299
|
+
|
|
300
|
+
The transform also registers each group loader's `revalidate()` predicates, which Vitest leaves as plain functions in the module, so the server runs them as the hoisted predicates run in your app. A `partial` navigation, or an `action` with the page's `segments`, reaches them with the `urls()` arguments, `actionResult` and `formData` included.
|
|
301
|
+
|
|
253
302
|
## Warming a route: router.prerender()
|
|
254
303
|
|
|
255
304
|
`router.prerender()` warms a route that is not `Prerender(..., { onDemand })`
|
|
@@ -322,6 +371,45 @@ expect(await result.readPayloadFormState()).toEqual(result.formState);
|
|
|
322
371
|
- Request headers come from `headers`. No `Origin` is sent by default, which the origin guard allows.
|
|
323
372
|
- Not with `partial`. The hydration itself (`hydrateRoot` taking the state, the hook not resetting) is e2e.
|
|
324
373
|
|
|
374
|
+
## A server action called from the page
|
|
375
|
+
|
|
376
|
+
`serveShellRequest(router, url, { action: { ref, id, args, segments } })` sends the request a JS server action call makes from the page at `url`, as `browser/server-action-bridge.ts` sends it: a POST of `args` with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments`. `ref` is registered under `id` (`<module id>#<export name>`, any unique string of that shape in a test) so the handler loads it by id. The response is the action's Flight payload: its return value and the segments it re-rendered, `flight` as for any other request. Pass the segment ids the page holds as `segments`, so the request has segments to revalidate; without them the server holds none. Read them from the `metadata.matched` of a `partial` request's payload (`flight`), as in "Testing `prefetch: false`" above.
|
|
377
|
+
|
|
378
|
+
```tsx
|
|
379
|
+
const page = await serveShellRequest(router, "/shop", {
|
|
380
|
+
partial: { from: "/" },
|
|
381
|
+
});
|
|
382
|
+
const held: string[] = readMatched(page.flight); // your parse of metadata.matched
|
|
383
|
+
const result = await serveShellRequest(router, "/shop", {
|
|
384
|
+
action: {
|
|
385
|
+
ref: checkout,
|
|
386
|
+
id: "src/actions/cart.ts#checkout",
|
|
387
|
+
args: [formData],
|
|
388
|
+
segments: held,
|
|
389
|
+
},
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
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
|
+
|
|
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
|
+
|
|
325
413
|
## Caveats
|
|
326
414
|
|
|
327
415
|
What the HTML stub cannot reproduce, so keep it in e2e:
|
|
@@ -337,7 +425,7 @@ Other caveats:
|
|
|
337
425
|
- 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.
|
|
338
426
|
- `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.
|
|
339
427
|
- `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.
|
|
340
|
-
- GET, and
|
|
428
|
+
- GET, and two POST shapes: a form submitted before hydration (see "A form submitted before hydration" above) and a JS server action call (`action`, see "A server action called from the page" above). Test an action's own effects (cookies, headers) with `runInRequestContext`.
|
|
341
429
|
- Same setup as every Flight test: the react-server condition and `rangoTestAliases()` (see [`./setup.md`](./setup.md)).
|
|
342
430
|
|
|
343
431
|
## See also
|
package/skills/testing/setup.md
CHANGED
|
@@ -19,7 +19,7 @@ Real machinery: Vite transpiles `@rangojs/router`'s shipped TS source and resolv
|
|
|
19
19
|
| `rangoTestConfig(opts?)` | `{ alias, server: { deps: { inline } } }` | Recommended. Spread into the node/DOM project's `test` block. Bundles the resolve aliases AND `server.deps.inline`. |
|
|
20
20
|
| `rangoTestAliases(opts?)` | `TestAlias[]` (`{ find, replacement }[]`) | Lower-level. The bare `@rangojs/router` -> `index.rsc.ts` alias plus the `:version` / `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) stubs (and CF stubs under `preset:"cloudflare"`). Used in the rsc project's `resolve.alias`. |
|
|
21
21
|
| `rangoInlineDeps` | `RegExp[]` (`[/@rangojs[/\\]router/]`) | The `server.deps.inline` patterns on their own. Use in the rsc project (`test.server.deps.inline`), or wherever you call `rangoTestAliases` directly instead of `rangoTestConfig`. |
|
|
22
|
-
| `rangoUseClientTransform()` | a Vite plugin (`
|
|
22
|
+
| `rangoUseClientTransform()` | a Vite plugin (`resolveId`, `transform`) | Add to the rsc project `plugins`. Applies the `"use client"` transform so `renderServerTree` / `renderHandler` auto-discover client islands, and projects `"use client"` `clientUrls()` modules for `serveShellRequest` / `dispatch`. |
|
|
23
23
|
| `rangoUseCacheTransform()` | a Vite plugin (`enforce: "post"`) | Add to the rsc project `plugins`. Runs the rango plugin's `"use cache"` transform with dev ids (`<path>#<name>`, the path relative to the root its `configResolved` reads) so a directive function caches. |
|
|
24
24
|
|
|
25
25
|
### Returns — `RangoTestConfig` (from `rangoTestConfig`)
|
|
@@ -117,7 +117,7 @@ Scripts:
|
|
|
117
117
|
- The forked rsc worker (`pool: "forks"`) must force the condition via `execArgv: ["--conditions=react-server"]`, or React throws "the react-server condition must be enabled".
|
|
118
118
|
- The `@rangojs/router:version` and `@vitejs/plugin-rsc/rsc` (`/rsc/server`, `/rsc/client`) virtuals must be stubbed; the preset does it. A bare router import without stubbing throws.
|
|
119
119
|
- The rango fragment goes under `test` (`test.alias` + `test.server.deps.inline`, both returned by `rangoTestConfig`), NOT under top-level `resolve`.
|
|
120
|
-
- Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`. The transform's `registerClientReference` import is resolved from `@rangojs/router`'s `@vitejs/plugin-rsc` — the consumer does not need a direct plugin-rsc dependency.
|
|
120
|
+
- Wire `rangoUseClientTransform()` into the rsc project `plugins` so islands auto-discover from the server tree imports (see `./server-tree.md`); without it, register islands explicitly with `clientComponents`. It also installs the server projection of every `"use client"` module whose default export is a `clientUrls()` definition, as the rango plugin's discovery does in your app, so `include("/shop", shopUrls)` and `.routes(shopUrls)` serve the group's routes through `serveShellRequest` and `dispatch`; without it the include throws `could not resolve the server projection`. The node project needs nothing: there the module's default export is the definition itself. The transform's `registerClientReference` import is resolved from `@rangojs/router`'s `@vitejs/plugin-rsc` — the consumer does not need a direct plugin-rsc dependency.
|
|
121
121
|
- Wire `rangoUseCacheTransform()` into the rsc project `plugins` so a function written with `"use cache"` is wrapped as in dev. `rango()` runs that transform only in its `rsc` environment and Vitest transforms in `ssr`, so without it the directive is inert and the function runs on every call. See [`./render-handler.md`](./render-handler.md#a-use-cache-hit).
|
|
122
122
|
|
|
123
123
|
## See also
|
|
@@ -246,5 +246,5 @@ changes nothing, so narrow with the ternary above. Avoid a bare
|
|
|
246
246
|
the reference the same way the router derives `actionId` (`$id ?? $$id`: the
|
|
247
247
|
file-path `$id` injected in a production RSC build, otherwise React's `$$id`),
|
|
248
248
|
so matching works in dev and production. `actionId` stays available for
|
|
249
|
-
advanced cases. In `clientUrls()` groups the predicate runs
|
|
250
|
-
|
|
249
|
+
advanced cases. In `clientUrls()` groups the loader's predicate runs on the
|
|
250
|
+
server too, with these same arguments (see `/client-urls`).
|
|
@@ -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()`.
|
|
240
|
-
|
|
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
|
|
|
@@ -433,6 +433,8 @@ function ProductPage() {
|
|
|
433
433
|
|
|
434
434
|
This is the rango analogue of the "router triggers, you place the names" model used by React Router / TanStack: rango guarantees navigations run inside a React transition; you own the boundaries.
|
|
435
435
|
|
|
436
|
+
On a route with `loading()` and slow loaders, `viewTransition: false` is also what removes the view transition that changes nothing; see [A view transition that changes nothing under `loading()`](#a-view-transition-that-changes-nothing-under-loading).
|
|
437
|
+
|
|
436
438
|
**App-wide default.** Flip the default for every `transition()` segment at the router level. A per-segment `viewTransition` still overrides it.
|
|
437
439
|
|
|
438
440
|
```ts
|
|
@@ -463,6 +465,30 @@ On React 19.2 (no `<ViewTransition>` export) the "VT" column is a no-op, so ever
|
|
|
463
465
|
|
|
464
466
|
> On React 19.2 there is no `<ViewTransition>` at all, so `viewTransition: false` is visually a no-op there — but the startTransition driving and content-hold still apply, identical to `transition({})`.
|
|
465
467
|
|
|
468
|
+
## A view transition that changes nothing under `loading()`
|
|
469
|
+
|
|
470
|
+
On a cold click, a route with `transition()` and `loading()` can start a view transition that changes nothing on screen, and its content shows only after that transition ends ([#1078](https://github.com/rangojs/rango/issues/1078)). React causes it, not a router update, and the router does not steer or cancel it. Two shapes hit it:
|
|
471
|
+
|
|
472
|
+
- **One loader slower than about 320 ms, on a navigation where the route's client component module is still loading when the navigation first renders.** In a production build that is the first navigation to that module; in dev it is every navigation, because the dev server resolves each response's client references asynchronously.
|
|
473
|
+
- **Two or more loaders under one `loading()` whose data lands at different times, the last one later than about 320 ms.** Module state does not matter.
|
|
474
|
+
|
|
475
|
+
Not affected: a fully prefetched click (it commits complete), and a loader that lands before the fallback has been up for 300 ms (the 100 ms and 280 ms cells in #1078 ran two view transitions, the navigation's and the reveal's).
|
|
476
|
+
|
|
477
|
+
The cost is the browser's view-transition duration before the content shows: Chromium's default animation is 250 ms, and the idle transitions measured took 265 to 285 ms from `ready` to `finished`. The View Transitions guide on the docs site ("When a view transition changes nothing") has the start counts and content times per shape, in dev and in production.
|
|
478
|
+
|
|
479
|
+
**Why.** When the first thing the route's `loading()` boundary waits on lands (the client module, or the first loader), React retries the boundary. The retry suspends again on the slower loader and commits with the fallback still in place, throttled to 300 ms after the fallback committed. That commit is view-transition eligible and the router's `<ViewTransition>` is on its path, so React calls `document.startViewTransition`; nothing changes on screen, the browser still runs its default animation, and the slower loader's reveal waits: React holds the next view-transition commit until the running transition's `finished` resolves. React 19.3.0 and the canary and experimental builds of 2026-10-06 behave the same.
|
|
480
|
+
|
|
481
|
+
### Escape hatches
|
|
482
|
+
|
|
483
|
+
Two options remove it, in dev and in production:
|
|
484
|
+
|
|
485
|
+
- **`transition({ viewTransition: false })`** places no router `<ViewTransition>` for the segment that declares it, so React has nothing of that segment's to animate on the retry commit. The navigation still commits through `startTransition`, so same-route content is still held; what you give up is the router's cross-fade. It only covers the segment it is set on: a layout above that declares its own `transition()` still wraps the route's outlet content in a router `<ViewTransition>` (`wrapDefaultOutletContent` in [segment-system.tsx](../../src/segment-system.tsx)), and the idle transition stays. Set it on every segment in the matched chain that declares `transition()`, or app-wide with `createRouter({ viewTransition: false })`.
|
|
486
|
+
- **`transition({ when })` returning `false`** commits the navigation urgently and renders every router `<ViewTransition>` of the navigation with every class `"none"` (`GATED_OFF_CLASSES` in [segment-system.tsx](../../src/segment-system.tsx)); React starts no transition for it. One `when` on one segment is enough, because a `false` gates the whole navigation off: on a same-route navigation the `loading()` skeleton streams instead of the previous content being held, and nothing animates. Use it when `kind`, `from` and `to` tell you which navigations hit the shape.
|
|
487
|
+
|
|
488
|
+
### Your own `<ViewTransition>`
|
|
489
|
+
|
|
490
|
+
A `<ViewTransition>` that you place and that stays on screen across the navigation (for example around a layout's `<Outlet />`) takes part in the same commits and starts the same idle transition, whichever router option is set. The router only controls its own boundaries, so this one is yours to configure. `update="none"` on it started no view transition for the click, but the boundary then no longer animates the real navigation's update either (no cross-fade of the outlet). Measured in Chromium only; other engines were not measured.
|
|
491
|
+
|
|
466
492
|
## Testing a transition() route
|
|
467
493
|
|
|
468
494
|
On React 19.3+ a transition commit briefly keeps the exiting `<ViewTransition>`
|
|
@@ -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">
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { use } from "react";
|
|
2
|
+
import { SETTLED_THENABLE } from "../thenable-status.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One hole of a `prefetch: false` adoption (browser/partial-update.ts
|
|
6
|
+
* armHoles): the promise every reader of the hole waits on, and what the
|
|
7
|
+
* router knows of it. The router sets `status` and `value` here; it never
|
|
8
|
+
* writes React's thenable fields on `promise`.
|
|
9
|
+
*
|
|
10
|
+
* Resolve-only: a hole is filled or released (releaseHoles), never rejected.
|
|
11
|
+
* A failure arrives as a fulfilled value (a loader's error envelope, an
|
|
12
|
+
* error segment's node).
|
|
13
|
+
*/
|
|
14
|
+
export class Defer<T> {
|
|
15
|
+
status: "pending" | "fulfilled" = "pending";
|
|
16
|
+
value: T | undefined;
|
|
17
|
+
/** A reader has handed React `promise` (readDefer). */
|
|
18
|
+
read?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Resolved with a value the router holds (a node), the hole is
|
|
21
|
+
* "fulfilled" at once. Resolved with a stream (a Flight chunk), its promise
|
|
22
|
+
* follows the stream, and so does the hole: "fulfilled" with the stream's
|
|
23
|
+
* value once it arrives, so a reader that mounts after that reads the value
|
|
24
|
+
* as it would read the chunk Flight marked. A rejected stream leaves the
|
|
25
|
+
* hole pending; its promise carries the rejection.
|
|
26
|
+
*/
|
|
27
|
+
resolve!: (value: T) => void;
|
|
28
|
+
readonly promise: Promise<T> = new Promise<T>((settle) => {
|
|
29
|
+
this.resolve = (value) => {
|
|
30
|
+
settle(value);
|
|
31
|
+
if (value instanceof Promise) {
|
|
32
|
+
value.then(
|
|
33
|
+
(arrived: T) => {
|
|
34
|
+
this.status = "fulfilled";
|
|
35
|
+
this.value = arrived;
|
|
36
|
+
},
|
|
37
|
+
() => {},
|
|
38
|
+
);
|
|
39
|
+
} else {
|
|
40
|
+
this.status = "fulfilled";
|
|
41
|
+
this.value = value;
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The read rule, for a component that reads a hole (Suspender, useLoader).
|
|
49
|
+
* A hole that has fulfilled and that no reader has handed React is its
|
|
50
|
+
* value: use() of a promise React has not seen suspends an urgent render
|
|
51
|
+
* once, and the boundary shows its fallback. Once a reader has handed React
|
|
52
|
+
* the promise, every read is use() of that promise: React has marked it and
|
|
53
|
+
* returns its value at once, and a replay of the reader must hand use() the
|
|
54
|
+
* thenable it read before ("A component was suspended by an uncached
|
|
55
|
+
* promise"). Either way use() is called once, as at every useLoader read.
|
|
56
|
+
*/
|
|
57
|
+
export function readDefer<T>(hole: Defer<T>): T {
|
|
58
|
+
if (hole.status === "fulfilled" && !hole.read) {
|
|
59
|
+
use(SETTLED_THENABLE);
|
|
60
|
+
return hole.value as T;
|
|
61
|
+
}
|
|
62
|
+
hole.read = true;
|
|
63
|
+
return use(hole.promise);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* A hole as the loader-streams convention has it: its promise while pending,
|
|
68
|
+
* its value once fulfilled. Anything else is returned as it is. For an
|
|
69
|
+
* aggregate, the pending-stream registry, and a node React reads itself (a
|
|
70
|
+
* segment or slot with no `loading()`): a router component there would be a
|
|
71
|
+
* fiber the filled page's node does not have, and the next render from the
|
|
72
|
+
* filled entry would remount the segment. React suspends such a read on the
|
|
73
|
+
* component above it, which commits nothing until the hole fulfils, so the
|
|
74
|
+
* value can stand where React read the promise.
|
|
75
|
+
*/
|
|
76
|
+
export function asStream(value: unknown): unknown {
|
|
77
|
+
return value instanceof Defer
|
|
78
|
+
? value.status === "fulfilled"
|
|
79
|
+
? value.value
|
|
80
|
+
: value.promise
|
|
81
|
+
: value;
|
|
82
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/** Poll interval; setTimeout so a tab hidden after first paint still settles. */
|
|
2
|
+
const REVEAL_POLL_MS = 50;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A Suspense boundary of the document React has not revealed yet: Fizz marks
|
|
6
|
+
* one `$?` while its content streams and `$~` once its reveal is queued.
|
|
7
|
+
*/
|
|
8
|
+
function isUnrevealed(node: Comment): boolean {
|
|
9
|
+
return node.isConnected && (node.data === "$?" || node.data === "$~");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function unrevealedBoundaries(): Comment[] {
|
|
13
|
+
const found: Comment[] = [];
|
|
14
|
+
const walker = document.createTreeWalker(document, NodeFilter.SHOW_COMMENT);
|
|
15
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
16
|
+
if (isUnrevealed(node as Comment)) found.push(node as Comment);
|
|
17
|
+
}
|
|
18
|
+
return found;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Resolves once the document has finished parsing and no Suspense boundary of
|
|
23
|
+
* it is unrevealed.
|
|
24
|
+
*
|
|
25
|
+
* Parsing ends with the stream, but React reveals a streamed boundary in
|
|
26
|
+
* batches up to 300 ms later (Fizz's $RC marks it `$~` and reveals it from a
|
|
27
|
+
* timer), so the boundary is still pending at DOMContentLoaded. A transition
|
|
28
|
+
* then would make React client-render it over its server HTML.
|
|
29
|
+
*
|
|
30
|
+
* A reveal rewrites the same comment's data, or React removes the node; a
|
|
31
|
+
* boundary nested in revealed content joins the document only then, so it
|
|
32
|
+
* resolves only once a full walk finds none. Polls with setTimeout, not
|
|
33
|
+
* requestAnimationFrame: a hidden tab never runs rAF, which would hold late
|
|
34
|
+
* handle pushes and location state until the tab is shown again. A tab hidden
|
|
35
|
+
* from load still waits: Fizz reveals boundaries from a rAF-gated script.
|
|
36
|
+
*/
|
|
37
|
+
export function awaitDocumentRevealed(): Promise<void> {
|
|
38
|
+
return new Promise<void>((resolve) => {
|
|
39
|
+
const start = (): void => {
|
|
40
|
+
let pending = unrevealedBoundaries();
|
|
41
|
+
const check = (): void => {
|
|
42
|
+
pending = pending.filter(isUnrevealed);
|
|
43
|
+
if (pending.length === 0) pending = unrevealedBoundaries();
|
|
44
|
+
if (pending.length > 0) setTimeout(check, REVEAL_POLL_MS);
|
|
45
|
+
else resolve();
|
|
46
|
+
};
|
|
47
|
+
check();
|
|
48
|
+
};
|
|
49
|
+
if (document.readyState !== "loading") start();
|
|
50
|
+
else document.addEventListener("DOMContentLoaded", start, { once: true });
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
filterRouteSegmentIds,
|
|
16
16
|
} from "./react/filter-segment-order.js";
|
|
17
17
|
import { notifyListeners } from "./notify-listeners.js";
|
|
18
|
-
import {
|
|
18
|
+
import { mergePartialHandleData } from "../handles/pushed-by.js";
|
|
19
19
|
import {
|
|
20
20
|
locationStateSnapshot,
|
|
21
21
|
type LocationStateSnapshot,
|
|
@@ -223,7 +223,13 @@ export interface EventController {
|
|
|
223
223
|
url: string,
|
|
224
224
|
options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
|
|
225
225
|
): NavigationHandle;
|
|
226
|
-
|
|
226
|
+
/**
|
|
227
|
+
* Abort the pending navigation, and the committed one whose stream is
|
|
228
|
+
* still open. `pendingOnly` leaves the committed one alone: a page
|
|
229
|
+
* restored from the back/forward cache is not being left
|
|
230
|
+
* (navigation-bridge.ts handlePageShow).
|
|
231
|
+
*/
|
|
232
|
+
abortNavigation(pendingOnly?: boolean): void;
|
|
227
233
|
|
|
228
234
|
// Action operations
|
|
229
235
|
startAction(
|
|
@@ -476,6 +482,14 @@ export function createEventController(
|
|
|
476
482
|
config?: EventControllerConfig,
|
|
477
483
|
): EventController {
|
|
478
484
|
let currentNavigation: NavigationEntry | null = null;
|
|
485
|
+
// The navigation that committed last, while its stream is still open. A
|
|
486
|
+
// newer navigation or a back/forward aborts it as it aborts one that has
|
|
487
|
+
// not committed: what a navigation does after its commit (the fill of a
|
|
488
|
+
// prefetch: false adoption, partial-update.ts fillHoles) reads its signal.
|
|
489
|
+
// The abort never cancels a response that has arrived (navigation-client.ts
|
|
490
|
+
// links a request to its signal only until then): the navigation stops
|
|
491
|
+
// reading it, and it streams to its end.
|
|
492
|
+
let streamingNavigation: AbortController | null = null;
|
|
479
493
|
|
|
480
494
|
const inflightActions = new Map<string, ActionEntry>();
|
|
481
495
|
|
|
@@ -698,6 +712,7 @@ export function createEventController(
|
|
|
698
712
|
options?: NavigateOptionsInternal & { skipLoadingState?: boolean },
|
|
699
713
|
): NavigationHandle {
|
|
700
714
|
// Cancel existing navigation (switchMap semantics)
|
|
715
|
+
abortStreamingNavigation();
|
|
701
716
|
if (currentNavigation) {
|
|
702
717
|
currentNavigation.abort.abort();
|
|
703
718
|
currentNavigation = null;
|
|
@@ -716,6 +731,7 @@ export function createEventController(
|
|
|
716
731
|
notify();
|
|
717
732
|
|
|
718
733
|
let completed = false;
|
|
734
|
+
let openStreams = 0;
|
|
719
735
|
|
|
720
736
|
return {
|
|
721
737
|
abort,
|
|
@@ -729,6 +745,7 @@ export function createEventController(
|
|
|
729
745
|
let ended = false;
|
|
730
746
|
entry.phase = "streaming";
|
|
731
747
|
activeStreamCount++;
|
|
748
|
+
openStreams++;
|
|
732
749
|
notify();
|
|
733
750
|
|
|
734
751
|
return {
|
|
@@ -736,6 +753,9 @@ export function createEventController(
|
|
|
736
753
|
if (ended) return;
|
|
737
754
|
ended = true;
|
|
738
755
|
activeStreamCount = Math.max(0, activeStreamCount - 1);
|
|
756
|
+
if (--openStreams === 0 && streamingNavigation === abort) {
|
|
757
|
+
streamingNavigation = null;
|
|
758
|
+
}
|
|
739
759
|
notify();
|
|
740
760
|
},
|
|
741
761
|
};
|
|
@@ -746,6 +766,7 @@ export function createEventController(
|
|
|
746
766
|
completed = true;
|
|
747
767
|
location = newLocation;
|
|
748
768
|
currentNavigation = null;
|
|
769
|
+
if (openStreams > 0) streamingNavigation = abort;
|
|
749
770
|
notify();
|
|
750
771
|
}
|
|
751
772
|
},
|
|
@@ -764,7 +785,14 @@ export function createEventController(
|
|
|
764
785
|
};
|
|
765
786
|
}
|
|
766
787
|
|
|
767
|
-
function
|
|
788
|
+
function abortStreamingNavigation(): void {
|
|
789
|
+
const streaming = streamingNavigation;
|
|
790
|
+
streamingNavigation = null;
|
|
791
|
+
streaming?.abort();
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
function abortNavigation(pendingOnly?: boolean) {
|
|
795
|
+
if (!pendingOnly) abortStreamingNavigation();
|
|
768
796
|
if (currentNavigation) {
|
|
769
797
|
currentNavigation.abort.abort();
|
|
770
798
|
currentNavigation = null;
|
|
@@ -1009,6 +1037,9 @@ export function createEventController(
|
|
|
1009
1037
|
// If not settled and not completed, this is an error case - force finalize
|
|
1010
1038
|
if (!settled && inflightActions.has(id)) {
|
|
1011
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;
|
|
1012
1043
|
streamingEnded = true;
|
|
1013
1044
|
tryFinalize();
|
|
1014
1045
|
}
|
|
@@ -1061,35 +1092,18 @@ export function createEventController(
|
|
|
1061
1092
|
const newRouteSegmentIds = filterRouteSegmentIds(rawMatched);
|
|
1062
1093
|
|
|
1063
1094
|
if (isPartial && newSegmentOrder.length > 0) {
|
|
1064
|
-
// Partial update:
|
|
1065
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
const resolvedIdSet =
|
|
1077
|
-
resolvedIds && resolvedIds.length > 0 ? new Set(resolvedIds) : null;
|
|
1078
|
-
// Cleanup pass:
|
|
1079
|
-
// a) segment dropped from the match list — delete its bucket.
|
|
1080
|
-
// b) segment was re-resolved this request but pushed nothing for
|
|
1081
|
-
// this handle — its previous bucket is stale.
|
|
1082
|
-
// (a) is the existing behavior; (b) requires resolvedIds.
|
|
1083
|
-
for (const handleName of Object.keys(handleData)) {
|
|
1084
|
-
for (const segmentId of Object.keys(handleData[handleName])) {
|
|
1085
|
-
const droppedFromMatch = !newSegmentOrder.includes(segmentId);
|
|
1086
|
-
const reresolvedWithoutPush =
|
|
1087
|
-
resolvedIdSet?.has(segmentId) && !data[handleName]?.[segmentId];
|
|
1088
|
-
if (droppedFromMatch || reresolvedWithoutPush) {
|
|
1089
|
-
delete handleData[handleName][segmentId];
|
|
1090
|
-
}
|
|
1091
|
-
}
|
|
1092
|
-
}
|
|
1095
|
+
// Partial update: a segment dropped from the match loses its buckets,
|
|
1096
|
+
// and in each other bucket only the handler and loaders that ran for
|
|
1097
|
+
// the response replace their pushes (#1103). Into new containers: a
|
|
1098
|
+
// history entry restored from the store's cache can be the object a
|
|
1099
|
+
// reader was handed.
|
|
1100
|
+
handleData = mergePartialHandleData(
|
|
1101
|
+
handleData,
|
|
1102
|
+
data,
|
|
1103
|
+
newSegmentOrder,
|
|
1104
|
+
rawMatched,
|
|
1105
|
+
resolvedIds,
|
|
1106
|
+
);
|
|
1093
1107
|
} else {
|
|
1094
1108
|
// Full update: replace all data
|
|
1095
1109
|
handleData = data;
|