@rangojs/router 0.33.2 → 0.34.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/{chunk-YP4HENIU.js → chunk-4HHFQSDC.js} +6 -1
- package/dist/bin/{chunk-3B7CKBRO.js → chunk-MWOHBDPM.js} +9 -9
- package/dist/bin/{client-urls-projection-J2BGDKQ4.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-O6KQ5KO2.js → runtime-discovery-RY6NMQM3.js} +2 -2
- package/dist/bin/{temp-rsc-server-BOHXBOR7.js → temp-rsc-server-F7AHOPV4.js} +3 -3
- 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 +19 -7
- package/dist/types/prefetch-markers.d.ts +13 -4
- package/dist/types/route-definition/helpers-types.d.ts +14 -10
- package/dist/types/router/match-context.d.ts +12 -0
- package/dist/types/router/navigation-snapshot.d.ts +17 -0
- 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/serve-shell-request.d.ts +7 -7
- 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 +26 -12
- 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 +4 -1
- 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/serve-shell-request.md +19 -7
- package/skills/ux-architecture/SKILL.md +16 -11
- package/src/browser/navigation-bridge.ts +6 -0
- 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 +28 -12
- 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 +3 -3
- package/src/router/match-context.ts +22 -1
- package/src/router/match-middleware/cache-lookup.ts +17 -8
- package/src/router/match-middleware/intercept-resolution.ts +3 -3
- package/src/router/navigation-snapshot.ts +39 -8
- 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/serve-shell-request.ts +7 -7
- package/src/types/loader-types.ts +10 -8
- package/src/urls/path-helper-types.ts +9 -8
- package/src/use-loader.tsx +5 -3
|
@@ -20,13 +20,13 @@ A `Prerender` route is served as in production: from the artifact `router.matchF
|
|
|
20
20
|
|
|
21
21
|
### Options — `ServeShellRequestOptions`
|
|
22
22
|
|
|
23
|
-
| Field | Type | Meaning
|
|
24
|
-
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
-
| `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store.
|
|
26
|
-
| `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them.
|
|
27
|
-
| `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`.
|
|
28
|
-
| `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends the speculative kind a viewport or render `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false` work is deferred; add `headers: { "X-Rango-Prefetch": "intent" }` to send a hover's or `prefetch()`'s intent prefetch instead, which defers
|
|
29
|
-
| `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`.
|
|
23
|
+
| Field | Type | Meaning |
|
|
24
|
+
| ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
25
|
+
| `cacheStore` | `SegmentCacheStore` | The store for this request, in place of the store your `createRouter({ cache })` config returns. The rest of that config (`enabled`, `searchParams`) still applies. Omit it to use the router's own store. |
|
|
26
|
+
| `env` | `TEnv` | Bindings, as `router.fetch(request, { env })` passes them. A function `cache` config receives them. |
|
|
27
|
+
| `headers` | `HeadersInit` | Request headers (e.g. `cookie`). A document request defaults `accept` to `text/html`. |
|
|
28
|
+
| `partial` | `true \| { from?: string; rewriteTarget?: string; segments?: string[]; prefetch?: boolean; fill?: boolean; interceptSource?: string; stale?: boolean; boundary?: string }` | Serve the navigation request the browser sends instead of a document GET: from `from` (default the site root) with `segments` mounted (default none). `prefetch: true` sends the speculative kind a viewport or render `<Link>` prefetch sends (`X-Rango-Prefetch: 1`), so `prefetch: false` and `prefetch: "intent"` work is deferred; add `headers: { "X-Rango-Prefetch": "intent" }` to send a hover's or `prefetch()`'s intent prefetch instead, which runs the `"intent"` work and defers the `false` work. `fill: true` sends the follow-up request (`_rsc_fill=1`) the browser makes after adopting a prefetch; pass the prefetch's delivered ids, without the deferred ones, as `segments`, and the same URL as `from`. `interceptSource` sends the intercept source (`X-RSC-Router-Intercept-Source`) the browser sends with the fill of an intercept, whose `from` is that source too. `rewriteTarget` sends what the page at `from` carried as its payload's `metadata.rewriteTarget` when middleware rewrote it (`_rsc_rt`), as the browser does, so the server diffs against the route that rendered that page. `stale: true` sends a stale request (`_rsc_stale`), as the browser sends it: every `revalidate()` sees `stale: true` (see `revalidate()`'s `stale`), segments take an action's revalidation defaults, and it renders live, as an action does: neither the route cache (`cache()`) nor the document cache serves or stores it, and a `ppr` route's `replayStatus` is `{ outcome: "BYPASS", reason: "stale" }`. `boundary` names the error segment of a thrown action's boundary the page at `from` shows (`_rsc_boundary`), as the background revalidation an earlier action owes that page sends it, with `stale: true` and no `segments`: the response renders every segment above the boundary and nothing at or below it, claims the boundary in `matched` and sends no segment for it. The server reads it only beside `stale`. |
|
|
29
|
+
| `action` | `{ ref: Function; id: string; args?: unknown[]; segments?: string[]; rewriteTarget?: string }` | Send the request a JS server action call makes from the page at `url`: a POST of `args` (encoded as the browser encodes them) with `?_rsc_action`, the `rsc-action` header, `X-RSC-Router-Client-Path` and `_rsc_segments` (the page's mounted segment ids, default none), plus the page's `rewriteTarget` as for `partial`. Registers `ref` under `id` (`<module id>#<export name>`). Not with `partial` or `form`. |
|
|
30
30
|
|
|
31
31
|
### Returns — `ServeShellRequestResult`
|
|
32
32
|
|
|
@@ -294,6 +294,18 @@ expect(await fill.readDeferred()).toEqual([]); // a fill defers nothing
|
|
|
294
294
|
|
|
295
295
|
`deliveredIds` is the payload's `metadata.matched`, read from `prefetch.flight`. Count the loader's runs in your own fixture: the prefetch ran it zero times, the fill once.
|
|
296
296
|
|
|
297
|
+
For a loader marked `prefetch: "intent"`, send the hover's prefetch too: it runs that loader and still defers a `prefetch: false` one beside it.
|
|
298
|
+
|
|
299
|
+
```ts
|
|
300
|
+
const hover = await serveShellRequest(router, "/product/1", {
|
|
301
|
+
cacheStore,
|
|
302
|
+
headers: { "X-Rango-Prefetch": "intent" },
|
|
303
|
+
partial: { from: "/", prefetch: true },
|
|
304
|
+
});
|
|
305
|
+
// loader(RelatedLoader, { prefetch: "intent" }) ran; ReviewsLoader is deferred.
|
|
306
|
+
expect(await hover.readDeferred()).toHaveLength(1);
|
|
307
|
+
```
|
|
308
|
+
|
|
297
309
|
### Intercepts
|
|
298
310
|
|
|
299
311
|
A prefetch of an `intercept()`'s target from its source page defers the intercept's flagged loaders, loader segments of its slot (`${parent}.@modalD${i}.${$$id}`). The browser sends the fill of an intercept from the source, with the intercept source header: pass both.
|
|
@@ -314,16 +314,20 @@ The rule: **prefetch broadly, and flag what is expensive.**
|
|
|
314
314
|
already on screen whose `revalidate()` re-runs it for the destination is
|
|
315
315
|
prefetched with it, so the click does not wait on a fill for data the page
|
|
316
316
|
is already showing.
|
|
317
|
-
- `prefetch: false` applies to
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
317
|
+
- `prefetch: false` applies to every prefetch, hovers included.
|
|
318
|
+
`prefetch: "intent"` applies to speculative prefetches only (viewport and
|
|
319
|
+
render): a hover, or `useRouter().prefetch()`, is intent to click and runs
|
|
320
|
+
the loader, so on a route whose flags are all `"intent"` its click needs no
|
|
321
|
+
fill. Pick `"intent"` for a loader worth starting on hover, `false` for one
|
|
322
|
+
too expensive to run on every cursor pass. A hover over a link a viewport
|
|
323
|
+
prefetch already answered sends nothing, and that click still fills.
|
|
321
324
|
- The trade-off of a deferring prefetch, measured on a `clientUrls()` tab in
|
|
322
|
-
a consumer app (dev, a loaded machine)
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
325
|
+
a consumer app (dev, a loaded machine): with the tab's loader skipped by
|
|
326
|
+
the hover's prefetch, a hover then a click 150 ms later showed the content
|
|
327
|
+
in 843 to 918 ms; with the tab prefetched whole, in 77 to 157 ms. The
|
|
328
|
+
deferred click pays the fill request and then React's 300 ms on the tab's
|
|
329
|
+
fallback. `prefetch: "intent"` buys the second number on hover; a
|
|
330
|
+
viewport-prefetched link still pays the first.
|
|
327
331
|
- A click that adopts a prefetch with a flagged loader shows the page at once
|
|
328
332
|
and sends one fill request for the flagged loaders. Read a flagged loader
|
|
329
333
|
under its own `<Suspense>`: with no boundary, React keeps the page being
|
|
@@ -335,7 +339,7 @@ The rule: **prefetch broadly, and flag what is expensive.**
|
|
|
335
339
|
or a grid does not prefetch each link again after every navigation. The server
|
|
336
340
|
makes an answer page-only (`x-rsc-prefetch-scope: source`) when sharing it
|
|
337
341
|
would be wrong: an intercept targets the route or the page sending it, the
|
|
338
|
-
answer defers a
|
|
342
|
+
answer defers a flagged unit the page does not show yet, a
|
|
339
343
|
`revalidate()` on the target read `currentUrl` and kept its segment, a rule
|
|
340
344
|
on the route (or inside its `path()`) read the page being left and kept the
|
|
341
345
|
segment through the route's default, or a kept answer sends no segment at
|
|
@@ -388,7 +392,8 @@ The rule: **prefetch broadly, and flag what is expensive.**
|
|
|
388
392
|
and the list stays mounted under it; a document load of the URL renders the
|
|
389
393
|
full page; Back closes the modal. Recipe 3.5.
|
|
390
394
|
- An intercept's loaders each take their own `revalidate()`, and a flagged
|
|
391
|
-
`prefetch: false` loader opens the modal at once with its
|
|
395
|
+
(`prefetch: false` or `"intent"`) loader opens the modal at once with its
|
|
396
|
+
`loading()`. Its
|
|
392
397
|
errors render the declaring layout's `errorBoundary()`.
|
|
393
398
|
- Inside a group, `intercept()` takes a dot-local named target in the same
|
|
394
399
|
definition and `loader()`/`loading()` only, and only navigations from inside
|
|
@@ -489,6 +489,9 @@ export function createNavigationBridge(
|
|
|
489
489
|
// Clear intercept source URL to ensure server doesn't treat this as intercept
|
|
490
490
|
store.setInterceptSourceUrl(null);
|
|
491
491
|
}
|
|
492
|
+
// The page under an open intercept; null once leaving. Navigations
|
|
493
|
+
// send it so the server resolves the target from there.
|
|
494
|
+
const interceptSourceUrl = store.getInterceptSourceUrl() ?? undefined;
|
|
492
495
|
|
|
493
496
|
// Before navigating away, update the source page's cache with the latest
|
|
494
497
|
// handleData. This ensures the cache has correct handleData even if handles
|
|
@@ -560,6 +563,8 @@ export function createNavigationBridge(
|
|
|
560
563
|
!cachedSegments.some((s) => s.type === "error") &&
|
|
561
564
|
!isInterceptOnlyCache(cachedSegments) &&
|
|
562
565
|
!isLeavingIntercept &&
|
|
566
|
+
// The history entry's segments are not what the client holds here.
|
|
567
|
+
!interceptSourceUrl &&
|
|
563
568
|
!cached?.stale &&
|
|
564
569
|
!options?._skipCache &&
|
|
565
570
|
// A cached intercept entry for this URL (from any source page) means
|
|
@@ -637,6 +642,7 @@ export function createNavigationBridge(
|
|
|
637
642
|
? { type: "leave-intercept" as const }
|
|
638
643
|
: {
|
|
639
644
|
type: "navigate" as const,
|
|
645
|
+
...(interceptSourceUrl && { interceptSourceUrl }),
|
|
640
646
|
...(hasUsableCache
|
|
641
647
|
? {
|
|
642
648
|
targetCacheSegments: cachedSegments,
|
|
@@ -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
|
@@ -28,6 +28,10 @@
|
|
|
28
28
|
* cancelled on resubscribe so a StrictMode / transition remount does not
|
|
29
29
|
* reclaim a bucket that is about to be reused, and is held until any
|
|
30
30
|
* in-flight load settles.
|
|
31
|
+
* - Owned buckets (one reader's private refresh-group bucket,
|
|
32
|
+
* `useLoader(L, { refreshGroup })` without a key) are sticky while that
|
|
33
|
+
* reader is mounted and dropped like an ephemeral bucket once it leaves:
|
|
34
|
+
* no other reader names the bucket, and a remount takes a new one.
|
|
31
35
|
*
|
|
32
36
|
* Each router's NavigationStore owns one (navigation-store.ts), and hooks reach
|
|
33
37
|
* it through NavigationStoreContext: the router store is the single source of
|
|
@@ -97,6 +101,13 @@ export interface SubscribeOptions {
|
|
|
97
101
|
* subscriber attaches, and from then on resets via `clearFamily`.
|
|
98
102
|
*/
|
|
99
103
|
ephemeral?: boolean;
|
|
104
|
+
/**
|
|
105
|
+
* When true, the bucket belongs to this subscriber alone (a private
|
|
106
|
+
* refresh-group bucket, which no other reader names): it is reset by
|
|
107
|
+
* `clearFamily` while subscribed, and dropped after the subscriber leaves
|
|
108
|
+
* even when sticky. Read at unsubscribe, so pass it on every subscribe.
|
|
109
|
+
*/
|
|
110
|
+
owned?: boolean;
|
|
100
111
|
/**
|
|
101
112
|
* Cross-loader refresh group name(s). Tags this bucket so `refreshGroups(name)`
|
|
102
113
|
* can refresh it alongside buckets of other loaders. A bucket may be tagged
|
|
@@ -130,8 +141,8 @@ interface InternalEntry {
|
|
|
130
141
|
/** Family id (loader.$$id) this bucket belongs to. */
|
|
131
142
|
loaderId: string;
|
|
132
143
|
/**
|
|
133
|
-
* True once any non-ephemeral subscriber has attached. Sticky buckets
|
|
134
|
-
*
|
|
144
|
+
* True once any non-ephemeral subscriber has attached. Sticky buckets reset
|
|
145
|
+
* via clearFamily() and are reference-count-dropped only when owned.
|
|
135
146
|
*/
|
|
136
147
|
sticky: boolean;
|
|
137
148
|
/** A deferred refcount-drop microtask is scheduled and not yet cancelled. */
|
|
@@ -223,8 +234,8 @@ export class LoaderStore {
|
|
|
223
234
|
* Subscribe to entry changes for `bucketKey`.
|
|
224
235
|
* Returns an unsubscribe function. A sticky bucket's entry is kept around even
|
|
225
236
|
* after the last subscriber leaves so that an in-flight `load()` can still
|
|
226
|
-
* commit if the consumer remounts. An ephemeral bucket is dropped
|
|
227
|
-
* last subscriber leaves (deferred, see maybeScheduleRefcountClear).
|
|
237
|
+
* commit if the consumer remounts. An ephemeral or owned bucket is dropped
|
|
238
|
+
* once its last subscriber leaves (deferred, see maybeScheduleRefcountClear).
|
|
228
239
|
*/
|
|
229
240
|
subscribe(
|
|
230
241
|
bucketKey: string,
|
|
@@ -261,7 +272,7 @@ export class LoaderStore {
|
|
|
261
272
|
// different groups stays in each group until ALL of that group's
|
|
262
273
|
// subscribers have left (order-independent).
|
|
263
274
|
for (const group of groups) this.releaseGroup(group, bucketKey, e);
|
|
264
|
-
this.maybeScheduleRefcountClear(bucketKey, e);
|
|
275
|
+
this.maybeScheduleRefcountClear(bucketKey, e, options?.owned);
|
|
265
276
|
};
|
|
266
277
|
}
|
|
267
278
|
|
|
@@ -354,17 +365,18 @@ export class LoaderStore {
|
|
|
354
365
|
}
|
|
355
366
|
|
|
356
367
|
/**
|
|
357
|
-
* Schedule a deferred drop for an ad-hoc (never-sticky)
|
|
358
|
-
* subscriber just left. Deferred to a microtask so a StrictMode /
|
|
359
|
-
* remount that resubscribes in the same tick cancels it.
|
|
368
|
+
* Schedule a deferred drop for an ad-hoc (never-sticky) or owned bucket
|
|
369
|
+
* whose last subscriber just left. Deferred to a microtask so a StrictMode /
|
|
370
|
+
* transition remount that resubscribes in the same tick cancels it.
|
|
360
371
|
*/
|
|
361
372
|
private maybeScheduleRefcountClear(
|
|
362
373
|
bucketKey: string,
|
|
363
374
|
e: InternalEntry,
|
|
375
|
+
owned: boolean | undefined,
|
|
364
376
|
): void {
|
|
365
377
|
if (e.listeners.size > 0) return;
|
|
366
|
-
// Sticky buckets persist for remount; they reset via clearFamily().
|
|
367
|
-
if (e.sticky) return;
|
|
378
|
+
// Sticky buckets persist for remount unless owned; they reset via clearFamily().
|
|
379
|
+
if (e.sticky && !owned) return;
|
|
368
380
|
if (e.pendingClear) return;
|
|
369
381
|
e.pendingClear = true;
|
|
370
382
|
queueMicrotask(() => {
|
|
@@ -393,7 +405,7 @@ export class LoaderStore {
|
|
|
393
405
|
}
|
|
394
406
|
}
|
|
395
407
|
|
|
396
|
-
/** Drop an ephemeral bucket that settled with no subscribers left. */
|
|
408
|
+
/** Drop an ephemeral or owned bucket that settled with no subscribers left. */
|
|
397
409
|
private dropIfSettled(bucketKey: string, e: InternalEntry): void {
|
|
398
410
|
if (e.clearWhenSettled && e.listeners.size === 0) {
|
|
399
411
|
e.clearWhenSettled = false;
|
|
@@ -523,7 +535,8 @@ export class LoaderStore {
|
|
|
523
535
|
* Reset a single bucket entry. Bumps `latestRequestId` so any in-flight
|
|
524
536
|
* `load()` whose promise is still pending will fail its gate when it resolves
|
|
525
537
|
* and be dropped — prevents pre-navigation loads from clobbering the new
|
|
526
|
-
* route's context. The entry itself is kept (sticky-bucket semantics)
|
|
538
|
+
* route's context. The entry itself is kept (sticky-bucket semantics),
|
|
539
|
+
* unless it was only waiting for that request to be dropped.
|
|
527
540
|
*/
|
|
528
541
|
clear(bucketKey: string): void {
|
|
529
542
|
const e = this.entries.get(bucketKey);
|
|
@@ -532,6 +545,9 @@ export class LoaderStore {
|
|
|
532
545
|
if (e.snapshot === EMPTY_SNAPSHOT) return;
|
|
533
546
|
e.snapshot = EMPTY_SNAPSHOT;
|
|
534
547
|
this.notify(e);
|
|
548
|
+
// An owned bucket left mid-flight waits for its request to settle, which
|
|
549
|
+
// the bump above stops from committing.
|
|
550
|
+
this.dropIfSettled(bucketKey, e);
|
|
535
551
|
}
|
|
536
552
|
|
|
537
553
|
/**
|
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
|