@rangojs/router 0.33.1 → 0.34.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/{chunk-TTXC2U3G.js → chunk-4HHFQSDC.js} +10 -2
- package/dist/bin/{chunk-3B7CKBRO.js → chunk-MWOHBDPM.js} +9 -9
- package/dist/bin/{client-urls-projection-DQLWPDAT.js → client-urls-projection-4Q5EQDAL.js} +2 -2
- package/dist/bin/rango.js +1 -1
- package/dist/bin/{runtime-EPUDEHO4.js → runtime-6WLCJCN7.js} +1 -1
- package/dist/bin/{runtime-discovery-3HH6BP3K.js → runtime-discovery-2ZTYRZ3V.js} +2 -2
- package/dist/bin/{temp-rsc-server-JALTIWLT.js → temp-rsc-server-EUNKRFJD.js} +3 -3
- 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/client-urls/server-projection.d.ts +7 -0
- package/dist/types/client-urls/types.d.ts +13 -14
- package/dist/types/errors.d.ts +5 -0
- package/dist/types/loader-store.d.ts +11 -1
- package/dist/types/prefetch-markers.d.ts +13 -4
- package/dist/types/route-definition/helpers-types.d.ts +14 -10
- package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +39 -21
- package/dist/types/server/context.d.ts +6 -5
- package/dist/types/testing/render-route.d.ts +21 -1
- package/dist/types/testing/serve-shell-request.d.ts +7 -7
- package/dist/types/testing/vitest-stubs/plugin-rsc-browser.d.ts +33 -0
- package/dist/types/types/loader-types.d.ts +10 -8
- package/dist/types/urls/path-helper-types.d.ts +9 -8
- package/dist/vite/index.js +30 -13
- package/package.json +1 -1
- package/skills/app-structure/large-app.md +3 -0
- package/skills/client-urls/SKILL.md +13 -10
- package/skills/hooks/data.md +5 -0
- package/skills/hooks/state.md +13 -11
- package/skills/intercept/SKILL.md +8 -4
- package/skills/links/SKILL.md +15 -12
- package/skills/loader/SKILL.md +52 -39
- package/skills/ppr/SKILL.md +1 -1
- package/skills/route/SKILL.md +15 -9
- package/skills/testing/SKILL.md +1 -0
- package/skills/testing/client-components.md +67 -19
- package/skills/testing/serve-shell-request.md +19 -7
- package/skills/ux-architecture/SKILL.md +16 -11
- package/src/browser/history-state.ts +48 -0
- package/src/browser/navigation-bridge.ts +12 -1
- package/src/browser/navigation-transaction.ts +14 -6
- package/src/browser/partial-update.ts +12 -4
- package/src/browser/prefetch/fetch.ts +3 -3
- package/src/client-urls/client-urls.ts +11 -7
- package/src/client-urls/server-projection.ts +21 -3
- package/src/client-urls/types.ts +13 -14
- package/src/errors.ts +17 -0
- package/src/loader-store.ts +41 -4
- package/src/prefetch-markers.ts +18 -4
- package/src/route-definition/dsl-helpers.ts +13 -6
- package/src/route-definition/helpers-types.ts +14 -10
- package/src/router/loader-resolution.ts +4 -4
- package/src/router/match-api.ts +1 -3
- package/src/router/segment-resolution/prefetch-deferral.ts +108 -50
- package/src/rsc/rsc-rendering.ts +1 -1
- package/src/server/context.ts +6 -5
- package/src/testing/render-route.tsx +122 -7
- package/src/testing/serve-shell-request.ts +7 -7
- package/src/testing/vitest-stubs/plugin-rsc-browser.ts +61 -0
- package/src/testing/vitest.ts +5 -0
- package/src/types/loader-types.ts +10 -8
- package/src/urls/path-helper-types.ts +9 -8
- package/src/use-loader.tsx +29 -7
|
@@ -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
|
|
|
@@ -616,7 +616,10 @@ export function createPartialUpdater(
|
|
|
616
616
|
|
|
617
617
|
// The page the client is on, which the server diffs the target against:
|
|
618
618
|
// the committed entry (the store's URL until tx.commit()), whose segment
|
|
619
|
-
// ids are sent above
|
|
619
|
+
// ids are sent above. A navigation names an intercept's source in its own
|
|
620
|
+
// header, so the client path stays the committed URL (the slot's params
|
|
621
|
+
// compare against it); an action's or a stale revalidation's refetch
|
|
622
|
+
// still names the source as the client path. Never window.location:
|
|
620
623
|
// a popstate has already moved it to the popped entry before that
|
|
621
624
|
// entry's fetch commits. Sent from there, a back/forward was told the
|
|
622
625
|
// target is also the source and kept the page being left (#1030), and a
|
|
@@ -624,7 +627,8 @@ export function createPartialUpdater(
|
|
|
624
627
|
// against the popped entry while holding the page on screen's copies, so
|
|
625
628
|
// a layout kept for one page showed the other's.
|
|
626
629
|
const previousUrl =
|
|
627
|
-
(mode.type
|
|
630
|
+
((mode.type === "action" || mode.type === "stale-revalidation") &&
|
|
631
|
+
interceptSourceUrl) ||
|
|
628
632
|
segmentState.currentUrl;
|
|
629
633
|
|
|
630
634
|
if (IS_BROWSER_DEBUG) {
|
|
@@ -711,11 +715,15 @@ export function createPartialUpdater(
|
|
|
711
715
|
targetUrl: url,
|
|
712
716
|
segmentIds: segments,
|
|
713
717
|
previousUrl,
|
|
714
|
-
// The entry being left (a popstate has not moved it yet)
|
|
715
|
-
//
|
|
718
|
+
// The entry being left (a popstate has not moved it yet). Dropped
|
|
719
|
+
// whenever an intercept source is present: the server diffs from the
|
|
720
|
+
// source, which `_rsc_rt` does not name, and ignores it beside the
|
|
721
|
+
// source header (router/navigation-snapshot.ts resolveNavigation).
|
|
716
722
|
rewriteTarget: interceptSourceUrl
|
|
717
723
|
? undefined
|
|
718
724
|
: store.getHistoryEntryMemory()?.rewriteTarget,
|
|
725
|
+
interceptSourceUrl:
|
|
726
|
+
mode.type === "navigate" ? interceptSourceUrl : undefined,
|
|
719
727
|
staleRevalidation:
|
|
720
728
|
mode.type === "stale-revalidation" || segments.length === 0,
|
|
721
729
|
boundary,
|
|
@@ -279,9 +279,9 @@ function executePrefetchFetch(
|
|
|
279
279
|
rangoState: string,
|
|
280
280
|
/**
|
|
281
281
|
* An intent prefetch (prefetchDirect: hover, `prefetch()`): sent as
|
|
282
|
-
* PREFETCH_INTENT so the server
|
|
283
|
-
*
|
|
284
|
-
* server defer
|
|
282
|
+
* PREFETCH_INTENT so the server runs `prefetch: "intent"` work and defers
|
|
283
|
+
* only `prefetch: false` work. A speculative one (prefetchQueued) sends `1`
|
|
284
|
+
* and lets the server defer both (docs/design/prefetch-false.md, R16).
|
|
285
285
|
*/
|
|
286
286
|
intent: boolean,
|
|
287
287
|
expectedRouterId?: string,
|
|
@@ -5,6 +5,7 @@ import { joinMount } from "../router/substitute-pattern-params.js";
|
|
|
5
5
|
import { tryTrieMatch } from "../router/trie-matching.js";
|
|
6
6
|
import type { LoaderDefinition } from "../types.js";
|
|
7
7
|
import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
|
|
8
|
+
import { isPrefetchFlag, type PrefetchFlag } from "../prefetch-markers.js";
|
|
8
9
|
import type {
|
|
9
10
|
ClientPathOptions,
|
|
10
11
|
ClientTransitionConfig,
|
|
@@ -141,7 +142,7 @@ interface LoaderItem extends ItemBase {
|
|
|
141
142
|
readonly definition: LoaderDefinition<any, any>;
|
|
142
143
|
readonly revalidate: ClientUrlLoaderRecord["revalidate"];
|
|
143
144
|
readonly ssr?: false;
|
|
144
|
-
readonly prefetch?:
|
|
145
|
+
readonly prefetch?: PrefetchFlag;
|
|
145
146
|
}
|
|
146
147
|
|
|
147
148
|
interface RevalidateItem extends ItemBase {
|
|
@@ -360,10 +361,11 @@ export const loader: ClientUrlHelpers["loader"] = (
|
|
|
360
361
|
if (
|
|
361
362
|
process.env.NODE_ENV !== "production" &&
|
|
362
363
|
options?.prefetch !== undefined &&
|
|
363
|
-
typeof options.prefetch !== "boolean"
|
|
364
|
+
typeof options.prefetch !== "boolean" &&
|
|
365
|
+
options.prefetch !== "intent"
|
|
364
366
|
) {
|
|
365
367
|
throw new Error(
|
|
366
|
-
`clientUrls() loader() prefetch must be a boolean (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
|
|
368
|
+
`clientUrls() loader() prefetch must be a boolean or "intent" (got ${JSON.stringify(options.prefetch)}). Omit it (or pass true) to run the loader in prefetches.`,
|
|
367
369
|
);
|
|
368
370
|
}
|
|
369
371
|
const items = use ? runUse(use, "loader use") : [];
|
|
@@ -379,7 +381,9 @@ export const loader: ClientUrlHelpers["loader"] = (
|
|
|
379
381
|
)
|
|
380
382
|
.map((item) => item.fn),
|
|
381
383
|
...(options?.ssr === false ? { ssr: false as const } : {}),
|
|
382
|
-
...(options?.prefetch
|
|
384
|
+
...(isPrefetchFlag(options?.prefetch)
|
|
385
|
+
? { prefetch: options.prefetch }
|
|
386
|
+
: {}),
|
|
383
387
|
});
|
|
384
388
|
};
|
|
385
389
|
|
|
@@ -630,7 +634,7 @@ function applyConfig(
|
|
|
630
634
|
loader: item.definition,
|
|
631
635
|
revalidate: Object.freeze([...item.revalidate]),
|
|
632
636
|
...(item.ssr === false ? { ssr: false as const } : {}),
|
|
633
|
-
...(item.prefetch
|
|
637
|
+
...(item.prefetch !== undefined ? { prefetch: item.prefetch } : {}),
|
|
634
638
|
}),
|
|
635
639
|
);
|
|
636
640
|
} else if (item.type === "loading") {
|
|
@@ -754,8 +758,8 @@ function collectIntercepts(
|
|
|
754
758
|
Object.freeze({
|
|
755
759
|
loader: useItem.definition,
|
|
756
760
|
revalidate: Object.freeze([...useItem.revalidate]),
|
|
757
|
-
...(useItem.prefetch
|
|
758
|
-
? { prefetch:
|
|
761
|
+
...(useItem.prefetch !== undefined
|
|
762
|
+
? { prefetch: useItem.prefetch }
|
|
759
763
|
: {}),
|
|
760
764
|
}),
|
|
761
765
|
);
|
|
@@ -113,6 +113,11 @@ export interface ClientUrlProjectionRoute {
|
|
|
113
113
|
* loader(), so a prefetch of the route defers them. Absent (= none) in
|
|
114
114
|
* projections serialized before the option existed. */
|
|
115
115
|
readonly deferrableLoaderIndices?: readonly number[];
|
|
116
|
+
/** Indices into loaderIds of loaders declared loader(Def, { prefetch:
|
|
117
|
+
* "intent" }); materialization passes the option through, so a
|
|
118
|
+
* speculative prefetch defers them and an intent prefetch runs them.
|
|
119
|
+
* Absent (= none) in projections serialized before the option existed. */
|
|
120
|
+
readonly intentLoaderIndices?: readonly number[];
|
|
116
121
|
/** Per loader (aligned with loaderIds), its revalidate() arguments in
|
|
117
122
|
* declaration order: a hoisted predicate's key, whose registration
|
|
118
123
|
* materialization attaches (revalidate-registry.ts), or a boolean
|
|
@@ -140,6 +145,8 @@ export interface ClientUrlProjectionIntercept {
|
|
|
140
145
|
readonly hasLoading: boolean;
|
|
141
146
|
/** As on a route (ClientUrlProjectionRoute.deferrableLoaderIndices). */
|
|
142
147
|
readonly deferrableLoaderIndices?: readonly number[];
|
|
148
|
+
/** As on a route (ClientUrlProjectionRoute.intentLoaderIndices). */
|
|
149
|
+
readonly intentLoaderIndices?: readonly number[];
|
|
143
150
|
/** As on a route (ClientUrlProjectionRoute.loaderRevalidate). */
|
|
144
151
|
readonly loaderRevalidate?: readonly (readonly (string | boolean)[])[];
|
|
145
152
|
}
|
|
@@ -381,7 +388,7 @@ function serializeLoaderOptions(
|
|
|
381
388
|
options: SerializeClientUrlPatternsOptions,
|
|
382
389
|
): Pick<
|
|
383
390
|
ClientUrlProjectionRoute,
|
|
384
|
-
"deferrableLoaderIndices" | "loaderRevalidate"
|
|
391
|
+
"deferrableLoaderIndices" | "intentLoaderIndices" | "loaderRevalidate"
|
|
385
392
|
> {
|
|
386
393
|
const loaderRevalidate = loaders.map(({ loader, revalidate }) =>
|
|
387
394
|
revalidate.flatMap((entry): (string | boolean)[] => {
|
|
@@ -403,10 +410,17 @@ function serializeLoaderOptions(
|
|
|
403
410
|
loaders,
|
|
404
411
|
({ prefetch }) => prefetch === false,
|
|
405
412
|
);
|
|
413
|
+
const intentLoaderIndices = indicesWhere(
|
|
414
|
+
loaders,
|
|
415
|
+
({ prefetch }) => prefetch === "intent",
|
|
416
|
+
);
|
|
406
417
|
return {
|
|
407
418
|
...(deferrableLoaderIndices.length > 0
|
|
408
419
|
? { deferrableLoaderIndices: Object.freeze(deferrableLoaderIndices) }
|
|
409
420
|
: {}),
|
|
421
|
+
...(intentLoaderIndices.length > 0
|
|
422
|
+
? { intentLoaderIndices: Object.freeze(intentLoaderIndices) }
|
|
423
|
+
: {}),
|
|
410
424
|
...(loaderRevalidate.some((keys) => keys.length > 0)
|
|
411
425
|
? {
|
|
412
426
|
loaderRevalidate: Object.freeze(
|
|
@@ -582,6 +596,7 @@ interface ProjectedLoaders {
|
|
|
582
596
|
| "loaderRevalidate"
|
|
583
597
|
| "awaitedLoaderIndices"
|
|
584
598
|
| "deferrableLoaderIndices"
|
|
599
|
+
| "intentLoaderIndices"
|
|
585
600
|
>;
|
|
586
601
|
/** Names the owner in an error: `route "/a"`, `intercept ".b"`. */
|
|
587
602
|
readonly label: string;
|
|
@@ -817,8 +832,8 @@ function materializeRouteItems(
|
|
|
817
832
|
};
|
|
818
833
|
|
|
819
834
|
// The loader() items of a route's or an intercept's projected loaders: each
|
|
820
|
-
// stub with its `ssr: false` and `prefetch
|
|
821
|
-
//
|
|
835
|
+
// stub with its `ssr: false` and `prefetch` flags and its revalidate()
|
|
836
|
+
// predicates.
|
|
822
837
|
const materializeLoaders = (owner: ProjectedLoaders) =>
|
|
823
838
|
owner.projected.loaderIds.map((id, loaderIndex) => {
|
|
824
839
|
const revalidators = loaderRevalidators(reference, owner, loaderIndex);
|
|
@@ -831,6 +846,9 @@ function materializeRouteItems(
|
|
|
831
846
|
...(owner.projected.deferrableLoaderIndices?.includes(
|
|
832
847
|
loaderIndex,
|
|
833
848
|
) && { prefetch: false }),
|
|
849
|
+
...(owner.projected.intentLoaderIndices?.includes(loaderIndex) && {
|
|
850
|
+
prefetch: "intent" as const,
|
|
851
|
+
}),
|
|
834
852
|
},
|
|
835
853
|
() => revalidators.map((fn) => revalidate(fn)),
|
|
836
854
|
);
|
package/src/client-urls/types.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type {
|
|
|
7
7
|
} from "../types.js";
|
|
8
8
|
import type { DefaultEnv } from "../types/global-namespace.js";
|
|
9
9
|
import type { HoistedRevalidateRef } from "./hoisted-revalidate.js";
|
|
10
|
+
import type { PrefetchFlag } from "../prefetch-markers.js";
|
|
10
11
|
import type { TrieMatchResult } from "../router/trie-matching.js";
|
|
11
12
|
import type { PathOptions } from "../urls/pattern-types.js";
|
|
12
13
|
import type { SearchSchema } from "../search-params.js";
|
|
@@ -85,13 +86,12 @@ export interface ClientUrlLoaderRecord {
|
|
|
85
86
|
*/
|
|
86
87
|
readonly ssr?: false;
|
|
87
88
|
/**
|
|
88
|
-
* loader(Def, { prefetch: false }): a
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
* Projected onto the materialized stub.
|
|
89
|
+
* loader(Def, { prefetch: false | "intent" }): a prefetch of the route, or
|
|
90
|
+
* of an intercept's target from its source, defers this loader and the
|
|
91
|
+
* click that adopts it runs it in its fill request (see
|
|
92
|
+
* {@link LoaderOptions}). Projected onto the materialized stub.
|
|
93
93
|
*/
|
|
94
|
-
readonly prefetch?:
|
|
94
|
+
readonly prefetch?: PrefetchFlag;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
97
|
/**
|
|
@@ -169,14 +169,13 @@ export interface ClientUrlHelpers {
|
|
|
169
169
|
* settled return freezes into the shell (nested promises stay live
|
|
170
170
|
* holes).
|
|
171
171
|
*
|
|
172
|
-
* Pass `{ prefetch: false }` to keep this loader out of the
|
|
173
|
-
*
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
177
|
-
*
|
|
178
|
-
*
|
|
179
|
-
* render prefetch of the intercept from its source, as in urls().
|
|
172
|
+
* Pass `{ prefetch: false | "intent" }` to keep this loader out of the
|
|
173
|
+
* route's prefetches (see {@link LoaderOptions}). An in-group click still
|
|
174
|
+
* shows the destination at once, commits the adopted prefetch with the
|
|
175
|
+
* other loaders' data, and runs only the flagged loaders in its one fill
|
|
176
|
+
* request. A `layout()` loader with the
|
|
177
|
+
* flag is flagged on every route below it. Inside intercept() it defers
|
|
178
|
+
* the loader in a prefetch of the intercept from its source, as in urls().
|
|
180
179
|
*/
|
|
181
180
|
readonly loader: <TData>(
|
|
182
181
|
definition: LoaderDefinition<TData>,
|
package/src/errors.ts
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
* - Cause property for structured debugging data
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
+
import { isPrefetchFlag } from "./prefetch-markers.js";
|
|
10
|
+
|
|
9
11
|
/**
|
|
10
12
|
* Options for error construction
|
|
11
13
|
*/
|
|
@@ -418,6 +420,21 @@ export function assertBooleanOption(
|
|
|
418
420
|
);
|
|
419
421
|
}
|
|
420
422
|
|
|
423
|
+
/**
|
|
424
|
+
* Rejects a `prefetch` DSL option that is neither a boolean nor `"intent"`
|
|
425
|
+
* (undefined passes), as an invariant.
|
|
426
|
+
*/
|
|
427
|
+
export function assertPrefetchOption(
|
|
428
|
+
value: unknown,
|
|
429
|
+
option: string,
|
|
430
|
+
effect: string,
|
|
431
|
+
): void {
|
|
432
|
+
invariant(
|
|
433
|
+
value === undefined || typeof value === "boolean" || isPrefetchFlag(value),
|
|
434
|
+
`${option} must be a boolean or "intent" (got ${JSON.stringify(value)}). Omit it (or pass true) to ${effect}.`,
|
|
435
|
+
);
|
|
436
|
+
}
|
|
437
|
+
|
|
421
438
|
/**
|
|
422
439
|
* Sanitize errors for production - prevents leaking sensitive information
|
|
423
440
|
*
|
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);
|
package/src/prefetch-markers.ts
CHANGED
|
@@ -12,13 +12,27 @@ export const PREFETCH_HEADER = "X-Rango-Prefetch";
|
|
|
12
12
|
/**
|
|
13
13
|
* PREFETCH_HEADER's value on an intent prefetch: a hover or an explicit
|
|
14
14
|
* `prefetch()` call (browser/prefetch/fetch.ts prefetchDirect). The server
|
|
15
|
-
*
|
|
16
|
-
* flagged `
|
|
17
|
-
*
|
|
18
|
-
* this one reads as speculative.
|
|
15
|
+
* runs the work flagged `prefetch: "intent"` in it and defers the work
|
|
16
|
+
* flagged `prefetch: false`. A speculative prefetch (viewport, render) sends
|
|
17
|
+
* `1` and defers both; any value other than this one reads as speculative.
|
|
19
18
|
*/
|
|
20
19
|
export const PREFETCH_INTENT = "intent";
|
|
21
20
|
|
|
21
|
+
/**
|
|
22
|
+
* A recorded `prefetch` flag of loader() or loading(): `false` defers the
|
|
23
|
+
* work in every prefetch, `"intent"` in a speculative one only
|
|
24
|
+
* (router/segment-resolution/prefetch-deferral.ts).
|
|
25
|
+
*/
|
|
26
|
+
export type PrefetchFlag = false | typeof PREFETCH_INTENT;
|
|
27
|
+
|
|
28
|
+
/** A `prefetch` DSL option as written: a boolean, or `"intent"`. */
|
|
29
|
+
export type PrefetchOption = boolean | PrefetchFlag;
|
|
30
|
+
|
|
31
|
+
/** The option asks for deferral: `prefetch: false` or `prefetch: "intent"`. */
|
|
32
|
+
export function isPrefetchFlag(value: unknown): value is PrefetchFlag {
|
|
33
|
+
return value === false || value === PREFETCH_INTENT;
|
|
34
|
+
}
|
|
35
|
+
|
|
22
36
|
/**
|
|
23
37
|
* The last row of a prefetch whose render reported an error
|
|
24
38
|
* (rsc/prefetch-render-error.ts): a Flight hint with a code React does not
|
|
@@ -25,7 +25,11 @@ import {
|
|
|
25
25
|
type InterceptConfig,
|
|
26
26
|
type LoaderEntry,
|
|
27
27
|
} from "../server/context";
|
|
28
|
-
import {
|
|
28
|
+
import {
|
|
29
|
+
assertBooleanOption,
|
|
30
|
+
assertPrefetchOption,
|
|
31
|
+
invariant,
|
|
32
|
+
} from "../errors";
|
|
29
33
|
import { validateUserRouteName } from "../route-name.js";
|
|
30
34
|
import { isCachedFunction } from "../cache/taint.js";
|
|
31
35
|
import { RangoContext } from "../server/context";
|
|
@@ -48,6 +52,7 @@ import type {
|
|
|
48
52
|
UseItems,
|
|
49
53
|
} from "../route-types.js";
|
|
50
54
|
import { isRenderableLoading } from "../types/boundaries.js";
|
|
55
|
+
import { isPrefetchFlag } from "../prefetch-markers.js";
|
|
51
56
|
import type { RouteHelpers } from "./helpers-types.js";
|
|
52
57
|
import {
|
|
53
58
|
resolveHandlerUse,
|
|
@@ -922,7 +927,7 @@ const loader: RouteHelpers<any, any>["loader"] = (
|
|
|
922
927
|
"loader() ssr",
|
|
923
928
|
"stream on every render",
|
|
924
929
|
);
|
|
925
|
-
|
|
930
|
+
assertPrefetchOption(
|
|
926
931
|
optionsGiven?.prefetch,
|
|
927
932
|
"loader() prefetch",
|
|
928
933
|
"run the loader in prefetches",
|
|
@@ -938,7 +943,9 @@ const loader: RouteHelpers<any, any>["loader"] = (
|
|
|
938
943
|
loader: loaderDef,
|
|
939
944
|
revalidate: [] as ShouldRevalidateFn<any, any>[],
|
|
940
945
|
...(optionsGiven?.ssr === false && { bake: true as const }),
|
|
941
|
-
...(optionsGiven?.prefetch
|
|
946
|
+
...(isPrefetchFlag(optionsGiven?.prefetch) && {
|
|
947
|
+
prefetch: optionsGiven.prefetch,
|
|
948
|
+
}),
|
|
942
949
|
// The declaring layout's / parallel's / cache()'s scope. A route's (and
|
|
943
950
|
// an intercept's) loaders have none: they resolve from the route's name,
|
|
944
951
|
// like the handler.
|
|
@@ -997,7 +1004,7 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
|
|
|
997
1004
|
invariant(false, "No parent entry available for loading()");
|
|
998
1005
|
}
|
|
999
1006
|
|
|
1000
|
-
|
|
1007
|
+
assertPrefetchOption(
|
|
1001
1008
|
options?.prefetch,
|
|
1002
1009
|
"loading() prefetch",
|
|
1003
1010
|
"render what the fallback covers in prefetches",
|
|
@@ -1018,8 +1025,8 @@ const loading: RouteHelpers<any, any>["loading"] = (component, options) => {
|
|
|
1018
1025
|
// `parent.loading`, so the document evaluation (ssr: false) agrees with
|
|
1019
1026
|
// the navigation one. Last loading() wins, like `loading` itself.
|
|
1020
1027
|
parent.loadingPrefetch =
|
|
1021
|
-
options?.prefetch
|
|
1022
|
-
?
|
|
1028
|
+
isPrefetchFlag(options?.prefetch) && isRenderableLoading(resolved)
|
|
1029
|
+
? options.prefetch
|
|
1023
1030
|
: undefined;
|
|
1024
1031
|
|
|
1025
1032
|
const name = `$${store.getNextIndex("loading")}`;
|
|
@@ -35,6 +35,7 @@ import type {
|
|
|
35
35
|
UseItems,
|
|
36
36
|
} from "../route-types.js";
|
|
37
37
|
import type { StaticHandlerRef } from "../static-handler.js";
|
|
38
|
+
import type { PrefetchOption } from "../prefetch-markers.js";
|
|
38
39
|
|
|
39
40
|
// Re-export route item types for backward compatibility
|
|
40
41
|
export type {
|
|
@@ -333,15 +334,16 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
333
334
|
* loader(RecommendationsLoader), // still streams behind loading()
|
|
334
335
|
* ```
|
|
335
336
|
*
|
|
336
|
-
* Pass `{ prefetch: false }` to keep the loader out of
|
|
337
|
-
*
|
|
338
|
-
*
|
|
339
|
-
*
|
|
337
|
+
* Pass `{ prefetch: false }` to keep the loader out of prefetches: a
|
|
338
|
+
* prefetch skips it and the click that adopts it fetches it in one
|
|
339
|
+
* follow-up request. `{ prefetch: "intent" }` defers it in viewport and
|
|
340
|
+
* render prefetches only (see {@link LoaderOptions}). Only on a segment the
|
|
340
341
|
* client does not have yet: a loader the client already holds runs in the
|
|
341
342
|
* prefetch when it revalidates.
|
|
342
343
|
*
|
|
343
344
|
* ```typescript
|
|
344
345
|
* loader(ReviewsLoader, { prefetch: false }),
|
|
346
|
+
* loader(RelatedLoader, { prefetch: "intent" }),
|
|
345
347
|
* ```
|
|
346
348
|
*
|
|
347
349
|
* @param loaderDef - Loader created with createLoader()
|
|
@@ -362,16 +364,18 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
362
364
|
* // Skip loading on SSR, only show on client navigation
|
|
363
365
|
* loading(<Skeleton />, { ssr: false })
|
|
364
366
|
*
|
|
365
|
-
* // Keep everything behind this fallback out of
|
|
366
|
-
* // prefetches (a hover prefetch runs it)
|
|
367
|
+
* // Keep everything behind this fallback out of prefetches
|
|
367
368
|
* loading(<Skeleton />, { prefetch: false })
|
|
369
|
+
*
|
|
370
|
+
* // Deferred in viewport and render prefetches only
|
|
371
|
+
* loading(<Skeleton />, { prefetch: "intent" })
|
|
368
372
|
* ```
|
|
369
373
|
* @param component - The loading UI to show during navigation
|
|
370
374
|
* @param options - Configuration options
|
|
371
375
|
* @param options.ssr - If false, skip showing loading on document requests (SSR)
|
|
372
|
-
* @param options.prefetch -
|
|
373
|
-
*
|
|
374
|
-
*
|
|
376
|
+
* @param options.prefetch - `false` or `"intent"` (see
|
|
377
|
+
* {@link LoaderOptions}); the flag applies when this entry's segment is
|
|
378
|
+
* new to the client. A prefetch then skips the whole unit: the
|
|
375
379
|
* handler, its loaders, its orphan layouts and parallel slots and, for a
|
|
376
380
|
* layout, every deeper entry; the click that adopts the prefetch shows
|
|
377
381
|
* this fallback and fetches them in one follow-up request, which reads
|
|
@@ -386,7 +390,7 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
386
390
|
*/
|
|
387
391
|
loading: (
|
|
388
392
|
component: ReactNode | (() => ReactNode),
|
|
389
|
-
options?: { ssr?: boolean; prefetch?:
|
|
393
|
+
options?: { ssr?: boolean; prefetch?: PrefetchOption },
|
|
390
394
|
) => LoadingItem;
|
|
391
395
|
/**
|
|
392
396
|
* Attach an error boundary to catch errors in this segment and children
|
|
@@ -731,17 +731,17 @@ function createLoaderExecutor<TEnv>(
|
|
|
731
731
|
);
|
|
732
732
|
}
|
|
733
733
|
|
|
734
|
-
// prefetch: false (segment-resolution/prefetch-deferral.ts).
|
|
735
|
-
// deferrable loader runs in a fill request, where its entry is held
|
|
734
|
+
// prefetch: false or "intent" (segment-resolution/prefetch-deferral.ts).
|
|
735
|
+
// A deferrable loader runs in a fill request, where its entry is held
|
|
736
736
|
// and its handler does not run: there is no render to wait for.
|
|
737
737
|
// Refused on every request kind, so it is seen on a document load.
|
|
738
738
|
const deferral = internal._prefetchDeferral;
|
|
739
739
|
if (deferral?.scope.loaderIds.has(currentLoaderId)) {
|
|
740
740
|
throw new Error(
|
|
741
741
|
`ctx.rendered() is not available to loader "${currentLoaderId}": it is deferred in prefetches ` +
|
|
742
|
-
`(registered with loader(Def, { prefetch: false }), or behind a loading(fallback, { prefetch: false })), ` +
|
|
742
|
+
`(registered with loader(Def, { prefetch: false }) or { prefetch: "intent" }, or behind a loading(fallback, { prefetch: false }) or { prefetch: "intent" }), ` +
|
|
743
743
|
`and the fill request that runs it afterwards does not re-run its entry's handler, so there is no render to wait for and no handle data to read. ` +
|
|
744
|
-
`Remove { prefetch: false } from the loader (or from the loading() above it), or stop calling ctx.rendered() in this loader.`,
|
|
744
|
+
`Remove { prefetch: false } or { prefetch: "intent" } from the loader (or from the loading() above it), or stop calling ctx.rendered() in this loader.`,
|
|
745
745
|
);
|
|
746
746
|
}
|
|
747
747
|
if (deferral?.mode === "fill") {
|
package/src/router/match-api.ts
CHANGED
|
@@ -491,9 +491,7 @@ export async function createMatchContextForPartial<TEnv>(
|
|
|
491
491
|
const deferral = planPrefetchDeferral(
|
|
492
492
|
snapshot.entries,
|
|
493
493
|
partialDeferralMode({
|
|
494
|
-
|
|
495
|
-
// Speculative only: an intent prefetch defers nothing.
|
|
496
|
-
prefetch: kind === "prefetch",
|
|
494
|
+
kind,
|
|
497
495
|
method: request.method,
|
|
498
496
|
isAction,
|
|
499
497
|
isShellCapture: reqCtx?._shellCaptureState?.run === true,
|