@rangojs/router 0.24.2 → 0.24.4
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/types/client-urls/client-root.d.ts +4 -1
- package/dist/types/client-urls/client-urls.d.ts +22 -0
- package/dist/types/client-urls/layout-loader-copies.d.ts +11 -0
- package/dist/types/client-urls/navigation.d.ts +6 -8
- package/dist/types/client-urls/server-projection.d.ts +16 -0
- package/dist/types/client-urls/types.d.ts +6 -1
- package/dist/types/handles/pushed-by.d.ts +7 -0
- package/dist/types/router/substitute-pattern-params.d.ts +16 -0
- package/dist/types/server/context.d.ts +8 -0
- package/dist/types/urls/path-helper.d.ts +5 -0
- package/dist/vite/index.js +9 -3
- package/package.json +1 -1
- package/skills/client-urls/SKILL.md +40 -7
- package/skills/loader/SKILL.md +7 -4
- package/src/browser/navigation-bridge.ts +7 -14
- package/src/browser/react/use-reverse.ts +4 -17
- package/src/browser/segment-reconciler.ts +15 -1
- package/src/client-urls/client-root.tsx +10 -5
- package/src/client-urls/client-urls.ts +44 -13
- package/src/client-urls/layout-loader-copies.ts +56 -0
- package/src/client-urls/navigation.ts +45 -38
- package/src/client-urls/server-projection.ts +205 -15
- package/src/client-urls/types.ts +10 -1
- package/src/handles/pushed-by.ts +29 -4
- package/src/router/match-api.ts +11 -3
- package/src/router/segment-resolution/revalidation.ts +4 -1
- package/src/router/substitute-pattern-params.ts +34 -0
- package/src/server/context.ts +48 -23
- package/src/ssr/index.tsx +63 -7
- package/src/urls/path-helper.ts +16 -3
|
@@ -25,12 +25,15 @@ export declare function ClientUrlsInterceptLoading({ definition, interceptIndex,
|
|
|
25
25
|
definition: ClientUrlPatterns;
|
|
26
26
|
interceptIndex: number;
|
|
27
27
|
}): ReactNode;
|
|
28
|
-
export declare function ClientUrlsRoot({ definition, routeId, namePrefix, }: {
|
|
28
|
+
export declare function ClientUrlsRoot({ definition, routeId, namePrefix, foreign, }: {
|
|
29
29
|
definition: ClientUrlPatterns;
|
|
30
30
|
routeId: string;
|
|
31
31
|
/** include() route-name prefix, injected at materialization for canonical
|
|
32
32
|
* name composition (intercept-target coordination). */
|
|
33
33
|
namePrefix?: string;
|
|
34
|
+
/** The server's routes that compete with the group's for a URL
|
|
35
|
+
* (competingPatterns in server-projection.ts). */
|
|
36
|
+
foreign?: readonly string[];
|
|
34
37
|
}): ReactNode;
|
|
35
38
|
export declare function ClientUrlsLoading({ definition, routeId, }: {
|
|
36
39
|
definition: ClientUrlPatterns;
|
|
@@ -1,5 +1,27 @@
|
|
|
1
|
+
import { type TrieNode } from "../router/route-trie-builder.js";
|
|
1
2
|
import type { ClientUrlBuilder, ClientUrlPatterns } from "./types.js";
|
|
2
3
|
import type { ExtractRoutes } from "../urls/type-extraction.js";
|
|
3
4
|
import type { ClientUrlItems } from "./types.js";
|
|
4
5
|
export declare function clientUrls<const TItems extends ClientUrlItems>(builder: ClientUrlBuilder<TItems>): ClientUrlPatterns<ExtractRoutes<TItems>>;
|
|
6
|
+
/**
|
|
7
|
+
* The router's trie over a group's routes, each pattern joined to the
|
|
8
|
+
* include() mount PATTERN (`/:locale?`, `/:accountId/:projectId`): an
|
|
9
|
+
* absolute pathname matches with the mount's params bound next to the
|
|
10
|
+
* route's, by the server's own precedence (an optional mount param expands
|
|
11
|
+
* like any other). Comparing the pathname with the mount as a literal prefix
|
|
12
|
+
* never matched a parameterized mount.
|
|
13
|
+
*
|
|
14
|
+
* `foreign` are the server's routes outside the group that compete with its
|
|
15
|
+
* routes for a URL (competingPatterns in server-projection.ts): a `/:slug`
|
|
16
|
+
* route under `/:locale?` matches a root `/login` too. Each is a leaf keyed by
|
|
17
|
+
* its own pattern, inserted last so an exact tie goes to it; a match on one is
|
|
18
|
+
* not the group's.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildClientUrlsTrie(routes: readonly {
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly pattern: string;
|
|
23
|
+
readonly options?: {
|
|
24
|
+
readonly trailingSlash?: string;
|
|
25
|
+
};
|
|
26
|
+
}[], mount: string, foreign?: readonly string[]): TrieNode;
|
|
5
27
|
export type { ClientTransitionConfig, ClientUrlBuilder, ClientUrlHelpers, ClientUrlInterceptRecord, ClientUrlItem, ClientUrlItemInput, ClientUrlItems, ClientLayoutFn, ClientPathOptions, ClientUrlLoaderRecord, ClientPathFn, ClientUrlPatterns, ClientUrlRouteRecord, ClientUrlUse, } from "./types.js";
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { EntryData } from "../server/context.js";
|
|
2
|
+
import type { LoaderDefinition } from "../types.js";
|
|
3
|
+
export declare function setLayoutLoaderCopies(stub: LoaderDefinition<any>, ids: readonly string[]): void;
|
|
4
|
+
/** True for the materialized stub of a clientUrls() layout() loader copy. */
|
|
5
|
+
export declare function isLayoutLoaderCopy(stub: LoaderDefinition<any>): boolean;
|
|
6
|
+
/**
|
|
7
|
+
* `held` with the id of each of `route`'s layout() loader copies whose
|
|
8
|
+
* loader the page holds under another route of the layout; `held` itself
|
|
9
|
+
* when there is none.
|
|
10
|
+
*/
|
|
11
|
+
export declare function withLayoutLoaderCopies(held: ReadonlySet<string>, route: EntryData): ReadonlySet<string>;
|
|
@@ -9,7 +9,10 @@ export interface ClientUrlNavigationIntent {
|
|
|
9
9
|
* screen.
|
|
10
10
|
*/
|
|
11
11
|
readonly fromFrame: boolean;
|
|
12
|
-
/**
|
|
12
|
+
/**
|
|
13
|
+
* Destination params: the route's and its include() mount's, the params
|
|
14
|
+
* useParams() reads after the commit.
|
|
15
|
+
*/
|
|
13
16
|
readonly params: Readonly<Record<string, string>>;
|
|
14
17
|
/** Absolute destination pathname (mount included) and search ("?..." or ""). */
|
|
15
18
|
readonly pathname: string;
|
|
@@ -24,17 +27,12 @@ export interface ClientUrlNavigationIntent {
|
|
|
24
27
|
}
|
|
25
28
|
/** What the bridge needs to decide transition({ when }) at the swap. */
|
|
26
29
|
export interface ClientUrlDestination {
|
|
27
|
-
/**
|
|
30
|
+
/** Destination params, the include() mount's included. */
|
|
28
31
|
readonly params: Readonly<Record<string, string>>;
|
|
29
32
|
/** Canonical route name (include prefix applied); undefined when unnamed. */
|
|
30
33
|
readonly routeName: string | undefined;
|
|
31
34
|
/** The destination route's own `when`, from its "use client" definition. */
|
|
32
35
|
readonly when: TransitionWhenFn | undefined;
|
|
33
|
-
/**
|
|
34
|
-
* Param names the ORIGIN route matched inside the group: the committed
|
|
35
|
-
* params minus these are the mount's (include prefix) params.
|
|
36
|
-
*/
|
|
37
|
-
readonly originLocalParamNames: readonly string[];
|
|
38
36
|
/** `history.state` the navigation will push (ClientUrlNavigationIntent.state). */
|
|
39
37
|
readonly state: unknown;
|
|
40
38
|
}
|
|
@@ -49,7 +47,7 @@ export interface ClientUrlNavigationPresentation {
|
|
|
49
47
|
readonly transitionGatedOff?: boolean;
|
|
50
48
|
clear(): void;
|
|
51
49
|
}
|
|
52
|
-
export declare function registerClientUrlGroup(definition: ClientUrlPatterns, mount: string, namePrefix: string, setIntent: (intent: ClientUrlNavigationIntent | null) => void, routeId?: string): () => void;
|
|
50
|
+
export declare function registerClientUrlGroup(definition: ClientUrlPatterns, mount: string, namePrefix: string, setIntent: (intent: ClientUrlNavigationIntent | null) => void, routeId?: string, foreign?: readonly string[]): () => void;
|
|
53
51
|
export declare function beginClientUrlNavigation(targetUrl: URL, signal: AbortSignal,
|
|
54
52
|
/** Builds the entry state; called only when the group presents the target. */
|
|
55
53
|
buildState?: () => unknown, decideTransition?: (destination: ClientUrlDestination) => boolean): ClientUrlNavigationPresentation | null;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { SearchSchemaValue } from "../search-params.js";
|
|
2
2
|
import type { TrailingSlashMode } from "../types.js";
|
|
3
3
|
import type { PartialPrerenderProps, UrlPatterns } from "../urls/pattern-types.js";
|
|
4
|
+
import type { TrieNode } from "../router/route-trie-builder.js";
|
|
4
5
|
import type { ClientTransitionConfig, ClientUrlPatterns } from "./types.js";
|
|
5
6
|
export interface ClientUrlReference {
|
|
6
7
|
readonly $$typeof: symbol;
|
|
@@ -42,6 +43,11 @@ export interface ClientUrlProjectionRoute {
|
|
|
42
43
|
* the registered predicates (revalidate-registry.ts). Absent when no
|
|
43
44
|
* loader declares one. */
|
|
44
45
|
readonly loaderRevalidate?: readonly (readonly string[])[];
|
|
46
|
+
/** One per leading loader its layout()s declare
|
|
47
|
+
* (ClientUrlRouteRecord.layoutLoaders): the record's ordinal in the
|
|
48
|
+
* definition. Two routes whose loader at an index carries the same ordinal
|
|
49
|
+
* hold one layout() loader (layout-loader-copies.ts). Absent when none. */
|
|
50
|
+
readonly layoutLoaders?: readonly number[];
|
|
45
51
|
/** Data-only transition config (never `when`); absent in projections
|
|
46
52
|
* serialized before transition support. */
|
|
47
53
|
readonly transition?: Readonly<ClientTransitionConfig>;
|
|
@@ -96,6 +102,16 @@ export declare function isClientUrlSource(value: unknown): value is ClientUrlDef
|
|
|
96
102
|
export declare function setClientUrlProjection(reference: string | ClientUrlReference, projection: ClientUrlProjection): void;
|
|
97
103
|
export declare function getClientUrlProjection(reference: string | ClientUrlReference): ClientUrlProjection | undefined;
|
|
98
104
|
export declare function clearClientUrlProjections(): void;
|
|
105
|
+
/**
|
|
106
|
+
* The patterns of the router's routes, outside the group, that a URL of the
|
|
107
|
+
* group matches too: the trie walked in step with the group's (its patterns
|
|
108
|
+
* joined to the mount), static against static, a param against either, a
|
|
109
|
+
* catch-all against everything below. Constraints are not compared, so the
|
|
110
|
+
* list may hold a route no URL of the group reaches: the client trie, which
|
|
111
|
+
* gets the patterns as they are, never lets one win a URL it does not match.
|
|
112
|
+
* `own` are the group's route keys in the router's trie.
|
|
113
|
+
*/
|
|
114
|
+
export declare function competingPatterns(router: TrieNode, group: TrieNode, own: ReadonlySet<string>): string[];
|
|
99
115
|
export declare function materializeClientUrlPatterns(reference: ClientUrlDefinitionSource, projection: ClientUrlProjection): UrlPatterns;
|
|
100
116
|
/**
|
|
101
117
|
* Adapt a clientUrls() source for `include()` mounting inside the canonical
|
|
@@ -149,6 +149,11 @@ export interface ClientUrlPatterns<TRoutes extends Record<string, any> = Record<
|
|
|
149
149
|
readonly intercepts: readonly ClientUrlInterceptRecord[];
|
|
150
150
|
readonly [CLIENT_URL_PATTERNS_BRAND]: void;
|
|
151
151
|
readonly _routes?: TRoutes;
|
|
152
|
-
|
|
152
|
+
/**
|
|
153
|
+
* Match an absolute pathname against the routes joined to `mount`, the
|
|
154
|
+
* include() mount pattern (default "/"); params include the mount's. A
|
|
155
|
+
* `foreign` pattern that wins is a match whose routeKey is that pattern.
|
|
156
|
+
*/
|
|
157
|
+
match(pathname: string, mount?: string, foreign?: readonly string[]): TrieMatchResult | null;
|
|
153
158
|
}
|
|
154
159
|
export {};
|
|
@@ -27,5 +27,12 @@ export declare function withSegmentHandles(target: HandleData, source: HandleDat
|
|
|
27
27
|
* the position of the first value they replace (the end when they replace
|
|
28
28
|
* none). New containers throughout: an object a reader was handed (a history
|
|
29
29
|
* entry's data) is never written to.
|
|
30
|
+
*
|
|
31
|
+
* A segment with no values of a handle takes, for each loader `matched`
|
|
32
|
+
* registers on it that did not run, the values that loader pushed under a
|
|
33
|
+
* segment the page leaves, a slot excepted: a clientUrls() layout() loader the
|
|
34
|
+
* server held on a navigation between two routes of the layout
|
|
35
|
+
* (client-urls/layout-loader-copies.ts), whose pushes the page filed under
|
|
36
|
+
* the route it leaves.
|
|
30
37
|
*/
|
|
31
38
|
export declare function mergePartialHandleData(current: HandleData, incoming: HandleData, segmentOrder: readonly string[], matched: readonly string[], resolvedIds: readonly string[] | undefined): HandleData;
|
|
@@ -21,3 +21,19 @@
|
|
|
21
21
|
* identical across all three call sites.
|
|
22
22
|
*/
|
|
23
23
|
export declare function substitutePatternParams(pattern: string, params: Record<string, string | undefined>, routeName: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Join an include() mount with a mount-relative pattern. The mount is the
|
|
26
|
+
* include PATTERN (`/:locale?`), so the result is a pattern too.
|
|
27
|
+
*
|
|
28
|
+
* `pattern === "/"` is the index of the local module: under a non-root mount
|
|
29
|
+
* it collapses, so `/` under `/blog` becomes `/blog`, not `/blog/`. This
|
|
30
|
+
* matches `ctx.reverse(".index")` and, for a mount without a trailing slash,
|
|
31
|
+
* the route pattern the server registers (applyUrlPrefix in
|
|
32
|
+
* urls/path-helper.ts).
|
|
33
|
+
*/
|
|
34
|
+
export declare function joinMount(mount: string, pattern: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* Whether two matches of an include() mount pattern are in the same instance
|
|
37
|
+
* of the include: `/en/...` and `/de/...` under `/:locale` are not.
|
|
38
|
+
*/
|
|
39
|
+
export declare function sameMountInstance(mount: string, a: Readonly<Record<string, string>>, b: Readonly<Record<string, string>>): boolean;
|
|
@@ -353,6 +353,14 @@ interface HelperContext {
|
|
|
353
353
|
includeScope?: string;
|
|
354
354
|
}
|
|
355
355
|
export declare const RangoContext: AsyncLocalStorage<HelperContext>;
|
|
356
|
+
type ShortCodeType = "layout" | "parallel" | "route" | "loader" | "cache";
|
|
357
|
+
/**
|
|
358
|
+
* The shortCode the `ahead`-th next `getShortCode(type)` call in this DSL
|
|
359
|
+
* context returns, without taking it. clientUrls() materialization reads the
|
|
360
|
+
* code of every group route this way, including the routes an evaluation for
|
|
361
|
+
* another route skips (client-urls/layout-loader-copies.ts).
|
|
362
|
+
*/
|
|
363
|
+
export declare function peekShortCode(type: ShortCodeType, ahead: number): string;
|
|
356
364
|
export declare const getContext: () => {
|
|
357
365
|
context: AsyncLocalStorage<HelperContext>;
|
|
358
366
|
getStore: () => HelperContext;
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import type { Handler } from "../types.js";
|
|
2
2
|
import type { PathFn, ResponsePathFn, JsonResponsePathFn, TextResponsePathFn } from "./path-helper-types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The key path() registers a route under: its name, or a `$path_` name built
|
|
5
|
+
* from its pattern, behind the include's name prefix.
|
|
6
|
+
*/
|
|
7
|
+
export declare function routeKeyOf(namePrefix: string | undefined, pattern: string, name: string | undefined): string;
|
|
3
8
|
/**
|
|
4
9
|
* A retained on-demand producer must never render on a live production
|
|
5
10
|
* request: bundle eviction is skipped for onDemand routes (the producer body
|
package/dist/vite/index.js
CHANGED
|
@@ -5929,7 +5929,7 @@ import { resolve } from "node:path";
|
|
|
5929
5929
|
// package.json
|
|
5930
5930
|
var package_default = {
|
|
5931
5931
|
name: "@rangojs/router",
|
|
5932
|
-
version: "0.24.
|
|
5932
|
+
version: "0.24.4",
|
|
5933
5933
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
5934
5934
|
keywords: [
|
|
5935
5935
|
"react",
|
|
@@ -11034,7 +11034,11 @@ function serializeTransition(route2) {
|
|
|
11034
11034
|
function indicesWhere(items, pred) {
|
|
11035
11035
|
return items.flatMap((item, index) => pred(item) ? [index] : []);
|
|
11036
11036
|
}
|
|
11037
|
-
function serializeRoute(route2, options) {
|
|
11037
|
+
function serializeRoute(route2, options, ordinals) {
|
|
11038
|
+
const layoutLoaders = route2.layoutLoaders.map((record) => {
|
|
11039
|
+
if (!ordinals.has(record)) ordinals.set(record, ordinals.size);
|
|
11040
|
+
return ordinals.get(record);
|
|
11041
|
+
});
|
|
11038
11042
|
const loaderIds = route2.loaders.map(({ loader: loader2 }, index) => {
|
|
11039
11043
|
if (typeof loader2.$$id !== "string" || loader2.$$id.length === 0) {
|
|
11040
11044
|
throw projectionError(
|
|
@@ -11078,6 +11082,7 @@ function serializeRoute(route2, options) {
|
|
|
11078
11082
|
loaderRevalidate.map((keys) => Object.freeze(keys))
|
|
11079
11083
|
)
|
|
11080
11084
|
} : {},
|
|
11085
|
+
...layoutLoaders.length > 0 ? { layoutLoaders: Object.freeze(layoutLoaders) } : {},
|
|
11081
11086
|
...transition2 ? { transition: transition2 } : {},
|
|
11082
11087
|
...typeof route2.transition?.when === "function" ? { transitionWhen: true } : {}
|
|
11083
11088
|
});
|
|
@@ -11099,10 +11104,11 @@ function serializeIntercept(record, index) {
|
|
|
11099
11104
|
});
|
|
11100
11105
|
}
|
|
11101
11106
|
function serializeClientUrlPatterns(patterns, options = {}) {
|
|
11107
|
+
const ordinals = /* @__PURE__ */ new Map();
|
|
11102
11108
|
return Object.freeze({
|
|
11103
11109
|
version: 1,
|
|
11104
11110
|
routes: Object.freeze(
|
|
11105
|
-
patterns.routes.map((route2) => serializeRoute(route2, options))
|
|
11111
|
+
patterns.routes.map((route2) => serializeRoute(route2, options, ordinals))
|
|
11106
11112
|
),
|
|
11107
11113
|
intercepts: Object.freeze(
|
|
11108
11114
|
(patterns.intercepts ?? []).map(serializeIntercept)
|
package/package.json
CHANGED
|
@@ -278,6 +278,18 @@ yet (a known gap, left for a follow-up). The build still checks those
|
|
|
278
278
|
predicates like any other, so one that captures a binding of the `"use client"`
|
|
279
279
|
module fails the build although nothing runs it: import it, or drop it.
|
|
280
280
|
|
|
281
|
+
A group `layout()` loader revalidates as a server layout's loader does. With no
|
|
282
|
+
`revalidate()` a navigation keeps it, whichever route of the layout it goes to
|
|
283
|
+
and whatever params or search change (a same-route param navigation too), and
|
|
284
|
+
an action re-runs it. Its `revalidate()` decides on every navigation and
|
|
285
|
+
action, with that default as `defaultShouldRevalidate`: a shop layout's
|
|
286
|
+
`loader(CartLoader, () => [revalidate(({ isAction }) => isAction(CartActions))])`
|
|
287
|
+
runs on the document request and then only on cart actions, and the page
|
|
288
|
+
commits without waiting for it. A navigation into the layout runs it. A layout
|
|
289
|
+
loader that reads the route's params needs a rule that re-runs it:
|
|
290
|
+
`revalidate(({ currentParams, nextParams, defaultShouldRevalidate }) =>
|
|
291
|
+
currentParams.slug !== nextParams.slug ? true : defaultShouldRevalidate)`.
|
|
292
|
+
|
|
281
293
|
Two scars worth copying:
|
|
282
294
|
|
|
283
295
|
- A blunt `() => false` keeps serving the OLD product on product→product
|
|
@@ -455,7 +467,11 @@ makes the first flush, and `ssr: false` guarantees the loader's data does. It
|
|
|
455
467
|
does not cover the client component's module: the first document a build's
|
|
456
468
|
fresh server renders can still be waiting for it. The dev server matches a
|
|
457
469
|
build here: when the shell is ready, it waits for the client components the
|
|
458
|
-
payload has named and that are still loading, and for nothing else.
|
|
470
|
+
payload has named and that are still loading, and for nothing else. On a
|
|
471
|
+
document with an `ssr: false` loader, a part React finishes a moment after the
|
|
472
|
+
shell, such as a `useActionState` form (React suspends it once to encode its
|
|
473
|
+
action), is in the first flush too: the router gives React up to three more
|
|
474
|
+
turns before it, and none when the document is already complete.
|
|
459
475
|
|
|
460
476
|
## `prefetch: false` — keep a loader out of prefetches
|
|
461
477
|
|
|
@@ -477,10 +493,16 @@ too, `useOutlet().pending` ends with that commit, and one follow-up request
|
|
|
477
493
|
own `<Suspense>` to show the page and that fallback at once; with no boundary
|
|
478
494
|
React holds the page being left until the fill answers, as it would with no
|
|
479
495
|
prefetch. Same rules as on a server route (`/loader` → "`prefetch: false`"):
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
496
|
+
the flag skips a loader in a prefetch only when the loader is new to the page.
|
|
497
|
+
A loader already on screen is never deferred: if its revalidation (its
|
|
498
|
+
`revalidate()` or the default) re-runs it, it runs in the prefetch with the
|
|
499
|
+
rest of the destination; otherwise it is held. A same-route click
|
|
500
|
+
(`/product/a` to `/product/b`) is the familiar case.
|
|
501
|
+
|
|
502
|
+
- A group `layout()` loader is on screen on every route of its layout: a
|
|
503
|
+
prefetch between two of them defers nothing for it (its `revalidate()`
|
|
504
|
+
decides, and with no rule it is held), and a prefetch that enters the
|
|
505
|
+
layout defers it.
|
|
484
506
|
- A flagged `loader()` on a server layout above the group is deferred while
|
|
485
507
|
that layout is new to the page (entering the group from outside).
|
|
486
508
|
- `loading()` in `clientUrls()` takes no options, and a flagged `loading()`
|
|
@@ -529,11 +551,22 @@ a hard load of the target URL renders the full route.
|
|
|
529
551
|
`loading()` does not re-flash there. Across different routes, a `layout()`
|
|
530
552
|
keeps its instance while the navigation stays under it, and its own
|
|
531
553
|
`layout()` loaders keep their data on the optimistic frame (a cart count does
|
|
532
|
-
not suspend)
|
|
533
|
-
|
|
554
|
+
not suspend), and in the canonical commit when their `revalidate()` holds
|
|
555
|
+
them; only the destination's loaders and those of layouts it mounts anew
|
|
556
|
+
wait for the server. `transition()` is not needed for the
|
|
534
557
|
hold; it adds the view-transition animation. Inline `<Suspense>` above each
|
|
535
558
|
`useLoader` read is still the finer-grained tool when different reads on
|
|
536
559
|
one route should wait independently.
|
|
560
|
+
- **Parameterized mounts.** A group under `include("/:locale", ...)`,
|
|
561
|
+
`include("/:locale?", ...)` or `include("/:accountId/:projectId", ...)`
|
|
562
|
+
presents at the click like one under a literal mount, and `useParams()` in
|
|
563
|
+
the destination carries the mount's params. A click that changes a mount
|
|
564
|
+
param (`/en/...` to `/de/...`) is another instance of the include: no
|
|
565
|
+
optimistic frame, a plain navigation. The group's intercepts open only from
|
|
566
|
+
one of its routes in the same mount instance. A URL the server routes
|
|
567
|
+
elsewhere (a root route that a group `/:slug` under `/:locale?` also
|
|
568
|
+
matches) never renders as a group route: the server ships the competing
|
|
569
|
+
routes with the group.
|
|
537
570
|
- **Two parallel loaders with equal latency look "SSR'd" together.** Loaders
|
|
538
571
|
kick off in parallel, so awaiting one (`ssr: false`) gives
|
|
539
572
|
same-or-faster siblings time to settle coincidentally. Do not read "it was
|
package/skills/loader/SKILL.md
CHANGED
|
@@ -1128,10 +1128,13 @@ What happens:
|
|
|
1128
1128
|
- A **prefetch** of the route does not run the loader and does not read its
|
|
1129
1129
|
`cache()`; the payload marks it deferred. The rest of the page prefetches as
|
|
1130
1130
|
usual.
|
|
1131
|
-
- Only on a
|
|
1132
|
-
|
|
1133
|
-
in the prefetch
|
|
1134
|
-
click sends no second request
|
|
1131
|
+
- Only on a loader new to the page. A loader already on screen is never
|
|
1132
|
+
deferred: if its revalidation (its `revalidate()` or the default) re-runs
|
|
1133
|
+
it, it runs in the prefetch with the rest of the destination, exactly as
|
|
1134
|
+
without the flag, and the click sends no second request; otherwise it is
|
|
1135
|
+
held. The familiar case is a same-route navigation such as `/product/a` to
|
|
1136
|
+
`/product/b`; a `clientUrls()` group `layout()` loader is on screen on every
|
|
1137
|
+
route of its layout the same way. The rule: a click that adopts a prefetch is
|
|
1135
1138
|
never worse than the same click with no prefetch at all (one exception: a
|
|
1136
1139
|
click before its prefetch has answered, under "The limits"), and content on
|
|
1137
1140
|
screen is never swapped for a fallback because of the flag. For the same
|
|
@@ -457,20 +457,13 @@ export function createNavigationBridge(
|
|
|
457
457
|
).filter((segment) => segment.clientGroup === undefined),
|
|
458
458
|
options?.replace ? "replace" : "push",
|
|
459
459
|
{
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
return {
|
|
468
|
-
url: targetUrl,
|
|
469
|
-
params: { ...params, ...destination.params },
|
|
470
|
-
routeName: destination.routeName,
|
|
471
|
-
state: destination.state,
|
|
472
|
-
};
|
|
473
|
-
},
|
|
460
|
+
// The destination's params include the mount's.
|
|
461
|
+
to: () => ({
|
|
462
|
+
url: targetUrl,
|
|
463
|
+
params: destination.params,
|
|
464
|
+
routeName: destination.routeName,
|
|
465
|
+
state: destination.state,
|
|
466
|
+
}),
|
|
474
467
|
extra: destination.when,
|
|
475
468
|
},
|
|
476
469
|
),
|
|
@@ -2,7 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
import { useCallback } from "react";
|
|
4
4
|
import type { LocalReverseFunction } from "../../reverse.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
joinMount,
|
|
7
|
+
substitutePatternParams,
|
|
8
|
+
} from "../../router/substitute-pattern-params.js";
|
|
6
9
|
import { serializeSearchParams } from "../../search-params.js";
|
|
7
10
|
import { useMount } from "./use-mount.js";
|
|
8
11
|
import { useParams } from "./use-params.js";
|
|
@@ -15,22 +18,6 @@ function getPattern(entry: RouteEntry | undefined): string | undefined {
|
|
|
15
18
|
return typeof entry === "string" ? entry : entry.path;
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
/**
|
|
19
|
-
* Join an include mount prefix with a mount-relative pattern.
|
|
20
|
-
*
|
|
21
|
-
* `pattern === "/"` is the index of the local module — under a non-root
|
|
22
|
-
* mount it must collapse so `/` under `/blog` becomes `/blog`, not
|
|
23
|
-
* `/blog/`. This matches `ctx.reverse(".index")` on the server.
|
|
24
|
-
*/
|
|
25
|
-
function joinMount(mount: string, pattern: string): string {
|
|
26
|
-
if (pattern === "/") {
|
|
27
|
-
if (mount === "" || mount === "/") return "/";
|
|
28
|
-
return mount.endsWith("/") ? mount.slice(0, -1) : mount;
|
|
29
|
-
}
|
|
30
|
-
const normalizedMount = mount === "/" ? "" : mount.replace(/\/+$/, "");
|
|
31
|
-
return normalizedMount + pattern;
|
|
32
|
-
}
|
|
33
|
-
|
|
34
21
|
/**
|
|
35
22
|
* Mount-aware reverse function for a locally-imported `routes` map.
|
|
36
23
|
*
|
|
@@ -87,6 +87,19 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
87
87
|
input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
|
|
88
88
|
|
|
89
89
|
const diffSet = new Set(diff);
|
|
90
|
+
// A clientUrls() layout() loader the server held on a navigation between
|
|
91
|
+
// two routes of the layout (client-urls/layout-loader-copies.ts): matched
|
|
92
|
+
// names it under the destination route, the page holds it under the group
|
|
93
|
+
// route it leaves. Its copy keeps its data under the new id.
|
|
94
|
+
const origin = input.cachedSegments.find((s) => s.clientGroup);
|
|
95
|
+
const destination =
|
|
96
|
+
origin &&
|
|
97
|
+
input.serverSegments.find((s) => s.clientGroup === origin.clientGroup);
|
|
98
|
+
const carried = (id: string) => {
|
|
99
|
+
const copy =
|
|
100
|
+
destination && cachedSegments.get(id.replace(destination.id, origin.id));
|
|
101
|
+
return copy && { ...copy, id };
|
|
102
|
+
};
|
|
90
103
|
if (IS_BROWSER_DEBUG) {
|
|
91
104
|
debugLog(
|
|
92
105
|
`[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
|
|
@@ -102,7 +115,7 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
102
115
|
const segments = matched
|
|
103
116
|
.map((segId: string) => {
|
|
104
117
|
const fromServer = serverSegments.get(segId);
|
|
105
|
-
|
|
118
|
+
let fromCache = cachedSegments.get(segId);
|
|
106
119
|
|
|
107
120
|
if (fromServer) {
|
|
108
121
|
const inDiff = diffSet.has(segId);
|
|
@@ -179,6 +192,7 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
179
192
|
}
|
|
180
193
|
|
|
181
194
|
// Fall back to cached segment (server expects client to already have it)
|
|
195
|
+
fromCache ??= carried(segId);
|
|
182
196
|
if (!fromCache) {
|
|
183
197
|
if (actor === "action") {
|
|
184
198
|
console.error(`[Browser] MISSING SEGMENT: ${segId} not in cache!`);
|
|
@@ -108,17 +108,21 @@ export function ClientUrlsRoot({
|
|
|
108
108
|
definition,
|
|
109
109
|
routeId,
|
|
110
110
|
namePrefix = "",
|
|
111
|
+
foreign,
|
|
111
112
|
}: {
|
|
112
113
|
definition: ClientUrlPatterns;
|
|
113
114
|
routeId: string;
|
|
114
115
|
/** include() route-name prefix, injected at materialization for canonical
|
|
115
116
|
* name composition (intercept-target coordination). */
|
|
116
117
|
namePrefix?: string;
|
|
118
|
+
/** The server's routes that compete with the group's for a URL
|
|
119
|
+
* (competingPatterns in server-projection.ts). */
|
|
120
|
+
foreign?: readonly string[];
|
|
117
121
|
}): ReactNode {
|
|
118
|
-
// The include() mount
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
122
|
+
// The include() mount pattern this group renders under ("/" at root).
|
|
123
|
+
// Navigation matches an absolute pathname against the definition's
|
|
124
|
+
// patterns joined to it (buildClientUrlsTrie in client-urls.ts), as
|
|
125
|
+
// useReverse joins it.
|
|
122
126
|
const mount = useMount();
|
|
123
127
|
const [intent, setIntent] = useState<ClientUrlNavigationIntent | null>(null);
|
|
124
128
|
|
|
@@ -155,8 +159,9 @@ export function ClientUrlsRoot({
|
|
|
155
159
|
namePrefix,
|
|
156
160
|
setIntent,
|
|
157
161
|
optimisticRoute ? undefined : routeId,
|
|
162
|
+
foreign,
|
|
158
163
|
),
|
|
159
|
-
[definition, mount, namePrefix, routeId, optimisticRoute],
|
|
164
|
+
[definition, mount, namePrefix, routeId, optimisticRoute, foreign],
|
|
160
165
|
);
|
|
161
166
|
|
|
162
167
|
// Pending entries for the destination's loaders: useLoader use()s a Promise
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from "react";
|
|
2
2
|
import { validateUserRouteName } from "../route-name.js";
|
|
3
|
-
import { buildRouteTrie } from "../router/route-trie-builder.js";
|
|
3
|
+
import { buildRouteTrie, type TrieNode } from "../router/route-trie-builder.js";
|
|
4
|
+
import { joinMount } from "../router/substitute-pattern-params.js";
|
|
4
5
|
import { tryTrieMatch } from "../router/trie-matching.js";
|
|
5
6
|
import type { LoaderDefinition } from "../types.js";
|
|
6
7
|
import { isHoistedRevalidateRef } from "./hoisted-revalidate.js";
|
|
@@ -768,28 +769,58 @@ export function clientUrls<const TItems extends ClientUrlItems>(
|
|
|
768
769
|
collectIntercepts(items, intercepts);
|
|
769
770
|
validateIntercepts(intercepts, routes);
|
|
770
771
|
|
|
771
|
-
const
|
|
772
|
-
const staticPrefixes: Record<string, string> = {};
|
|
773
|
-
const trailingSlash: Record<string, string> = {};
|
|
774
|
-
for (const route of routes) {
|
|
775
|
-
routeManifest[route.id] = route.pattern;
|
|
776
|
-
staticPrefixes[route.id] = "";
|
|
777
|
-
if (route.options?.trailingSlash) {
|
|
778
|
-
trailingSlash[route.id] = route.options.trailingSlash;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
const trie = buildRouteTrie(routeManifest, staticPrefixes, trailingSlash);
|
|
772
|
+
const tries = new Map<string, TrieNode>();
|
|
782
773
|
|
|
783
774
|
return Object.freeze({
|
|
784
775
|
__brand: "client-urls" as const,
|
|
785
776
|
routes: Object.freeze(routes),
|
|
786
777
|
intercepts: Object.freeze(intercepts),
|
|
787
|
-
match(pathname: string) {
|
|
778
|
+
match(pathname: string, mount = "/", foreign: readonly string[] = []) {
|
|
779
|
+
const key = `${mount} ${foreign}`;
|
|
780
|
+
let trie = tries.get(key);
|
|
781
|
+
if (!trie) {
|
|
782
|
+
tries.set(key, (trie = buildClientUrlsTrie(routes, mount, foreign)));
|
|
783
|
+
}
|
|
788
784
|
return tryTrieMatch(trie, pathname);
|
|
789
785
|
},
|
|
790
786
|
}) as ClientUrlPatterns<ExtractRoutes<TItems>>;
|
|
791
787
|
}
|
|
792
788
|
|
|
789
|
+
/**
|
|
790
|
+
* The router's trie over a group's routes, each pattern joined to the
|
|
791
|
+
* include() mount PATTERN (`/:locale?`, `/:accountId/:projectId`): an
|
|
792
|
+
* absolute pathname matches with the mount's params bound next to the
|
|
793
|
+
* route's, by the server's own precedence (an optional mount param expands
|
|
794
|
+
* like any other). Comparing the pathname with the mount as a literal prefix
|
|
795
|
+
* never matched a parameterized mount.
|
|
796
|
+
*
|
|
797
|
+
* `foreign` are the server's routes outside the group that compete with its
|
|
798
|
+
* routes for a URL (competingPatterns in server-projection.ts): a `/:slug`
|
|
799
|
+
* route under `/:locale?` matches a root `/login` too. Each is a leaf keyed by
|
|
800
|
+
* its own pattern, inserted last so an exact tie goes to it; a match on one is
|
|
801
|
+
* not the group's.
|
|
802
|
+
*/
|
|
803
|
+
export function buildClientUrlsTrie(
|
|
804
|
+
routes: readonly {
|
|
805
|
+
readonly id: string;
|
|
806
|
+
readonly pattern: string;
|
|
807
|
+
readonly options?: { readonly trailingSlash?: string };
|
|
808
|
+
}[],
|
|
809
|
+
mount: string,
|
|
810
|
+
foreign: readonly string[] = [],
|
|
811
|
+
): TrieNode {
|
|
812
|
+
const routeManifest: Record<string, string> = {};
|
|
813
|
+
const trailingSlash: Record<string, string> = {};
|
|
814
|
+
for (const route of routes) {
|
|
815
|
+
routeManifest[route.id] = joinMount(mount, route.pattern);
|
|
816
|
+
if (route.options?.trailingSlash) {
|
|
817
|
+
trailingSlash[route.id] = route.options.trailingSlash;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
for (const pattern of foreign) routeManifest[pattern] = pattern;
|
|
821
|
+
return buildRouteTrie(routeManifest, {}, trailingSlash);
|
|
822
|
+
}
|
|
823
|
+
|
|
793
824
|
export type {
|
|
794
825
|
ClientTransitionConfig,
|
|
795
826
|
ClientUrlBuilder,
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { EntryData } from "../server/context.js";
|
|
2
|
+
import type { LoaderDefinition } from "../types.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A clientUrls() layout()'s loaders are copied into every route record below
|
|
6
|
+
* it (client-urls.ts applyConfig), and a loader segment's id names its route
|
|
7
|
+
* (`${routeShortCode}D${index}.${$$id}`). On a navigation between two of the
|
|
8
|
+
* layout's routes the destination's copy was a segment the client lacked, so
|
|
9
|
+
* it ran whatever its revalidate() said.
|
|
10
|
+
*
|
|
11
|
+
* Materialization (server-projection.ts) records, for each copy, the ids the
|
|
12
|
+
* same layout() loader has under the group's other routes of that layout. A
|
|
13
|
+
* request whose page holds one of them holds the destination's copy: the
|
|
14
|
+
* loader's revalidate() decides, as on a param navigation, and a held copy is
|
|
15
|
+
* left out of the response. The browser carries the page's copy to the id
|
|
16
|
+
* `matched` names (browser/segment-reconciler.ts) with its handle pushes
|
|
17
|
+
* (handles/pushed-by.ts).
|
|
18
|
+
*
|
|
19
|
+
* A copy takes a server layout's revalidation default, not its route's: held
|
|
20
|
+
* on a navigation, params or search changes included, and re-run on an action
|
|
21
|
+
* (isLayoutLoaderCopy, segment-resolution/revalidation.ts).
|
|
22
|
+
*
|
|
23
|
+
* Keyed by the materialized loader stub, created once per evaluation of the
|
|
24
|
+
* route: the ids depend on the route's shortCode and its siblings'.
|
|
25
|
+
*/
|
|
26
|
+
const copies = new WeakMap<LoaderDefinition<any>, readonly string[]>();
|
|
27
|
+
|
|
28
|
+
export function setLayoutLoaderCopies(
|
|
29
|
+
stub: LoaderDefinition<any>,
|
|
30
|
+
ids: readonly string[],
|
|
31
|
+
): void {
|
|
32
|
+
copies.set(stub, ids);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** True for the materialized stub of a clientUrls() layout() loader copy. */
|
|
36
|
+
export function isLayoutLoaderCopy(stub: LoaderDefinition<any>): boolean {
|
|
37
|
+
return copies.has(stub);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* `held` with the id of each of `route`'s layout() loader copies whose
|
|
42
|
+
* loader the page holds under another route of the layout; `held` itself
|
|
43
|
+
* when there is none.
|
|
44
|
+
*/
|
|
45
|
+
export function withLayoutLoaderCopies(
|
|
46
|
+
held: ReadonlySet<string>,
|
|
47
|
+
route: EntryData,
|
|
48
|
+
): ReadonlySet<string> {
|
|
49
|
+
let out: Set<string> | undefined;
|
|
50
|
+
route.loader?.forEach(({ loader }, index) => {
|
|
51
|
+
if (copies.get(loader)?.some((id) => held.has(id))) {
|
|
52
|
+
(out ??= new Set(held)).add(`${route.shortCode}D${index}.${loader.$$id}`);
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return out ?? held;
|
|
56
|
+
}
|