@rangojs/router 0.33.0 → 0.33.2
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/{chunk-TTXC2U3G.js → chunk-YP4HENIU.js} +4 -1
- package/dist/bin/{client-urls-projection-DQLWPDAT.js → client-urls-projection-J2BGDKQ4.js} +1 -1
- package/dist/bin/rango.js +1 -1
- package/dist/bin/{runtime-discovery-CZ2QHT4Q.js → runtime-discovery-O6KQ5KO2.js} +2 -2
- package/dist/bin/{temp-rsc-server-QRR6HD6T.js → temp-rsc-server-BOHXBOR7.js} +2 -2
- package/dist/testing/vitest.js +5 -0
- package/dist/types/browser/history-state.d.ts +21 -0
- package/dist/types/browser/navigation-transaction.d.ts +2 -0
- package/dist/types/loader-store.d.ts +11 -1
- package/dist/types/testing/render-route.d.ts +21 -1
- package/dist/types/testing/vitest-stubs/plugin-rsc-browser.d.ts +33 -0
- package/dist/vite/index.js +5 -2
- package/package.json +1 -1
- package/skills/app-structure/SKILL.md +7 -0
- package/skills/app-structure/large-app.md +3 -0
- package/skills/client-urls/SKILL.md +11 -0
- package/skills/hooks/data.md +5 -0
- package/skills/hooks/outlets.md +4 -0
- package/skills/hooks/state.md +13 -11
- package/skills/layout/SKILL.md +49 -0
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/client-components.md +67 -19
- package/skills/ux-architecture/SKILL.md +3 -1
- package/src/browser/history-state.ts +48 -0
- package/src/browser/navigation-bridge.ts +6 -1
- package/src/browser/navigation-transaction.ts +14 -6
- package/src/browser/server-action-bridge.ts +13 -8
- package/src/loader-store.ts +41 -4
- package/src/testing/render-route.tsx +122 -7
- package/src/testing/vitest-stubs/plugin-rsc-browser.ts +61 -0
- package/src/testing/vitest.ts +5 -0
- package/src/use-loader.tsx +29 -7
|
@@ -488,12 +488,15 @@ import {
|
|
|
488
488
|
} from "react";
|
|
489
489
|
|
|
490
490
|
// src/loader-store.ts
|
|
491
|
+
var NO_ROUTE_DATA = /* @__PURE__ */ new Set();
|
|
491
492
|
var EMPTY_SNAPSHOT = Object.freeze({
|
|
492
493
|
value: void 0,
|
|
493
494
|
hasValue: false,
|
|
494
495
|
error: null,
|
|
495
496
|
isLoading: false,
|
|
496
|
-
requestId: 0
|
|
497
|
+
requestId: 0,
|
|
498
|
+
valueFor: NO_ROUTE_DATA,
|
|
499
|
+
requestFor: NO_ROUTE_DATA
|
|
497
500
|
});
|
|
498
501
|
|
|
499
502
|
// src/client-urls/hoisted-revalidate.ts
|
package/dist/bin/rango.js
CHANGED
|
@@ -229,7 +229,7 @@ ${formatNestedRouterConflictError(nestedRouterConflict, "[rango]")}
|
|
|
229
229
|
}
|
|
230
230
|
let discoverAndWriteRouteTypes;
|
|
231
231
|
try {
|
|
232
|
-
const mod = await import("./runtime-discovery-
|
|
232
|
+
const mod = await import("./runtime-discovery-O6KQ5KO2.js");
|
|
233
233
|
discoverAndWriteRouteTypes = mod.discoverAndWriteRouteTypes;
|
|
234
234
|
} catch (err) {
|
|
235
235
|
if (err.code === "ERR_MODULE_NOT_FOUND" || err.code === "MODULE_NOT_FOUND") {
|
|
@@ -163,9 +163,9 @@ async function discoverAndWriteRouteTypes(opts) {
|
|
|
163
163
|
"Runtime discovery requires '@vitejs/plugin-rsc'. Install it with: pnpm add -D @vitejs/plugin-rsc"
|
|
164
164
|
);
|
|
165
165
|
}
|
|
166
|
-
const { createTempRscServer } = await import("./temp-rsc-server-
|
|
166
|
+
const { createTempRscServer } = await import("./temp-rsc-server-BOHXBOR7.js");
|
|
167
167
|
const { createDiscoveryState } = await import("./state-ACD2MGKJ.js");
|
|
168
|
-
const { discoverClientUrlProjections } = await import("./client-urls-projection-
|
|
168
|
+
const { discoverClientUrlProjections } = await import("./client-urls-projection-J2BGDKQ4.js");
|
|
169
169
|
const {
|
|
170
170
|
isFrameworkPlugin,
|
|
171
171
|
pickForwardedRunnerConfig,
|
|
@@ -27,7 +27,7 @@ import {
|
|
|
27
27
|
propertyKeyIs,
|
|
28
28
|
recordClientUrlsModule,
|
|
29
29
|
unwrapExpression
|
|
30
|
-
} from "./chunk-
|
|
30
|
+
} from "./chunk-YP4HENIU.js";
|
|
31
31
|
import "./chunk-3B7CKBRO.js";
|
|
32
32
|
import {
|
|
33
33
|
codeMatchIndices,
|
|
@@ -47,7 +47,7 @@ var DEFAULT_ROUTER_VERSIONS_KEY = "*";
|
|
|
47
47
|
// package.json
|
|
48
48
|
var package_default = {
|
|
49
49
|
name: "@rangojs/router",
|
|
50
|
-
version: "0.33.
|
|
50
|
+
version: "0.33.2",
|
|
51
51
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
52
52
|
keywords: [
|
|
53
53
|
"react",
|
package/dist/testing/vitest.js
CHANGED
|
@@ -296,6 +296,11 @@ function rangoTestAliases(opts = {}) {
|
|
|
296
296
|
{
|
|
297
297
|
find: /^@vitejs\/plugin-rsc\/rsc(\/(server|client))?$/,
|
|
298
298
|
replacement: here("src/testing/vitest-stubs/plugin-rsc.ts")
|
|
299
|
+
},
|
|
300
|
+
// useLoader().load() decodes renderRoute's loader answers through it.
|
|
301
|
+
{
|
|
302
|
+
find: /^@vitejs\/plugin-rsc\/browser$/,
|
|
303
|
+
replacement: here("src/testing/vitest-stubs/plugin-rsc-browser.ts")
|
|
299
304
|
}
|
|
300
305
|
];
|
|
301
306
|
if (rsdServerEdgePath) {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LocationStateSnapshot } from "./react/location-state-shared.js";
|
|
1
2
|
/**
|
|
2
3
|
* Resolve navigation state - handles both LocationStateEntry[] and plain formats
|
|
3
4
|
*/
|
|
@@ -11,6 +12,26 @@ export declare function buildHistoryState(userState: unknown, routerState?: {
|
|
|
11
12
|
intercept?: boolean;
|
|
12
13
|
sourceUrl?: string;
|
|
13
14
|
}, serverState?: Record<string, unknown>): Record<string, unknown> | null;
|
|
15
|
+
/**
|
|
16
|
+
* The entry a commit onto the entry on screen writes (router.refresh(), the
|
|
17
|
+
* cross-tab refresh): `entryState`, read when the commit lands, keeps its
|
|
18
|
+
* location state. Its scroll key and idx go (pushHistoryWithIdx and
|
|
19
|
+
* ensureHistoryKey write them again), and its intercept marks are this
|
|
20
|
+
* commit's: a refresh renders the URL's full page. Server-set state merges
|
|
21
|
+
* over the kept state. A flash a reader already cleared from the entry stays
|
|
22
|
+
* cleared: the entry is copied, never rebuilt from what readers show.
|
|
23
|
+
*/
|
|
24
|
+
export declare function keptEntryState(entryState: unknown, routerState?: {
|
|
25
|
+
intercept?: boolean;
|
|
26
|
+
sourceUrl?: string;
|
|
27
|
+
}, serverState?: Record<string, unknown>): Record<string, unknown> | null;
|
|
28
|
+
/**
|
|
29
|
+
* What readers get from a commit onto the entry on screen: the state they
|
|
30
|
+
* show, plus the server's, recorded under the client's version. Not re-read
|
|
31
|
+
* from the entry, so a flash value on screen stays until the next navigation
|
|
32
|
+
* or back/forward.
|
|
33
|
+
*/
|
|
34
|
+
export declare function shownLocationState(shown: LocationStateSnapshot, serverState?: Record<string, unknown>): Record<string, unknown>;
|
|
14
35
|
/** Check if a history state object contains location state keys. */
|
|
15
36
|
export declare function hasLocationState(state: unknown): boolean;
|
|
16
37
|
/**
|
|
@@ -41,6 +41,8 @@ interface CommitOptions {
|
|
|
41
41
|
* screen stays (EventController.commitLocationState).
|
|
42
42
|
*/
|
|
43
43
|
treeless?: boolean;
|
|
44
|
+
/** Commits onto the entry on screen, keeping its location state (see keptEntryState). */
|
|
45
|
+
sameEntry?: boolean;
|
|
44
46
|
}
|
|
45
47
|
/**
|
|
46
48
|
* Options that can override the pre-configured commit settings
|
|
@@ -53,6 +53,14 @@ export interface LoaderEntry<T = unknown> {
|
|
|
53
53
|
readonly isLoading: boolean;
|
|
54
54
|
/** Identifies the request that produced this snapshot. 0 means "no request". */
|
|
55
55
|
readonly requestId: number;
|
|
56
|
+
/**
|
|
57
|
+
* Route data (a route reader's `contextData`, by identity) of the readers on
|
|
58
|
+
* screen when `value` was requested. A route reader shows `value` only while
|
|
59
|
+
* its own route data is in this set.
|
|
60
|
+
*/
|
|
61
|
+
readonly valueFor: ReadonlySet<unknown>;
|
|
62
|
+
/** The same for the latest request: `isLoading` and `error`. */
|
|
63
|
+
readonly requestFor: ReadonlySet<unknown>;
|
|
56
64
|
}
|
|
57
65
|
/**
|
|
58
66
|
* Options for `subscribe`.
|
|
@@ -92,6 +100,8 @@ export interface SubscribeOptions {
|
|
|
92
100
|
* its own lifecycle.
|
|
93
101
|
*/
|
|
94
102
|
onStreamPending?: () => void;
|
|
103
|
+
/** The subscriber's committed route data. Route-context readers only. */
|
|
104
|
+
routeData?: () => unknown;
|
|
95
105
|
}
|
|
96
106
|
export declare class LoaderStore {
|
|
97
107
|
private readonly entries;
|
|
@@ -172,7 +182,7 @@ export declare class LoaderStore {
|
|
|
172
182
|
* error during the new request. Gated on `requestId === latestRequestId`
|
|
173
183
|
* for symmetry with the other mutators.
|
|
174
184
|
*/
|
|
175
|
-
beginRequest(bucketKey: string, requestId: number): void;
|
|
185
|
+
beginRequest(bucketKey: string, requestId: number, initiatorRouteData?: () => unknown): void;
|
|
176
186
|
/**
|
|
177
187
|
* Commit a successful result. No-op if `requestId` is not the latest
|
|
178
188
|
* (a newer `load()` was issued or `clear()` ran). Clearing `error` is
|
|
@@ -45,6 +45,13 @@
|
|
|
45
45
|
* lane production's Back uses (useNavigation() idle), with the loader
|
|
46
46
|
* data current then. The prefetch request, its decode and the HTTP cache
|
|
47
47
|
* are e2e territory.
|
|
48
|
+
* - useLoader().load() and a refreshGroup refresh reach the loader endpoint:
|
|
49
|
+
* renderRoute answers a GET for `_rsc_loader=<id>` with the last value the
|
|
50
|
+
* test gave that loader (the render seeds or respondToLoads()),
|
|
51
|
+
* unencoded; a pending Promise holds the answer until it settles, and a
|
|
52
|
+
* rejection is the loader's error. Needs the testing kit's
|
|
53
|
+
* `@vitejs/plugin-rsc/browser` alias (rangoTestAliases). A load() with params
|
|
54
|
+
* is answered with a loaderError; a body and non-GET loads are not answered.
|
|
48
55
|
* - A spec's transition({ when }) decides every commit with production's
|
|
49
56
|
* browser decision: navigate() and useRouter().push model kind "push",
|
|
50
57
|
* useRouter().replace kind "replace", refresh() kind "revalidate", and a
|
|
@@ -428,8 +435,10 @@ export interface TestRouterHandle {
|
|
|
428
435
|
/**
|
|
429
436
|
* Re-render the current location, as router.refresh() does: a spec's
|
|
430
437
|
* transition({ when }) decides it with kind "revalidate" (`to` is `from`).
|
|
438
|
+
* `options.loaders` is the loader data the response carries, merged over
|
|
439
|
+
* the render-time seeds as in navigate().
|
|
431
440
|
*/
|
|
432
|
-
refresh(): Promise<void>;
|
|
441
|
+
refresh(options?: Pick<RenderRouteOptions, "loaders">): Promise<void>;
|
|
433
442
|
/**
|
|
434
443
|
* Prefetch `url`, as a `<Link>` hover or viewport trigger does: the page is
|
|
435
444
|
* kept with the loader data its response would carry now (the render-time
|
|
@@ -469,6 +478,17 @@ export interface TestRouterHandle {
|
|
|
469
478
|
action(options?: Pick<RenderRouteOptions, "loaders"> & {
|
|
470
479
|
keepClientCache?: boolean;
|
|
471
480
|
}): Promise<void>;
|
|
481
|
+
/**
|
|
482
|
+
* Set what the loader endpoint answers from now on: a later
|
|
483
|
+
* `useLoader().load()` or refreshGroup refresh of each loader resolves to
|
|
484
|
+
* its value (a pending Promise holds the answer, a rejection is the
|
|
485
|
+
* loader's error). Renders nothing; the page on screen keeps its data.
|
|
486
|
+
*
|
|
487
|
+
* @example
|
|
488
|
+
* router.respondToLoads({ loaders: [[CartLoader, { count: 3 }]] });
|
|
489
|
+
* fireEvent.click(getByText("Refresh")); // load(): count 3
|
|
490
|
+
*/
|
|
491
|
+
respondToLoads(options: Pick<RenderRouteOptions, "loaders">): void;
|
|
472
492
|
/**
|
|
473
493
|
* The background revalidations a click on a stale prefetch started, in
|
|
474
494
|
* order: the page's pathname and search, and the segment ids the request
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub for `@vitejs/plugin-rsc/browser`, shipped so consumers do not have to
|
|
3
|
+
* write a per-file `vi.mock(...)`. The real entry imports the
|
|
4
|
+
* `virtual:vite-rsc/client-references` Vite virtual at the top level, which a
|
|
5
|
+
* bare Vitest process cannot resolve.
|
|
6
|
+
*
|
|
7
|
+
* The router reaches this module from `useLoader().load()` and a
|
|
8
|
+
* `refreshGroup` refresh (use-loader.tsx imports deps/browser.ts lazily).
|
|
9
|
+
* renderRoute answers those requests (render-route.tsx), and a DOM test has
|
|
10
|
+
* no Flight server to encode an answer: renderRoute's seeds are client values
|
|
11
|
+
* already. So the answer travels with its Response object, and
|
|
12
|
+
* `createFromFetch` hands it back unchanged, as a decoded payload. Any other
|
|
13
|
+
* response is rejected. The remaining exports exist for the re-export in
|
|
14
|
+
* deps/browser.ts; navigations, actions and the document are not decoded
|
|
15
|
+
* here.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* A loader endpoint Response whose decoded payload is `payload`
|
|
19
|
+
* (`{ loaderResult }` or `{ loaderResult: null, loaderError }`, as
|
|
20
|
+
* rsc/loader-fetch.ts answers).
|
|
21
|
+
*/
|
|
22
|
+
export declare function loaderAnswerResponse(payload: {
|
|
23
|
+
loaderResult: unknown;
|
|
24
|
+
loaderError?: {
|
|
25
|
+
message: string;
|
|
26
|
+
name: string;
|
|
27
|
+
};
|
|
28
|
+
}): Response;
|
|
29
|
+
export declare function createFromFetch<T>(response: Promise<Response> | Response): Promise<T>;
|
|
30
|
+
export declare const createFromReadableStream: () => Promise<never>;
|
|
31
|
+
export declare const encodeReply: () => Promise<never>;
|
|
32
|
+
export declare const setServerCallback: () => void;
|
|
33
|
+
export declare const createTemporaryReferenceSet: () => unknown;
|
package/dist/vite/index.js
CHANGED
|
@@ -5203,12 +5203,15 @@ import {
|
|
|
5203
5203
|
} from "react";
|
|
5204
5204
|
|
|
5205
5205
|
// src/loader-store.ts
|
|
5206
|
+
var NO_ROUTE_DATA = /* @__PURE__ */ new Set();
|
|
5206
5207
|
var EMPTY_SNAPSHOT = Object.freeze({
|
|
5207
5208
|
value: void 0,
|
|
5208
5209
|
hasValue: false,
|
|
5209
5210
|
error: null,
|
|
5210
5211
|
isLoading: false,
|
|
5211
|
-
requestId: 0
|
|
5212
|
+
requestId: 0,
|
|
5213
|
+
valueFor: NO_ROUTE_DATA,
|
|
5214
|
+
requestFor: NO_ROUTE_DATA
|
|
5212
5215
|
});
|
|
5213
5216
|
|
|
5214
5217
|
// src/router/route-trie-builder.ts
|
|
@@ -7327,7 +7330,7 @@ function resolveVersionsFrom(table, routerId2) {
|
|
|
7327
7330
|
// package.json
|
|
7328
7331
|
var package_default = {
|
|
7329
7332
|
name: "@rangojs/router",
|
|
7330
|
-
version: "0.33.
|
|
7333
|
+
version: "0.33.2",
|
|
7331
7334
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
7332
7335
|
keywords: [
|
|
7333
7336
|
"react",
|
package/package.json
CHANGED
|
@@ -120,6 +120,13 @@ here for the rules they share.
|
|
|
120
120
|
by name, keep page-specific blocks in their route, and write such a rule
|
|
121
121
|
for chrome that shows a param's data
|
|
122
122
|
([Prefetches are shared](#prefetches-are-shared)).
|
|
123
|
+
10. **A layout keeps its `<Outlet />` in one place.** Branch inside one shape
|
|
124
|
+
(`<>{promo ? <Banner /> : null}<Outlet /></>`), never between a bare
|
|
125
|
+
`return <Outlet />` and a wrapped one: the outlet moves, and every page
|
|
126
|
+
below remounts when the branch flips, its state reset and its mount
|
|
127
|
+
effects run again. When an early return is unavoidable and the other
|
|
128
|
+
branch is only a fragment, give the outlet the same `key` in both
|
|
129
|
+
(`/layout`, "Moving the Outlet remounts its children").
|
|
123
130
|
|
|
124
131
|
## A small app: one module
|
|
125
132
|
|
|
@@ -893,6 +893,9 @@ stay inside the project module can come from `useReverse(routes)` in
|
|
|
893
893
|
user's name): the capture is refused and the page never serves a shell.
|
|
894
894
|
- **`prefetch: false` on every loader**: each click then waits for a fill.
|
|
895
895
|
Flag the expensive, rarely-clicked ones.
|
|
896
|
+
- **A layout that moves its `<Outlet />` between branches** (a bare
|
|
897
|
+
`return <Outlet />` on one, a wrapped one on another): every page below it
|
|
898
|
+
remounts when the branch flips. Keep one shape (`/layout`).
|
|
896
899
|
- **A bare `isAction(refs)` predicate**: it is a hard `false` on navigation
|
|
897
900
|
too, so the loader stops following param changes. Use
|
|
898
901
|
`isAction() ? isAction(refs) : undefined` (`/loader`).
|
|
@@ -745,6 +745,17 @@ a hard load of the target URL renders the full route.
|
|
|
745
745
|
throwers in the client `ErrorBoundary`; `useFetchLoader` works unchanged
|
|
746
746
|
(route-independent lane; `revalidate()` deliberately not consulted).
|
|
747
747
|
Pinned in the router repo's client-urls hook-probe e2e.
|
|
748
|
+
- **An action called on the optimistic frame runs against the committed
|
|
749
|
+
page.** The destination mounts at the click, before the navigation
|
|
750
|
+
commits, so an action its mount effect calls is sent from the page the URL
|
|
751
|
+
bar still shows: the server runs it with that page's request context
|
|
752
|
+
(`ctx.url`, `ctx.params`, its middleware) and re-renders that page, not
|
|
753
|
+
the destination. Nothing of that render reaches the destination, and a
|
|
754
|
+
write still refreshes the destination once it commits (unless the action
|
|
755
|
+
called `keepClientCache()`). Pass what the action needs as arguments
|
|
756
|
+
instead of reading the request's URL or params. A link prefetched before
|
|
757
|
+
the click (the production default, `viewport`) usually commits before the
|
|
758
|
+
effect runs.
|
|
748
759
|
- **Location state follows the same split as the route hooks.** Inside the
|
|
749
760
|
optimistic branch (the destination and the group's client layouts)
|
|
750
761
|
`useLocationState` reads the state the navigation carries (`<Link state>`,
|
package/skills/hooks/data.md
CHANGED
|
@@ -200,6 +200,11 @@ shared value lives as long as at least one component using that key is mounted.
|
|
|
200
200
|
A persistent component (e.g. a header) keeps the value across navigations; a
|
|
201
201
|
route-scoped component's value is reclaimed when it unmounts. Registered-loader
|
|
202
202
|
reads (keyed or not) reset on navigation from fresh route data, as before.
|
|
203
|
+
A refreshed value belongs to the route data of the readers on screen when
|
|
204
|
+
`load()` was called: a reader that mounts on another page or on a new visit,
|
|
205
|
+
or that a navigation brings new route data, shows that route data from its
|
|
206
|
+
first frame, and a `load()` still in flight when you leave lands on no other
|
|
207
|
+
page; Back onto the refreshed page shows the refresh again.
|
|
203
208
|
|
|
204
209
|
**Refreshing multiple loaders together (`refreshGroup` + `useRefreshLoaders`)**:
|
|
205
210
|
|
package/skills/hooks/outlets.md
CHANGED
|
@@ -28,6 +28,10 @@ function DashboardLayout() {
|
|
|
28
28
|
`ParallelOutlet` requires the name and reads clearer at the call site. See
|
|
29
29
|
`/layout` and `/parallel` for the server side.
|
|
30
30
|
|
|
31
|
+
An outlet that moves between a layout's branches (bare in one, wrapped in
|
|
32
|
+
another) remounts its children; see `/layout`, "Moving the Outlet remounts its
|
|
33
|
+
children".
|
|
34
|
+
|
|
31
35
|
### useOutlet()
|
|
32
36
|
|
|
33
37
|
Access outlet content and its client-route presentation state programmatically:
|
package/skills/hooks/state.md
CHANGED
|
@@ -96,16 +96,17 @@ reads the entry on screen, not the one `history.state` already holds.
|
|
|
96
96
|
Back/forward and state the server sets on a navigation
|
|
97
97
|
(`ctx.setLocationState()`, `redirect(url, { state })`) follow the same rule.
|
|
98
98
|
|
|
99
|
-
| What changes the state
|
|
100
|
-
|
|
|
101
|
-
| `<Link state>`, `router.push()` / `router.replace()`
|
|
102
|
-
| back/forward
|
|
103
|
-
| the server, on a navigation (`setLocationState`, a redirect)
|
|
104
|
-
| the server, on a document load (a page load, a no-JS post)
|
|
105
|
-
| a navigation the server answers with nothing to re-render
|
|
106
|
-
| `router.push(url, { revalidate: false })` (no server fetch)
|
|
107
|
-
| a server action's `setLocationState` (no navigation)
|
|
108
|
-
| `
|
|
99
|
+
| What changes the state | A reader gets it |
|
|
100
|
+
| --------------------------------------------------------------- | ---------------------------------------------------------------------------------------- |
|
|
101
|
+
| `<Link state>`, `router.push()` / `router.replace()` | in the commit that shows the destination |
|
|
102
|
+
| back/forward | in the commit that restores the entry (cached or refetched) |
|
|
103
|
+
| the server, on a navigation (`setLocationState`, a redirect) | in the commit that shows the destination |
|
|
104
|
+
| the server, on a document load (a page load, a no-JS post) | once the page has hydrated, as stored state |
|
|
105
|
+
| a navigation the server answers with nothing to re-render | with the new location: the page on screen is the entry's |
|
|
106
|
+
| `router.push(url, { revalidate: false })` (no server fetch) | with the new location: the page does not change |
|
|
107
|
+
| a server action's `setLocationState` (no navigation) | when the action's response arrives, added to what readers show |
|
|
108
|
+
| `router.refresh()`, or another tab's action refreshing this one | in the commit that shows the refreshed page: what readers show, plus what the server set |
|
|
109
|
+
| `Def.write()` / `Def.delete()` | when the entry is next restored: back/forward to it, or a reload |
|
|
109
110
|
|
|
110
111
|
Three consequences:
|
|
111
112
|
|
|
@@ -260,7 +261,8 @@ and hydrating, so SSR and hydration agree), then clears it from `history.state`
|
|
|
260
261
|
`replaceState` in a `useEffect`. Every component reading the same flash
|
|
261
262
|
definition sees the value, whenever it mounts, until the next navigation or
|
|
262
263
|
back/forward commits its entry's state: an entry that carries no flash shows
|
|
263
|
-
none. A server action's state does not end it
|
|
264
|
+
none. A server action's state does not end it, nor a refresh, which keeps it
|
|
265
|
+
on screen without writing it back to the entry. Pressing back/forward will not
|
|
264
266
|
re-show the flash since it was cleared.
|
|
265
267
|
|
|
266
268
|
Set flash state from the server via `redirect()` with state (from a handler,
|
package/skills/layout/SKILL.md
CHANGED
|
@@ -12,6 +12,12 @@ layout stays mounted while you navigate between the routes it wraps. This skill
|
|
|
12
12
|
covers layout forms, outlets, orphan layouts (a layout inside a `path()`), and
|
|
13
13
|
layout revalidation.
|
|
14
14
|
|
|
15
|
+
**Rule: a layout keeps its `<Outlet />` in one place on every branch.** Branch
|
|
16
|
+
around the outlet, never around it and the outlet together: an early
|
|
17
|
+
`return <Outlet />` beside a wrapped one remounts every child segment each time
|
|
18
|
+
the branch flips (state resets, mount effects and the actions they call run
|
|
19
|
+
again). See [Moving the Outlet remounts its children](#moving-the-outlet-remounts-its-children).
|
|
20
|
+
|
|
15
21
|
Related skills: `/parallel` (named slots rendered by a layout), `/intercept`
|
|
16
22
|
(modal slots), `/route` (the routes a layout wraps), `/router-setup` (the
|
|
17
23
|
`document` shell that renders `<html>`).
|
|
@@ -131,6 +137,49 @@ function ShopLayout() {
|
|
|
131
137
|
|
|
132
138
|
A layout's `transition()` config wraps the content that flows through `<Outlet />` — not the layout chrome itself, and not sibling `<ParallelOutlet />` slots. Stacking transitions across nested layouts collapses around the deepest default outlet content. See [skills/view-transitions](../view-transitions/SKILL.md) for the full wrap rules and intercept-modal interaction.
|
|
133
139
|
|
|
140
|
+
### Moving the Outlet remounts its children
|
|
141
|
+
|
|
142
|
+
React keeps a subtree mounted only while each element on its path keeps the
|
|
143
|
+
same type at the same position. A layout that returns a bare `<Outlet />` on
|
|
144
|
+
one branch and wraps it on another (a fragment, a `<div>`) moves the outlet,
|
|
145
|
+
so when the branch flips, every child segment unmounts and mounts again:
|
|
146
|
+
client state resets, mount effects run again (an action they call is sent
|
|
147
|
+
again), and the content flashes. Prefer one shape with the branch inside it:
|
|
148
|
+
|
|
149
|
+
```tsx
|
|
150
|
+
// Remounts the children whenever `promo` flips
|
|
151
|
+
function CategoryLayout({ promo }: { promo: boolean }) {
|
|
152
|
+
if (!promo) return <Outlet />;
|
|
153
|
+
return (
|
|
154
|
+
<>
|
|
155
|
+
<PromoBanner />
|
|
156
|
+
<Outlet />
|
|
157
|
+
</>
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Keeps them mounted: the outlet keeps its position
|
|
162
|
+
function CategoryLayout({ promo }: { promo: boolean }) {
|
|
163
|
+
return (
|
|
164
|
+
<>
|
|
165
|
+
{promo ? <PromoBanner /> : null}
|
|
166
|
+
<Outlet />
|
|
167
|
+
</>
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
A stable `key` on the outlet (`<Outlet key="outlet" />` in both branches)
|
|
173
|
+
also keeps the children mounted when the only difference is a top-level
|
|
174
|
+
fragment: React reads a component's unkeyed top-level fragment as a list of
|
|
175
|
+
siblings and matches the keyed outlet across it. It does not help when one
|
|
176
|
+
branch wraps the outlet in an element (a `<div>`, a provider): the outlet then
|
|
177
|
+
sits one level deeper, and keys match only among siblings.
|
|
178
|
+
|
|
179
|
+
All of this applies to `<ParallelOutlet />` too. To reset the children on
|
|
180
|
+
purpose, change a `key` on a stable wrapper rather than relying on a shape
|
|
181
|
+
change.
|
|
182
|
+
|
|
134
183
|
## Named Outlets
|
|
135
184
|
|
|
136
185
|
For parallel routes, use named outlets (`<Outlet name="@sidebar" />` is
|
package/skills/testing/SKILL.md
CHANGED
|
@@ -101,6 +101,7 @@ Each primitive links to its sub-file (API + recipe + caveats).
|
|
|
101
101
|
| a handle's `collect`/accumulator, a seeded handle read, or a loader handle write | unit | [`collectHandle` / seeded `handles` / `handlePushes`](./handles.md) | `@rangojs/router/testing` |
|
|
102
102
|
| a CLIENT component reading router context (`useParams`/`useReverse`/`Outlet`/`useNavigation`/`useLoader`) | unit (DOM) | [`renderRoute`](./client-components.md) | `@rangojs/router/testing/dom` |
|
|
103
103
|
| a stale indicator from `useLoader().isLoading` while a `transition()` navigation re-runs the loader | unit (DOM) | [`renderRoute` + `navigate(url, { loaders })`](./client-components.md#held-navigation) | `@rangojs/router/testing/dom` |
|
|
104
|
+
| a component that refreshes with `useLoader().load()` or `useRefreshLoaders()`, and the readers that show the answer | unit (DOM) | [`renderRoute` + `router.respondToLoads`](./client-components.md#load-and-refresh-groups) | `@rangojs/router/testing/dom` |
|
|
104
105
|
| a component reading `useLocationState(Def)` after `router.push(url, { state: [Def(value)] })` | unit (DOM) | [`renderRoute` + `withLocationStateKey`](./client-components.md#location-state) | `@rangojs/router/testing[/dom]` |
|
|
105
106
|
| location state while a navigation is pending (a loader the test holds), or after a back/forward | unit (DOM) | [`navigate(url, { state, loaders })`, a `popstate` event](./client-components.md#state-while-a-navigation-is-pending) | `@rangojs/router/testing[/dom]` |
|
|
106
107
|
| a CLIENT component's hydration: its first client render against its own server HTML (`typeof window`, a late `<Suspense>`) | unit (DOM) | [`renderRoute` + `hydrate: true`](./client-components.md#hydration) | `@rangojs/router/testing/dom` |
|
|
@@ -33,23 +33,23 @@
|
|
|
33
33
|
|
|
34
34
|
### Context — client hooks it makes resolve (what your code receives)
|
|
35
35
|
|
|
36
|
-
| Hook | Meaning
|
|
37
|
-
| ------------------------------ |
|
|
38
|
-
| `useParams` | Params from the matched leaf pattern, with `options.params` merged over.
|
|
39
|
-
| `useReverse` | Reverse a name->pattern map to a URL; merges `useParams()` and the `mount`/`basename` prefix.
|
|
40
|
-
| `useHref` | Resolve an href against the mount/basename.
|
|
41
|
-
| `useMount` | The `include()` mount prefix (`options.mount`), else `"/"`.
|
|
42
|
-
| `useNavigation` | Navigation controller state: `loading` from a `navigate()` call to its commit, else `idle`.
|
|
43
|
-
| `useRouter` | The router handle, including `.basename`.
|
|
44
|
-
| `usePathname` | Current committed pathname.
|
|
45
|
-
| `useSearchParams` | Search params from the `request` URL.
|
|
46
|
-
| `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default).
|
|
47
|
-
| `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below). |
|
|
48
|
-
| `useLocationState` | SEEDED `history.state` value, or the `state` a navigation or a `popstate` brought (below).
|
|
49
|
-
| `useHandle` | SEEDED handle output (globally accumulated).
|
|
50
|
-
| `Outlet` | Renders the next segment in the chain (layout nesting).
|
|
51
|
-
| `useOutlet` | Next-segment `content` plus SEEDED `options.outletPending`.
|
|
52
|
-
| `useTheme` | Theme; throws without `options.theme` (see caveat).
|
|
36
|
+
| Hook | Meaning |
|
|
37
|
+
| ------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
38
|
+
| `useParams` | Params from the matched leaf pattern, with `options.params` merged over. |
|
|
39
|
+
| `useReverse` | Reverse a name->pattern map to a URL; merges `useParams()` and the `mount`/`basename` prefix. |
|
|
40
|
+
| `useHref` | Resolve an href against the mount/basename. |
|
|
41
|
+
| `useMount` | The `include()` mount prefix (`options.mount`), else `"/"`. |
|
|
42
|
+
| `useNavigation` | Navigation controller state: `loading` from a `navigate()` call to its commit, else `idle`. |
|
|
43
|
+
| `useRouter` | The router handle, including `.basename`. |
|
|
44
|
+
| `usePathname` | Current committed pathname. |
|
|
45
|
+
| `useSearchParams` | Search params from the `request` URL. |
|
|
46
|
+
| `useNonce` | SEEDED CSP nonce (`options.nonce`), else `undefined` (the browser default). |
|
|
47
|
+
| `useLoader` / `useFetchLoader` | SEEDED loader data (read path, not run path). Held-navigation `isLoading` is modeled (below), and `load()` is answered from the seeds ([`load()`](#load-and-refresh-groups)). |
|
|
48
|
+
| `useLocationState` | SEEDED `history.state` value, or the `state` a navigation or a `popstate` brought (below). |
|
|
49
|
+
| `useHandle` | SEEDED handle output (globally accumulated). |
|
|
50
|
+
| `Outlet` | Renders the next segment in the chain (layout nesting). |
|
|
51
|
+
| `useOutlet` | Next-segment `content` plus SEEDED `options.outletPending`. |
|
|
52
|
+
| `useTheme` | Theme; throws without `options.theme` (see caveat). |
|
|
53
53
|
|
|
54
54
|
### Returns — `RenderRouteResult`
|
|
55
55
|
|
|
@@ -77,8 +77,11 @@ type RenderRouteResult = RenderResult & {
|
|
|
77
77
|
},
|
|
78
78
|
): Promise<void>;
|
|
79
79
|
// re-render the current location, as router.refresh() does:
|
|
80
|
-
// a spec's when gets kind "revalidate" (`to` is `from`)
|
|
81
|
-
|
|
80
|
+
// a spec's when gets kind "revalidate" (`to` is `from`); `loaders` is the
|
|
81
|
+
// data the response carries
|
|
82
|
+
refresh(options?: {
|
|
83
|
+
loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
|
|
84
|
+
}): Promise<void>;
|
|
82
85
|
// keep `url` as a prefetch made now carries it (the seeds then, `loaders`
|
|
83
86
|
// over them); a later navigate(url) without `loaders` commits that data
|
|
84
87
|
prefetch(
|
|
@@ -94,6 +97,11 @@ type RenderRouteResult = RenderResult & {
|
|
|
94
97
|
loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
|
|
95
98
|
keepClientCache?: boolean;
|
|
96
99
|
}): Promise<void>;
|
|
100
|
+
// what a later useLoader().load() (or refreshGroup refresh) of each
|
|
101
|
+
// loader resolves to; renders nothing
|
|
102
|
+
respondToLoads(options: {
|
|
103
|
+
loaders?: ReadonlyArray<readonly [LoaderDefinition<any>, unknown]>;
|
|
104
|
+
}): void;
|
|
97
105
|
pathname(): string;
|
|
98
106
|
params(): Record<string, string>;
|
|
99
107
|
store: NavigationStore; // advanced
|
|
@@ -454,6 +462,46 @@ it("dims the held price while the next product streams", async () => {
|
|
|
454
462
|
- Pass settled data as a plain value, not `Promise.resolve(value)`: React has not observed a plain promise yet, so it counts as pending and renders one `stale` frame. (A Flight chunk that has already settled carries its status and is skipped, which a plain value mirrors.)
|
|
455
463
|
- `loading()` is not a spec field: a `<Suspense>` in your layout component stands in for it.
|
|
456
464
|
|
|
465
|
+
## `load()` and refresh groups
|
|
466
|
+
|
|
467
|
+
`useLoader().load()`, `useFetchLoader().load()` and a `useRefreshLoaders()` refresh reach renderRoute's loader endpoint, so a refresh button, a polling component, or a value one reader refreshes and another shows is unit-testable without a server or a `fetch` stub. The endpoint answers a GET for a loader with the last value the test gave it: the render-time `loaders`/`loaderData`, or `router.respondToLoads({ loaders })`, which only sets the answer (a `navigate()`, `prefetch()`, `action()` or `refresh()` seeds the page it commits, not the answer). A `load({ params })` is answered with a `loaderError`. The value is read when `load()` sends its request. A pending Promise holds the answer until it settles (`isLoading` stays `true`), and a rejected one arrives as the loader's error. The value reaches the component as given, never serialized.
|
|
468
|
+
|
|
469
|
+
The answer decodes through `@vitejs/plugin-rsc/browser`, which `rangoTestAliases()` (and `rangoTestConfig()`) alias to the testing kit's stub; remove any `vi.mock("@vitejs/plugin-rsc/browser")` you wrote for it.
|
|
470
|
+
|
|
471
|
+
```tsx
|
|
472
|
+
import { fireEvent, waitFor } from "@testing-library/react";
|
|
473
|
+
import { renderRoute } from "@rangojs/router/testing/dom";
|
|
474
|
+
import { useLoader } from "@rangojs/router/client";
|
|
475
|
+
import { CartLoader } from "../src/loaders"; // createLoader(fn, true)
|
|
476
|
+
|
|
477
|
+
function CartBadge() {
|
|
478
|
+
const { data, load, isLoading } = useLoader(CartLoader);
|
|
479
|
+
return (
|
|
480
|
+
<button data-testid="cart" onClick={() => load()}>
|
|
481
|
+
{isLoading ? "..." : data.count}
|
|
482
|
+
</button>
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
it("refreshes the count from the server", async () => {
|
|
487
|
+
const { getByTestId, router } = await renderRoute(
|
|
488
|
+
[{ path: "/cart", Component: CartBadge }],
|
|
489
|
+
{ request: "/cart", loaders: [[CartLoader, { count: 1 }]] },
|
|
490
|
+
);
|
|
491
|
+
router.respondToLoads({ loaders: [[CartLoader, { count: 3 }]] });
|
|
492
|
+
fireEvent.click(getByTestId("cart"));
|
|
493
|
+
// load() loads its decoder lazily: wait for the answer, not one act()
|
|
494
|
+
await waitFor(() => expect(getByTestId("cart").textContent).toBe("3"));
|
|
495
|
+
});
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
| `load()` call | Under `renderRoute` |
|
|
499
|
+
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
500
|
+
| `load()` with no options, a `key`, or a `refreshGroup` | Answered. Readers of a registered loader converge on the value; a reader that mounts later, or that a navigation brings new route data, shows that route data instead, as in production. |
|
|
501
|
+
| `load({ params })`, `load({ method: "POST", body })` | NOT answered: the request goes to the `fetch` your test environment has. Test the loader body with `runLoader`, and the round trip at e2e. |
|
|
502
|
+
| A loader the test never gave a value | Answered with an error naming the loader (`error` set; `throwOnError` throws it). |
|
|
503
|
+
| Any other request (a navigation's or action's Flight payload) | Passed through to the `fetch` installed before the mount. renderRoute never decodes one: a `navigate()` or `action()` seeds its data directly. A `fetch` stub the test installs after the mount replaces the endpoint. |
|
|
504
|
+
|
|
457
505
|
## Caveats
|
|
458
506
|
|
|
459
507
|
- Client tree ONLY. Does NOT catch server/client boundary reference-identity remount bugs, real Flight serialization errors, loader execution, middleware, or handler ordering — those are `renderServerTree` / `renderHandler` / e2e territory. Loader data is SEEDED, never run. `hydrate: true` adds an HTML pass of this same client tree ([Hydration](#hydration)), not a render of your routes on a server.
|
|
@@ -941,7 +941,9 @@ Each entry: what the user sees, why, the fix.
|
|
|
941
941
|
8. **Shared chrome in a route component.** It remounts on every navigation to
|
|
942
942
|
another route: its state resets and its own fallbacks show again. Fix: move
|
|
943
943
|
it into a `layout()` (a group `layout()` keeps its instance across its
|
|
944
|
-
routes; a server layout across everything below it).
|
|
944
|
+
routes; a server layout across everything below it). A layout that moves
|
|
945
|
+
its `<Outlet />` between branches remounts the children the same way; see
|
|
946
|
+
`/layout`, "Moving the Outlet remounts its children".
|
|
945
947
|
9. **`useEffect` fetching.** A spinner after the page has shown, nothing
|
|
946
948
|
prefetched or server-rendered, no re-run after an action, races on fast
|
|
947
949
|
navigations. Fix: a loader read with `useLoader`; `useFetchLoader` for
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
replaceCurrentHistoryState,
|
|
9
9
|
resolveLocationStateEntries,
|
|
10
10
|
stampLocationState,
|
|
11
|
+
type LocationStateSnapshot,
|
|
11
12
|
} from "./react/location-state-shared.js";
|
|
12
13
|
import { WIRE } from "../wire-params.js";
|
|
13
14
|
|
|
@@ -110,6 +111,53 @@ export function buildHistoryState(
|
|
|
110
111
|
return Object.keys(result).length > 0 ? result : null;
|
|
111
112
|
}
|
|
112
113
|
|
|
114
|
+
/**
|
|
115
|
+
* The entry a commit onto the entry on screen writes (router.refresh(), the
|
|
116
|
+
* cross-tab refresh): `entryState`, read when the commit lands, keeps its
|
|
117
|
+
* location state. Its scroll key and idx go (pushHistoryWithIdx and
|
|
118
|
+
* ensureHistoryKey write them again), and its intercept marks are this
|
|
119
|
+
* commit's: a refresh renders the URL's full page. Server-set state merges
|
|
120
|
+
* over the kept state. A flash a reader already cleared from the entry stays
|
|
121
|
+
* cleared: the entry is copied, never rebuilt from what readers show.
|
|
122
|
+
*/
|
|
123
|
+
export function keptEntryState(
|
|
124
|
+
entryState: unknown,
|
|
125
|
+
routerState?: { intercept?: boolean; sourceUrl?: string },
|
|
126
|
+
serverState?: Record<string, unknown>,
|
|
127
|
+
): Record<string, unknown> | null {
|
|
128
|
+
let result: Record<string, unknown> =
|
|
129
|
+
entryState !== null && typeof entryState === "object"
|
|
130
|
+
? { ...entryState }
|
|
131
|
+
: {};
|
|
132
|
+
delete result.key;
|
|
133
|
+
delete result.idx;
|
|
134
|
+
delete result.intercept;
|
|
135
|
+
delete result.sourceUrl;
|
|
136
|
+
if (routerState?.intercept) {
|
|
137
|
+
result.intercept = true;
|
|
138
|
+
if (routerState.sourceUrl) result.sourceUrl = routerState.sourceUrl;
|
|
139
|
+
}
|
|
140
|
+
if (serverState && Object.keys(serverState).length > 0) {
|
|
141
|
+
result = addLocationState(result, serverState);
|
|
142
|
+
}
|
|
143
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* What readers get from a commit onto the entry on screen: the state they
|
|
148
|
+
* show, plus the server's, recorded under the client's version. Not re-read
|
|
149
|
+
* from the entry, so a flash value on screen stays until the next navigation
|
|
150
|
+
* or back/forward.
|
|
151
|
+
*/
|
|
152
|
+
export function shownLocationState(
|
|
153
|
+
shown: LocationStateSnapshot,
|
|
154
|
+
serverState?: Record<string, unknown>,
|
|
155
|
+
): Record<string, unknown> {
|
|
156
|
+
const result: Record<string, unknown> = { ...shown, ...serverState };
|
|
157
|
+
stampLocationState(result);
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
113
161
|
/** Check if a history state object contains location state keys. */
|
|
114
162
|
export function hasLocationState(state: unknown): boolean {
|
|
115
163
|
if (!state || typeof state !== "object") return false;
|
|
@@ -769,7 +769,12 @@ export function createNavigationBridge(
|
|
|
769
769
|
[],
|
|
770
770
|
false,
|
|
771
771
|
tx.handle.signal,
|
|
772
|
-
tx.with({
|
|
772
|
+
tx.with({
|
|
773
|
+
url: window.location.href,
|
|
774
|
+
replace: true,
|
|
775
|
+
scroll: false,
|
|
776
|
+
sameEntry: true,
|
|
777
|
+
}),
|
|
773
778
|
{ type: "navigate", refresh: true, boundary: shown[at]?.id },
|
|
774
779
|
);
|
|
775
780
|
} catch (error) {
|
|
@@ -17,8 +17,10 @@ import type { EventController, NavigationHandle } from "./event-controller.js";
|
|
|
17
17
|
import { debugLog } from "./logging.js";
|
|
18
18
|
import {
|
|
19
19
|
buildHistoryState,
|
|
20
|
+
keptEntryState,
|
|
20
21
|
mergeLocationState,
|
|
21
22
|
pushHistoryWithIdx,
|
|
23
|
+
shownLocationState,
|
|
22
24
|
} from "./history-state.js";
|
|
23
25
|
|
|
24
26
|
export { resolveNavigationState } from "./history-state.js";
|
|
@@ -67,6 +69,8 @@ interface CommitOptions {
|
|
|
67
69
|
* screen stays (EventController.commitLocationState).
|
|
68
70
|
*/
|
|
69
71
|
treeless?: boolean;
|
|
72
|
+
/** Commits onto the entry on screen, keeping its location state (see keptEntryState). */
|
|
73
|
+
sameEntry?: boolean;
|
|
70
74
|
}
|
|
71
75
|
|
|
72
76
|
/**
|
|
@@ -227,16 +231,20 @@ export function createNavigationTransaction(
|
|
|
227
231
|
return { scroll: { enabled: scroll } };
|
|
228
232
|
}
|
|
229
233
|
|
|
230
|
-
const
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
serverState
|
|
234
|
-
);
|
|
234
|
+
const marks = { intercept, sourceUrl: interceptSourceUrl };
|
|
235
|
+
const historyState = opts.sameEntry
|
|
236
|
+
? keptEntryState(window.history.state, marks, serverState)
|
|
237
|
+
: buildHistoryState(opts.state, marks, serverState);
|
|
235
238
|
|
|
236
239
|
pushHistoryWithIdx(historyState, url, replace ?? false);
|
|
237
240
|
ensureHistoryKey();
|
|
238
241
|
store.rememberDisplayedEntry(opts.metadata);
|
|
239
|
-
eventController.commitLocationState(
|
|
242
|
+
eventController.commitLocationState(
|
|
243
|
+
opts.sameEntry
|
|
244
|
+
? shownLocationState(eventController.getLocationState(), serverState)
|
|
245
|
+
: historyState,
|
|
246
|
+
opts.treeless,
|
|
247
|
+
);
|
|
240
248
|
|
|
241
249
|
handle.complete(parsedUrl);
|
|
242
250
|
|
|
@@ -885,14 +885,19 @@ export function createServerActionBridge(
|
|
|
885
885
|
throw new Error("No matched segments in response");
|
|
886
886
|
}
|
|
887
887
|
|
|
888
|
-
// Reconcile server segments with cached segments (single source of
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
888
|
+
// Reconcile server segments with cached segments (single source of
|
|
889
|
+
// truth). A page that left commits nothing (navigated-away), and the
|
|
890
|
+
// cache on screen is another page's: reconciling would report missing
|
|
891
|
+
// segments.
|
|
892
|
+
const reconciled = navigatedSince()
|
|
893
|
+
? { segments: [], mainSegments: [], interceptSegments: [] }
|
|
894
|
+
: reconcileSegments({
|
|
895
|
+
actor: "action",
|
|
896
|
+
matched,
|
|
897
|
+
diff: diff || [],
|
|
898
|
+
serverSegments: segments || [],
|
|
899
|
+
cachedSegments,
|
|
900
|
+
});
|
|
896
901
|
const fullSegments = reconciled.segments;
|
|
897
902
|
|
|
898
903
|
const returnData = returnValue?.data;
|
package/src/loader-store.ts
CHANGED
|
@@ -58,14 +58,26 @@ export interface LoaderEntry<T = unknown> {
|
|
|
58
58
|
readonly isLoading: boolean;
|
|
59
59
|
/** Identifies the request that produced this snapshot. 0 means "no request". */
|
|
60
60
|
readonly requestId: number;
|
|
61
|
+
/**
|
|
62
|
+
* Route data (a route reader's `contextData`, by identity) of the readers on
|
|
63
|
+
* screen when `value` was requested. A route reader shows `value` only while
|
|
64
|
+
* its own route data is in this set.
|
|
65
|
+
*/
|
|
66
|
+
readonly valueFor: ReadonlySet<unknown>;
|
|
67
|
+
/** The same for the latest request: `isLoading` and `error`. */
|
|
68
|
+
readonly requestFor: ReadonlySet<unknown>;
|
|
61
69
|
}
|
|
62
70
|
|
|
71
|
+
const NO_ROUTE_DATA: ReadonlySet<unknown> = new Set();
|
|
72
|
+
|
|
63
73
|
const EMPTY_SNAPSHOT: LoaderEntry = Object.freeze({
|
|
64
74
|
value: undefined,
|
|
65
75
|
hasValue: false,
|
|
66
76
|
error: null,
|
|
67
77
|
isLoading: false,
|
|
68
78
|
requestId: 0,
|
|
79
|
+
valueFor: NO_ROUTE_DATA,
|
|
80
|
+
requestFor: NO_ROUTE_DATA,
|
|
69
81
|
});
|
|
70
82
|
|
|
71
83
|
/**
|
|
@@ -106,6 +118,8 @@ export interface SubscribeOptions {
|
|
|
106
118
|
* its own lifecycle.
|
|
107
119
|
*/
|
|
108
120
|
onStreamPending?: () => void;
|
|
121
|
+
/** The subscriber's committed route data. Route-context readers only. */
|
|
122
|
+
routeData?: () => unknown;
|
|
109
123
|
}
|
|
110
124
|
|
|
111
125
|
interface InternalEntry {
|
|
@@ -139,6 +153,7 @@ interface InternalEntry {
|
|
|
139
153
|
refetch: (() => Promise<void>) | undefined;
|
|
140
154
|
/** Subscribers' `onStreamPending` callbacks (see SubscribeOptions). */
|
|
141
155
|
streamPendingListeners: Set<() => void>;
|
|
156
|
+
routeDataSources: Set<() => unknown>;
|
|
142
157
|
}
|
|
143
158
|
|
|
144
159
|
/**
|
|
@@ -188,6 +203,7 @@ export class LoaderStore {
|
|
|
188
203
|
groups: new Map(),
|
|
189
204
|
refetch: undefined,
|
|
190
205
|
streamPendingListeners: new Set(),
|
|
206
|
+
routeDataSources: new Set(),
|
|
191
207
|
};
|
|
192
208
|
this.entries.set(bucketKey, e);
|
|
193
209
|
}
|
|
@@ -234,9 +250,12 @@ export class LoaderStore {
|
|
|
234
250
|
e.listeners.add(cb);
|
|
235
251
|
const onStreamPending = options?.onStreamPending;
|
|
236
252
|
if (onStreamPending) e.streamPendingListeners.add(onStreamPending);
|
|
253
|
+
const routeData = options?.routeData;
|
|
254
|
+
if (routeData) e.routeDataSources.add(routeData);
|
|
237
255
|
return () => {
|
|
238
256
|
e.listeners.delete(cb);
|
|
239
257
|
if (onStreamPending) e.streamPendingListeners.delete(onStreamPending);
|
|
258
|
+
if (routeData) e.routeDataSources.delete(routeData);
|
|
240
259
|
// Group membership is refcounted per subscriber so refreshGroups() never
|
|
241
260
|
// fetches for an unmounted reader, and a bucket shared by subscribers in
|
|
242
261
|
// different groups stays in each group until ALL of that group's
|
|
@@ -414,16 +433,30 @@ export class LoaderStore {
|
|
|
414
433
|
* error during the new request. Gated on `requestId === latestRequestId`
|
|
415
434
|
* for symmetry with the other mutators.
|
|
416
435
|
*/
|
|
417
|
-
beginRequest(
|
|
436
|
+
beginRequest(
|
|
437
|
+
bucketKey: string,
|
|
438
|
+
requestId: number,
|
|
439
|
+
initiatorRouteData?: () => unknown,
|
|
440
|
+
): void {
|
|
418
441
|
const e = this.entries.get(bucketKey);
|
|
419
442
|
if (!e || requestId !== e.latestRequestId) return;
|
|
420
|
-
|
|
443
|
+
const requestFor = new Set<unknown>();
|
|
444
|
+
if (initiatorRouteData) requestFor.add(initiatorRouteData());
|
|
445
|
+
for (const read of e.routeDataSources) requestFor.add(read());
|
|
446
|
+
const prev = e.snapshot;
|
|
447
|
+
if (
|
|
448
|
+
prev.isLoading &&
|
|
449
|
+
prev.error === null &&
|
|
450
|
+
[...requestFor].every((d) => prev.requestFor.has(d))
|
|
451
|
+
) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
421
454
|
e.snapshot = Object.freeze({
|
|
422
|
-
|
|
423
|
-
hasValue: e.snapshot.hasValue,
|
|
455
|
+
...prev,
|
|
424
456
|
error: null,
|
|
425
457
|
isLoading: true,
|
|
426
458
|
requestId,
|
|
459
|
+
requestFor,
|
|
427
460
|
});
|
|
428
461
|
this.notify(e);
|
|
429
462
|
}
|
|
@@ -442,6 +475,8 @@ export class LoaderStore {
|
|
|
442
475
|
error: null,
|
|
443
476
|
isLoading: false,
|
|
444
477
|
requestId,
|
|
478
|
+
valueFor: e.snapshot.requestFor,
|
|
479
|
+
requestFor: e.snapshot.requestFor,
|
|
445
480
|
});
|
|
446
481
|
this.notify(e);
|
|
447
482
|
this.dropIfSettled(bucketKey, e);
|
|
@@ -461,6 +496,8 @@ export class LoaderStore {
|
|
|
461
496
|
error,
|
|
462
497
|
isLoading: false,
|
|
463
498
|
requestId,
|
|
499
|
+
valueFor: e.snapshot.valueFor,
|
|
500
|
+
requestFor: e.snapshot.requestFor,
|
|
464
501
|
});
|
|
465
502
|
this.notify(e);
|
|
466
503
|
this.dropIfSettled(bucketKey, e);
|
|
@@ -45,6 +45,13 @@
|
|
|
45
45
|
* lane production's Back uses (useNavigation() idle), with the loader
|
|
46
46
|
* data current then. The prefetch request, its decode and the HTTP cache
|
|
47
47
|
* are e2e territory.
|
|
48
|
+
* - useLoader().load() and a refreshGroup refresh reach the loader endpoint:
|
|
49
|
+
* renderRoute answers a GET for `_rsc_loader=<id>` with the last value the
|
|
50
|
+
* test gave that loader (the render seeds or respondToLoads()),
|
|
51
|
+
* unencoded; a pending Promise holds the answer until it settles, and a
|
|
52
|
+
* rejection is the loader's error. Needs the testing kit's
|
|
53
|
+
* `@vitejs/plugin-rsc/browser` alias (rangoTestAliases). A load() with params
|
|
54
|
+
* is answered with a loaderError; a body and non-GET loads are not answered.
|
|
48
55
|
* - A spec's transition({ when }) decides every commit with production's
|
|
49
56
|
* browser decision: navigate() and useRouter().push model kind "push",
|
|
50
57
|
* useRouter().replace kind "replace", refresh() kind "revalidate", and a
|
|
@@ -128,14 +135,18 @@ import {
|
|
|
128
135
|
buildHistoryState,
|
|
129
136
|
applyDocumentLocationState,
|
|
130
137
|
clearLocationStateOnDocumentLoad,
|
|
138
|
+
keptEntryState,
|
|
131
139
|
pushHistoryWithIdx,
|
|
132
140
|
resolveNavigationState,
|
|
141
|
+
shownLocationState,
|
|
133
142
|
} from "../browser/history-state.js";
|
|
134
143
|
import { withLocationStateKey } from "./location-state-key.js";
|
|
135
144
|
import type { Handle } from "../handle.js";
|
|
136
145
|
import type { ThemeConfig } from "../theme/types.js";
|
|
137
146
|
import { resolveThemeConfig } from "../theme/constants.js";
|
|
138
147
|
import { isUnderTestRunner } from "../runtime-env.js";
|
|
148
|
+
import { loaderAnswerResponse } from "./vitest-stubs/plugin-rsc-browser.js";
|
|
149
|
+
import { WIRE } from "../wire-params.js";
|
|
139
150
|
import {
|
|
140
151
|
scheduleStalePrefetchRevalidation,
|
|
141
152
|
setupNavigationBridgeDelegatedPrefetch,
|
|
@@ -532,8 +543,10 @@ export interface TestRouterHandle {
|
|
|
532
543
|
/**
|
|
533
544
|
* Re-render the current location, as router.refresh() does: a spec's
|
|
534
545
|
* transition({ when }) decides it with kind "revalidate" (`to` is `from`).
|
|
546
|
+
* `options.loaders` is the loader data the response carries, merged over
|
|
547
|
+
* the render-time seeds as in navigate().
|
|
535
548
|
*/
|
|
536
|
-
refresh(): Promise<void>;
|
|
549
|
+
refresh(options?: Pick<RenderRouteOptions, "loaders">): Promise<void>;
|
|
537
550
|
/**
|
|
538
551
|
* Prefetch `url`, as a `<Link>` hover or viewport trigger does: the page is
|
|
539
552
|
* kept with the loader data its response would carry now (the render-time
|
|
@@ -575,6 +588,17 @@ export interface TestRouterHandle {
|
|
|
575
588
|
keepClientCache?: boolean;
|
|
576
589
|
},
|
|
577
590
|
): Promise<void>;
|
|
591
|
+
/**
|
|
592
|
+
* Set what the loader endpoint answers from now on: a later
|
|
593
|
+
* `useLoader().load()` or refreshGroup refresh of each loader resolves to
|
|
594
|
+
* its value (a pending Promise holds the answer, a rejection is the
|
|
595
|
+
* loader's error). Renders nothing; the page on screen keeps its data.
|
|
596
|
+
*
|
|
597
|
+
* @example
|
|
598
|
+
* router.respondToLoads({ loaders: [[CartLoader, { count: 3 }]] });
|
|
599
|
+
* fireEvent.click(getByText("Refresh")); // load(): count 3
|
|
600
|
+
*/
|
|
601
|
+
respondToLoads(options: Pick<RenderRouteOptions, "loaders">): void;
|
|
578
602
|
/**
|
|
579
603
|
* The background revalidations a click on a stale prefetch started, in
|
|
580
604
|
* order: the page's pathname and search, and the segment ids the request
|
|
@@ -689,6 +713,38 @@ function LinkInterceptionRegistration({
|
|
|
689
713
|
return null;
|
|
690
714
|
}
|
|
691
715
|
|
|
716
|
+
/**
|
|
717
|
+
* The loader endpoint `useLoader().load()` fetches (`_rsc_loader=<id>`):
|
|
718
|
+
* installed on `fetch` while the tree is mounted, every other request passed
|
|
719
|
+
* through. A test that stubs `fetch` after the mount replaces it.
|
|
720
|
+
*/
|
|
721
|
+
function LoaderEndpoint({
|
|
722
|
+
answer,
|
|
723
|
+
}: {
|
|
724
|
+
answer: (loaderId: string, hasParams: boolean) => Promise<Response>;
|
|
725
|
+
}): null {
|
|
726
|
+
useEffect(() => {
|
|
727
|
+
const passThrough = globalThis.fetch;
|
|
728
|
+
// useLoader fetches with a URL string and an init.
|
|
729
|
+
const endpoint: typeof fetch = (input, init) => {
|
|
730
|
+
const url = new URL(String(input), TEST_ORIGIN);
|
|
731
|
+
const loaderId = url.searchParams.get(WIRE.loader);
|
|
732
|
+
if (
|
|
733
|
+
loaderId !== null &&
|
|
734
|
+
(init?.method ?? "GET").toUpperCase() === "GET"
|
|
735
|
+
) {
|
|
736
|
+
return answer(loaderId, url.searchParams.has(WIRE.loaderParams));
|
|
737
|
+
}
|
|
738
|
+
return passThrough(input, init);
|
|
739
|
+
};
|
|
740
|
+
globalThis.fetch = endpoint;
|
|
741
|
+
return () => {
|
|
742
|
+
if (globalThis.fetch === endpoint) globalThis.fetch = passThrough;
|
|
743
|
+
};
|
|
744
|
+
}, [answer]);
|
|
745
|
+
return null;
|
|
746
|
+
}
|
|
747
|
+
|
|
692
748
|
/**
|
|
693
749
|
* The marker production's root sets from its effect after hydration
|
|
694
750
|
* (browser/rsc-router.tsx Rango), so a boundary that hydrates in a later pass
|
|
@@ -841,6 +897,43 @@ export async function renderRoute(
|
|
|
841
897
|
return out;
|
|
842
898
|
};
|
|
843
899
|
const loaderData = seedLoaders(options.loaderData ?? {}, options.loaders);
|
|
900
|
+
// What the loader endpoint answers (LoaderEndpoint): the render seeds, then
|
|
901
|
+
// what respondToLoads() gave each loader.
|
|
902
|
+
const loadAnswers: Record<string, unknown> = { ...loaderData };
|
|
903
|
+
// The payload has rsc/loader-fetch.ts's shape; the value is taken when the
|
|
904
|
+
// request is made, as a server reads its data then.
|
|
905
|
+
const answerLoad = async (
|
|
906
|
+
loaderId: string,
|
|
907
|
+
hasParams: boolean,
|
|
908
|
+
): Promise<Response> => {
|
|
909
|
+
type Answer = Parameters<typeof loaderAnswerResponse>[0];
|
|
910
|
+
const failure = (name: string, message: string): Answer => ({
|
|
911
|
+
loaderResult: null,
|
|
912
|
+
loaderError: { name, message },
|
|
913
|
+
});
|
|
914
|
+
let payload: Answer;
|
|
915
|
+
if (hasParams) {
|
|
916
|
+
payload = failure(
|
|
917
|
+
"Error",
|
|
918
|
+
`renderRoute does not answer parameterized load() calls (loader "${loaderId}"). Call load() without params, or stub fetch.`,
|
|
919
|
+
);
|
|
920
|
+
} else if (!(loaderId in loadAnswers)) {
|
|
921
|
+
payload = failure(
|
|
922
|
+
"Error",
|
|
923
|
+
`renderRoute: no value for loader "${loaderId}". Seed it with \`loaders\` or router.respondToLoads().`,
|
|
924
|
+
);
|
|
925
|
+
} else {
|
|
926
|
+
try {
|
|
927
|
+
payload = { loaderResult: await loadAnswers[loaderId] };
|
|
928
|
+
} catch (error) {
|
|
929
|
+
payload = failure(
|
|
930
|
+
error instanceof Error ? error.name : "Error",
|
|
931
|
+
error instanceof Error ? error.message : String(error),
|
|
932
|
+
);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
return loaderAnswerResponse(payload);
|
|
936
|
+
};
|
|
844
937
|
|
|
845
938
|
if (typeof window !== "undefined") {
|
|
846
939
|
// The tree has no app version (its bridge reports none), so a seed needs
|
|
@@ -967,6 +1060,8 @@ export async function renderRoute(
|
|
|
967
1060
|
kind?: TransitionWhenKind;
|
|
968
1061
|
transition?: boolean;
|
|
969
1062
|
streamUntil?: Promise<unknown>;
|
|
1063
|
+
/** refresh(): a commit onto the entry on screen. */
|
|
1064
|
+
sameEntry?: boolean;
|
|
970
1065
|
};
|
|
971
1066
|
|
|
972
1067
|
// The page each history entry shows, by the entry's `idx` stamp (the
|
|
@@ -1120,7 +1215,7 @@ export async function renderRoute(
|
|
|
1120
1215
|
// on the event controller, which the provider takes with the payload.
|
|
1121
1216
|
// The URL stays put: renderRoute tracks location on the event
|
|
1122
1217
|
// controller, not window.location. router.navigate() without `state` or
|
|
1123
|
-
// `replace: true
|
|
1218
|
+
// `replace: true` leaves history alone.
|
|
1124
1219
|
const history = navOptions?.history;
|
|
1125
1220
|
const historyState = history
|
|
1126
1221
|
? buildHistoryState(
|
|
@@ -1199,6 +1294,18 @@ export async function renderRoute(
|
|
|
1199
1294
|
history.replace ?? false,
|
|
1200
1295
|
);
|
|
1201
1296
|
eventController.commitLocationState(historyState);
|
|
1297
|
+
} else if (navOptions?.sameEntry) {
|
|
1298
|
+
// refresh() commits onto the entry on screen as production's does
|
|
1299
|
+
// (navigation-transaction.ts sameEntry): the entry keeps its location
|
|
1300
|
+
// state, readers keep what they show.
|
|
1301
|
+
pushHistoryWithIdx(
|
|
1302
|
+
keptEntryState(window.history.state),
|
|
1303
|
+
window.location.href,
|
|
1304
|
+
true,
|
|
1305
|
+
);
|
|
1306
|
+
eventController.commitLocationState(
|
|
1307
|
+
shownLocationState(eventController.getLocationState()),
|
|
1308
|
+
);
|
|
1202
1309
|
}
|
|
1203
1310
|
entryOnScreen = entryIndex();
|
|
1204
1311
|
entries.set(entryOnScreen, {
|
|
@@ -1229,9 +1336,15 @@ export async function renderRoute(
|
|
|
1229
1336
|
});
|
|
1230
1337
|
};
|
|
1231
1338
|
|
|
1232
|
-
const refresh = (
|
|
1339
|
+
const refresh = (
|
|
1340
|
+
refreshOptions?: Pick<RenderRouteOptions, "loaders">,
|
|
1341
|
+
): Promise<void> => {
|
|
1233
1342
|
const current = new URL(eventController.getLocation().href);
|
|
1234
|
-
return navigate(current.pathname + current.search, {
|
|
1343
|
+
return navigate(current.pathname + current.search, {
|
|
1344
|
+
kind: "revalidate",
|
|
1345
|
+
loaders: refreshOptions?.loaders,
|
|
1346
|
+
sameEntry: true,
|
|
1347
|
+
});
|
|
1235
1348
|
};
|
|
1236
1349
|
|
|
1237
1350
|
// prefetch(): the pages prefetched so far, by pathname and search, each with
|
|
@@ -1358,9 +1471,7 @@ export async function renderRoute(
|
|
|
1358
1471
|
keepClientCache?: boolean;
|
|
1359
1472
|
},
|
|
1360
1473
|
): Promise<void> => {
|
|
1361
|
-
|
|
1362
|
-
loaderData[ensureSyntheticId(loader)] = data;
|
|
1363
|
-
}
|
|
1474
|
+
Object.assign(loaderData, seedLoaders({}, actionOptions?.loaders));
|
|
1364
1475
|
const page = await renderPageAgain(
|
|
1365
1476
|
new URL(eventController.getLocation().href),
|
|
1366
1477
|
"action",
|
|
@@ -1508,6 +1619,7 @@ export async function renderRoute(
|
|
|
1508
1619
|
/>
|
|
1509
1620
|
<DelegatedPrefetchRegistration bridge={bridge} />
|
|
1510
1621
|
<LinkInterceptionRegistration bridge={bridge} />
|
|
1622
|
+
<LoaderEndpoint answer={answerLoad} />
|
|
1511
1623
|
{options.hydrate && <HydratedMarker />}
|
|
1512
1624
|
{applyLateHandles && <AfterTree run={applyLateHandles} />}
|
|
1513
1625
|
{resolveDocumentRevealed && <AfterTree run={resolveDocumentRevealed} />}
|
|
@@ -1587,6 +1699,9 @@ export async function renderRoute(
|
|
|
1587
1699
|
refresh,
|
|
1588
1700
|
prefetch,
|
|
1589
1701
|
action,
|
|
1702
|
+
respondToLoads: (respondOptions) => {
|
|
1703
|
+
Object.assign(loadAnswers, seedLoaders({}, respondOptions.loaders));
|
|
1704
|
+
},
|
|
1590
1705
|
revalidations: () => [...revalidations],
|
|
1591
1706
|
pathname: () => new URL(eventController.getLocation().href).pathname,
|
|
1592
1707
|
params: () => eventController.getParams(),
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub for `@vitejs/plugin-rsc/browser`, shipped so consumers do not have to
|
|
3
|
+
* write a per-file `vi.mock(...)`. The real entry imports the
|
|
4
|
+
* `virtual:vite-rsc/client-references` Vite virtual at the top level, which a
|
|
5
|
+
* bare Vitest process cannot resolve.
|
|
6
|
+
*
|
|
7
|
+
* The router reaches this module from `useLoader().load()` and a
|
|
8
|
+
* `refreshGroup` refresh (use-loader.tsx imports deps/browser.ts lazily).
|
|
9
|
+
* renderRoute answers those requests (render-route.tsx), and a DOM test has
|
|
10
|
+
* no Flight server to encode an answer: renderRoute's seeds are client values
|
|
11
|
+
* already. So the answer travels with its Response object, and
|
|
12
|
+
* `createFromFetch` hands it back unchanged, as a decoded payload. Any other
|
|
13
|
+
* response is rejected. The remaining exports exist for the re-export in
|
|
14
|
+
* deps/browser.ts; navigations, actions and the document are not decoded
|
|
15
|
+
* here.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const answers = new WeakMap<object, unknown>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A loader endpoint Response whose decoded payload is `payload`
|
|
22
|
+
* (`{ loaderResult }` or `{ loaderResult: null, loaderError }`, as
|
|
23
|
+
* rsc/loader-fetch.ts answers).
|
|
24
|
+
*/
|
|
25
|
+
export function loaderAnswerResponse(payload: {
|
|
26
|
+
loaderResult: unknown;
|
|
27
|
+
loaderError?: { message: string; name: string };
|
|
28
|
+
}): Response {
|
|
29
|
+
const response = new Response(null, {
|
|
30
|
+
headers: { "content-type": "text/x-component" },
|
|
31
|
+
});
|
|
32
|
+
answers.set(response, payload);
|
|
33
|
+
return response;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function createFromFetch<T>(
|
|
37
|
+
response: Promise<Response> | Response,
|
|
38
|
+
): Promise<T> {
|
|
39
|
+
const settled = await response;
|
|
40
|
+
if (!answers.has(settled)) {
|
|
41
|
+
throw new Error(
|
|
42
|
+
"@vitejs/plugin-rsc/browser test stub: createFromFetch decodes only " +
|
|
43
|
+
"the loader answers renderRoute gives (useLoader().load(), a " +
|
|
44
|
+
"refreshGroup refresh). Real Flight responses are e2e territory.",
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
return answers.get(settled) as T;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function unavailable(name: string): never {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`@vitejs/plugin-rsc/browser test stub: ${name} is not available in a ` +
|
|
53
|
+
"bare Vitest process. Real Flight responses are e2e territory.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const createFromReadableStream = (): Promise<never> =>
|
|
58
|
+
unavailable("createFromReadableStream");
|
|
59
|
+
export const encodeReply = (): Promise<never> => unavailable("encodeReply");
|
|
60
|
+
export const setServerCallback = (): void => {};
|
|
61
|
+
export const createTemporaryReferenceSet = (): unknown => new Map();
|
package/src/testing/vitest.ts
CHANGED
|
@@ -229,6 +229,11 @@ export function rangoTestAliases(
|
|
|
229
229
|
find: /^@vitejs\/plugin-rsc\/rsc(\/(server|client))?$/,
|
|
230
230
|
replacement: here("src/testing/vitest-stubs/plugin-rsc.ts"),
|
|
231
231
|
},
|
|
232
|
+
// useLoader().load() decodes renderRoute's loader answers through it.
|
|
233
|
+
{
|
|
234
|
+
find: /^@vitejs\/plugin-rsc\/browser$/,
|
|
235
|
+
replacement: here("src/testing/vitest-stubs/plugin-rsc-browser.ts"),
|
|
236
|
+
},
|
|
232
237
|
];
|
|
233
238
|
if (rsdServerEdgePath) {
|
|
234
239
|
aliases.push({
|
package/src/use-loader.tsx
CHANGED
|
@@ -270,10 +270,15 @@ function useLoaderInternal<T>(
|
|
|
270
270
|
const loaders: LoaderStore | undefined = useContext(NavigationStoreContext)
|
|
271
271
|
?.store?.loaders;
|
|
272
272
|
|
|
273
|
+
// Committed route data (updated by the reset effect below), read by the
|
|
274
|
+
// store when a refresh begins: the snapshot it produces belongs to it.
|
|
275
|
+
const prevContextDataRef = useRef(contextData);
|
|
276
|
+
const routeDataOf = useCallback(() => prevContextDataRef.current, []);
|
|
277
|
+
|
|
273
278
|
const groupRefetch = useCallback(async (): Promise<void> => {
|
|
274
279
|
if (!loaderId || !loaders) return;
|
|
275
280
|
const requestId = loaders.reserveRequestId(bucketKey);
|
|
276
|
-
loaders.beginRequest(bucketKey, requestId);
|
|
281
|
+
loaders.beginRequest(bucketKey, requestId, routeDataOf);
|
|
277
282
|
try {
|
|
278
283
|
const url = new URL(window.location.href);
|
|
279
284
|
url.searchParams.set("_rsc_loader", loaderId);
|
|
@@ -316,6 +321,15 @@ function useLoaderInternal<T>(
|
|
|
316
321
|
sharedState.bucketKey === bucketKey
|
|
317
322
|
? sharedState.snapshot
|
|
318
323
|
: EMPTY_LOADER_SNAPSHOT;
|
|
324
|
+
// A route reader shows a refresh only when it was requested for this
|
|
325
|
+
// reader's route data: never another page's, nor one a navigation replaced
|
|
326
|
+
// (the rule the reset effect below applies when a mounted reader's data
|
|
327
|
+
// changes, here also for a reader that mounts).
|
|
328
|
+
const sharedValue =
|
|
329
|
+
sharedSnapshot.hasValue &&
|
|
330
|
+
(!hasContextData || sharedSnapshot.valueFor.has(contextData));
|
|
331
|
+
const sharedRequest =
|
|
332
|
+
!hasContextData || sharedSnapshot.requestFor.has(contextData);
|
|
319
333
|
useEffect(() => {
|
|
320
334
|
if (!loaders) return;
|
|
321
335
|
const initial = loaders.getSnapshot(bucketKey);
|
|
@@ -343,6 +357,7 @@ function useLoaderInternal<T>(
|
|
|
343
357
|
onStreamPending: hasContextData
|
|
344
358
|
? () => startTransition(() => setStreamPending(true))
|
|
345
359
|
: undefined,
|
|
360
|
+
routeData: hasContextData ? routeDataOf : undefined,
|
|
346
361
|
},
|
|
347
362
|
);
|
|
348
363
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- intentional:
|
|
@@ -388,7 +403,6 @@ function useLoaderInternal<T>(
|
|
|
388
403
|
// any pre-navigation load() promise that resolves later fails its gate
|
|
389
404
|
// and is dropped — fixes the race where a stale fetch overwrites the
|
|
390
405
|
// new route's context.
|
|
391
|
-
const prevContextDataRef = useRef(contextData);
|
|
392
406
|
useEffect(() => {
|
|
393
407
|
if (prevContextDataRef.current !== contextData) {
|
|
394
408
|
setLocalFetchedData({ has: false, value: undefined });
|
|
@@ -407,11 +421,14 @@ function useLoaderInternal<T>(
|
|
|
407
421
|
// `has`/`hasValue` gate each level so a committed falsy value is not skipped.
|
|
408
422
|
const data = localFetchedData.has
|
|
409
423
|
? localFetchedData.value
|
|
410
|
-
:
|
|
424
|
+
: sharedValue
|
|
411
425
|
? (sharedSnapshot.value as T | undefined)
|
|
412
426
|
: contextData;
|
|
413
|
-
const isLoading =
|
|
414
|
-
|
|
427
|
+
const isLoading =
|
|
428
|
+
localIsLoading ||
|
|
429
|
+
(sharedRequest && sharedSnapshot.isLoading) ||
|
|
430
|
+
streamPending;
|
|
431
|
+
const error = localError ?? (sharedRequest ? sharedSnapshot.error : null);
|
|
415
432
|
|
|
416
433
|
const throwOnError = options?.throwOnError ?? true;
|
|
417
434
|
|
|
@@ -446,7 +463,11 @@ function useLoaderInternal<T>(
|
|
|
446
463
|
if (shared) {
|
|
447
464
|
sharedRequestId = loaders.reserveRequestId(bucket);
|
|
448
465
|
lastSharedRequestIdRef.current = sharedRequestId;
|
|
449
|
-
loaders.beginRequest(
|
|
466
|
+
loaders.beginRequest(
|
|
467
|
+
bucket,
|
|
468
|
+
sharedRequestId,
|
|
469
|
+
hasContextDataRef.current ? routeDataOf : undefined,
|
|
470
|
+
);
|
|
450
471
|
} else {
|
|
451
472
|
localRequestId = ++localRequestIdRef.current;
|
|
452
473
|
setLocalIsLoading(true);
|
|
@@ -559,12 +580,13 @@ function useLoaderInternal<T>(
|
|
|
559
580
|
}
|
|
560
581
|
}
|
|
561
582
|
},
|
|
562
|
-
[throwOnError, loaders],
|
|
583
|
+
[throwOnError, loaders, routeDataOf],
|
|
563
584
|
);
|
|
564
585
|
|
|
565
586
|
if (throwOnError) {
|
|
566
587
|
if (localError) throw localError;
|
|
567
588
|
if (
|
|
589
|
+
sharedRequest &&
|
|
568
590
|
sharedSnapshot.error &&
|
|
569
591
|
lastSharedRequestIdRef.current !== null &&
|
|
570
592
|
sharedSnapshot.requestId === lastSharedRequestIdRef.current
|