@rangojs/router 0.12.3 → 0.13.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/types/browser/optimistic-commit.d.ts +12 -0
- package/dist/types/browser/partial-update.d.ts +7 -0
- package/dist/types/browser/react/use-params.d.ts +3 -1
- package/dist/types/browser/react/use-pathname.d.ts +3 -1
- package/dist/types/client-urls/navigation.d.ts +5 -0
- package/dist/types/client-urls/optimistic-location.d.ts +17 -0
- package/dist/types/client-urls/server-projection.d.ts +8 -0
- package/dist/types/urls/include-provider.d.ts +9 -4
- package/dist/types/urls/path-helper-types.d.ts +5 -3
- package/dist/vite/index.js +16 -7
- package/package.json +3 -3
- package/skills/client-urls/SKILL.md +8 -3
- package/src/browser/navigation-bridge.ts +11 -8
- package/src/browser/optimistic-commit.ts +32 -0
- package/src/browser/partial-update.ts +16 -0
- package/src/browser/react/use-params.ts +8 -1
- package/src/browser/react/use-pathname.ts +6 -2
- package/src/browser/react/use-search-params.ts +13 -2
- package/src/client-urls/client-root.tsx +75 -22
- package/src/client-urls/navigation.ts +11 -1
- package/src/client-urls/optimistic-location.ts +22 -0
- package/src/client-urls/server-projection.ts +13 -0
- package/src/router.ts +2 -8
- package/src/segment-system.tsx +15 -0
- package/src/urls/include-helper.ts +6 -10
- package/src/urls/include-provider.ts +26 -7
- package/src/urls/path-helper-types.ts +9 -2
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ViewTransitionClass } from "../types/segments.js";
|
|
2
|
+
/**
|
|
3
|
+
* Transition type added to the canonical commit of a navigation that already
|
|
4
|
+
* presented an optimistic clientUrls() destination (client-urls/client-root.tsx).
|
|
5
|
+
* The optimistic swap ran in a transition lane and got the route's configured
|
|
6
|
+
* <ViewTransition> animation; the commit then replaces the branch with the
|
|
7
|
+
* destination's own segment — identical pixels — so every router-placed
|
|
8
|
+
* boundary maps this type to "none" (withOptimisticCommitNone) and the user
|
|
9
|
+
* perceives one animated navigation, not two.
|
|
10
|
+
*/
|
|
11
|
+
export declare const OPTIMISTIC_COMMIT_TRANSITION_TYPE = "rango-optimistic-commit";
|
|
12
|
+
export declare function withOptimisticCommitNone(value: ViewTransitionClass | undefined): ViewTransitionClass;
|
|
@@ -43,6 +43,13 @@ export type UpdateMode = {
|
|
|
43
43
|
targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
|
|
44
44
|
/** Source URL for intercept restore (popstate cache miss) */
|
|
45
45
|
interceptSourceUrl?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The bridge already presented an optimistic clientUrls() destination
|
|
48
|
+
* for this navigation: transition-lane commits add
|
|
49
|
+
* OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
|
|
50
|
+
* boundaries do not animate the identical repaint.
|
|
51
|
+
*/
|
|
52
|
+
optimisticPresented?: boolean;
|
|
46
53
|
} | {
|
|
47
54
|
type: "leave-intercept";
|
|
48
55
|
interceptSourceUrl?: string;
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Hook to access the current route params.
|
|
3
3
|
*
|
|
4
4
|
* Returns the merged route params from the matched route.
|
|
5
|
-
* Updates when navigation completes, not during pending navigation
|
|
5
|
+
* Updates when navigation completes, not during pending navigation — except
|
|
6
|
+
* inside an optimistically rendered clientUrls() destination, where it
|
|
7
|
+
* reports THAT route's params (see OptimisticLocationContext).
|
|
6
8
|
*
|
|
7
9
|
* @example
|
|
8
10
|
* ```tsx
|
|
@@ -2,7 +2,9 @@
|
|
|
2
2
|
* Hook to access the current pathname.
|
|
3
3
|
*
|
|
4
4
|
* Returns the committed pathname string (excludes search params and hash).
|
|
5
|
-
* Updates when navigation completes, not during pending navigation
|
|
5
|
+
* Updates when navigation completes, not during pending navigation — except
|
|
6
|
+
* inside an optimistically rendered clientUrls() destination, where it
|
|
7
|
+
* reports THAT route's pathname (see OptimisticLocationContext).
|
|
6
8
|
*
|
|
7
9
|
* @example
|
|
8
10
|
* ```tsx
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import type { ClientUrlPatterns } from "./types.js";
|
|
2
2
|
export interface ClientUrlNavigationIntent {
|
|
3
3
|
readonly routeId: string;
|
|
4
|
+
/** Destination params from the local trie match (definition-local). */
|
|
5
|
+
readonly params: Readonly<Record<string, string>>;
|
|
6
|
+
/** Absolute destination pathname (mount included) and search ("?..." or ""). */
|
|
7
|
+
readonly pathname: string;
|
|
8
|
+
readonly search: string;
|
|
4
9
|
}
|
|
5
10
|
export declare function setActiveInterceptTargets(targets: readonly string[] | undefined): void;
|
|
6
11
|
export interface ClientUrlNavigationPresentation {
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { type Context } from "react";
|
|
2
|
+
/**
|
|
3
|
+
* Route identity of an optimistically rendered clientUrls() destination:
|
|
4
|
+
* the values the local trie match produced for the URL the user navigated
|
|
5
|
+
* to. Provided by ClientUrlsRoot around the optimistic branch ONLY, so
|
|
6
|
+
* useParams / usePathname / useSearchParams inside that branch describe the
|
|
7
|
+
* route being rendered while the same hooks in chrome outside it keep the
|
|
8
|
+
* committed location until the canonical response commits (or redirects, in
|
|
9
|
+
* which case the branch — and these values — are discarded).
|
|
10
|
+
*/
|
|
11
|
+
export interface OptimisticLocation {
|
|
12
|
+
readonly params: Readonly<Record<string, string>>;
|
|
13
|
+
readonly pathname: string;
|
|
14
|
+
/** Search string including the leading "?" (or ""). */
|
|
15
|
+
readonly search: string;
|
|
16
|
+
}
|
|
17
|
+
export declare const OptimisticLocationContext: Context<OptimisticLocation | null>;
|
|
@@ -54,6 +54,14 @@ export interface ClientUrlProjection {
|
|
|
54
54
|
export declare function serializeClientUrlPatterns(patterns: ClientUrlPatterns): ClientUrlProjection;
|
|
55
55
|
export declare function isClientUrlPatterns(value: unknown): value is ClientUrlPatterns;
|
|
56
56
|
export declare function isClientUrlReference(value: unknown): value is ClientUrlReference;
|
|
57
|
+
/**
|
|
58
|
+
* A clientUrls() definition object or, on the server, its client reference.
|
|
59
|
+
* Run this BEFORE any duck-typing of the value (`typeof === "function"`,
|
|
60
|
+
* `.handler` reads): a client reference is a callable Proxy that throws on
|
|
61
|
+
* unknown property reads, so a later shape check would either invoke it as a
|
|
62
|
+
* thunk or surface React's "cannot dot into a client module" error.
|
|
63
|
+
*/
|
|
64
|
+
export declare function isClientUrlSource(value: unknown): value is ClientUrlDefinitionSource;
|
|
57
65
|
export declare function setClientUrlProjection(reference: string | ClientUrlReference, projection: ClientUrlProjection): void;
|
|
58
66
|
export declare function getClientUrlProjection(reference: string | ClientUrlReference): ClientUrlProjection | undefined;
|
|
59
67
|
export declare function clearClientUrlProjections(): void;
|
|
@@ -1,12 +1,16 @@
|
|
|
1
1
|
import type { UrlPatterns } from "./pattern-types.js";
|
|
2
|
+
import type { ClientUrlPatterns } from "../client-urls/types.js";
|
|
2
3
|
/**
|
|
3
4
|
* What an async `include()` provider may resolve to: a `urls()` value directly,
|
|
4
5
|
* or a module namespace whose `default` export is a `urls()` value (the shape
|
|
5
6
|
* produced by `() => import("./routes")` when the route module does
|
|
6
|
-
* `export default urls(...)`).
|
|
7
|
+
* `export default urls(...)`). A `clientUrls()` module resolves the same way
|
|
8
|
+
* (`() => import("./shop.client")`); on the server its `default` is the client
|
|
9
|
+
* reference, adapted exactly as the eager `include(prefix, clientUrlsDefault)`
|
|
10
|
+
* form.
|
|
7
11
|
*/
|
|
8
|
-
export type IncludeModule<TEnv = any> = UrlPatterns<TEnv> | {
|
|
9
|
-
default: UrlPatterns<TEnv
|
|
12
|
+
export type IncludeModule<TEnv = any> = UrlPatterns<TEnv> | ClientUrlPatterns | {
|
|
13
|
+
default: UrlPatterns<TEnv> | ClientUrlPatterns;
|
|
10
14
|
};
|
|
11
15
|
/**
|
|
12
16
|
* An async/lazy include provider: a thunk returning a `urls()` value (or a
|
|
@@ -22,6 +26,7 @@ export type IncludeProvider<TEnv = any> = () => IncludeModule<TEnv> | Promise<In
|
|
|
22
26
|
export declare function isIncludeProvider(value: unknown): value is IncludeProvider;
|
|
23
27
|
/**
|
|
24
28
|
* Normalize an async provider's resolved value to a `UrlPatterns`. Accepts a
|
|
25
|
-
* `urls()` value directly or a module whose `default` export is
|
|
29
|
+
* `urls()`/`clientUrls()` value directly or a module whose `default` export is
|
|
30
|
+
* one.
|
|
26
31
|
*/
|
|
27
32
|
export declare function resolveIncludeModule<TEnv = any>(mod: IncludeModule<TEnv>, id?: string): UrlPatterns<TEnv>;
|
|
@@ -40,10 +40,12 @@ export type TextResponsePathFn<TEnv> = <const TPattern extends string, const TNa
|
|
|
40
40
|
/**
|
|
41
41
|
* What an async include() provider resolves to. Route types (`TRoutes`) are
|
|
42
42
|
* inferred from the resolved `urls()` value so `href()` and named routes stay
|
|
43
|
-
* type-safe through a code-split module (`() => import("./routes")`).
|
|
43
|
+
* type-safe through a code-split module (`() => import("./routes")`). A
|
|
44
|
+
* clientUrls() module's default export types as ClientUrlPatterns, so
|
|
45
|
+
* `() => import("./shop.client")` infers the group's names the same way.
|
|
44
46
|
*/
|
|
45
|
-
type IncludeResolved<TEnv, TRoutes extends Record<string, any>, TResponses extends Record<string, unknown>> = UrlPatterns<TEnv, TRoutes, TResponses> | {
|
|
46
|
-
default: UrlPatterns<TEnv, TRoutes, TResponses>;
|
|
47
|
+
type IncludeResolved<TEnv, TRoutes extends Record<string, any>, TResponses extends Record<string, unknown>> = UrlPatterns<TEnv, TRoutes, TResponses> | ClientUrlPatterns<TRoutes> | {
|
|
48
|
+
default: UrlPatterns<TEnv, TRoutes, TResponses> | ClientUrlPatterns<TRoutes>;
|
|
47
49
|
};
|
|
48
50
|
/** include() argument: an eager `urls()` value or an async provider thunk. */
|
|
49
51
|
export type IncludeArg<TEnv, TRoutes extends Record<string, any>, TResponses extends Record<string, unknown>> = UrlPatterns<TEnv, TRoutes, TResponses> | ClientUrlPatterns<TRoutes> | (() => IncludeResolved<TEnv, TRoutes, TResponses> | Promise<IncludeResolved<TEnv, TRoutes, TResponses>>);
|
package/dist/vite/index.js
CHANGED
|
@@ -3746,7 +3746,7 @@ import { resolve } from "node:path";
|
|
|
3746
3746
|
// package.json
|
|
3747
3747
|
var package_default = {
|
|
3748
3748
|
name: "@rangojs/router",
|
|
3749
|
-
version: "0.
|
|
3749
|
+
version: "0.13.0",
|
|
3750
3750
|
description: "Django-inspired RSC router with composable URL patterns",
|
|
3751
3751
|
keywords: [
|
|
3752
3752
|
"react",
|
|
@@ -8057,6 +8057,10 @@ import {
|
|
|
8057
8057
|
// src/browser/react/use-pathname.ts
|
|
8058
8058
|
import { useContext as useContext6, useState as useState3, useEffect as useEffect4, useRef as useRef3 } from "react";
|
|
8059
8059
|
|
|
8060
|
+
// src/client-urls/optimistic-location.ts
|
|
8061
|
+
import { createContext as createContext4 } from "react";
|
|
8062
|
+
var OptimisticLocationContext = createContext4(null);
|
|
8063
|
+
|
|
8060
8064
|
// src/browser/react/use-search-params.ts
|
|
8061
8065
|
import {
|
|
8062
8066
|
useCallback as useCallback2,
|
|
@@ -8072,8 +8076,8 @@ import { useContext as useContext8, useState as useState5, useEffect as useEffec
|
|
|
8072
8076
|
var EMPTY_PARAMS = Object.freeze({});
|
|
8073
8077
|
|
|
8074
8078
|
// src/browser/react/nonce-context.ts
|
|
8075
|
-
import { createContext as
|
|
8076
|
-
var NonceContext =
|
|
8079
|
+
import { createContext as createContext5, useContext as useContext9 } from "react";
|
|
8080
|
+
var NonceContext = createContext5(void 0);
|
|
8077
8081
|
|
|
8078
8082
|
// src/browser/react/use-action.ts
|
|
8079
8083
|
import {
|
|
@@ -8112,8 +8116,8 @@ import {
|
|
|
8112
8116
|
} from "react";
|
|
8113
8117
|
|
|
8114
8118
|
// src/theme/theme-context.ts
|
|
8115
|
-
import { createContext as
|
|
8116
|
-
var ThemeContext =
|
|
8119
|
+
import { createContext as createContext6, useContext as useContext12 } from "react";
|
|
8120
|
+
var ThemeContext = createContext6(null);
|
|
8117
8121
|
|
|
8118
8122
|
// src/theme/constants.ts
|
|
8119
8123
|
var THEME_COOKIE = {
|
|
@@ -8155,7 +8159,7 @@ import {
|
|
|
8155
8159
|
|
|
8156
8160
|
// src/browser/react/use-link-status.ts
|
|
8157
8161
|
import {
|
|
8158
|
-
createContext as
|
|
8162
|
+
createContext as createContext7,
|
|
8159
8163
|
useContext as useContext13,
|
|
8160
8164
|
useState as useState11,
|
|
8161
8165
|
useEffect as useEffect11,
|
|
@@ -8163,7 +8167,7 @@ import {
|
|
|
8163
8167
|
useOptimistic as useOptimistic3,
|
|
8164
8168
|
startTransition as startTransition4
|
|
8165
8169
|
} from "react";
|
|
8166
|
-
var LinkContext =
|
|
8170
|
+
var LinkContext = createContext7(
|
|
8167
8171
|
null
|
|
8168
8172
|
);
|
|
8169
8173
|
|
|
@@ -8988,7 +8992,10 @@ init_redirect_origin();
|
|
|
8988
8992
|
import {
|
|
8989
8993
|
createElement as createElement4,
|
|
8990
8994
|
Fragment as Fragment4,
|
|
8995
|
+
Suspense as Suspense3,
|
|
8996
|
+
useDeferredValue,
|
|
8991
8997
|
useEffect as useEffect16,
|
|
8998
|
+
useMemo as useMemo9,
|
|
8992
8999
|
useState as useState15
|
|
8993
9000
|
} from "react";
|
|
8994
9001
|
|
|
@@ -8997,6 +9004,8 @@ import { startTransition as startTransition6 } from "react";
|
|
|
8997
9004
|
|
|
8998
9005
|
// src/client-urls/client-root.tsx
|
|
8999
9006
|
import { jsx as jsx11, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
9007
|
+
var PENDING_FOREVER = new Promise(() => {
|
|
9008
|
+
});
|
|
9000
9009
|
|
|
9001
9010
|
// src/client-urls/server-projection.ts
|
|
9002
9011
|
var SEARCH_SCHEMA_VALUES = /* @__PURE__ */ new Set([
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rangojs/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Django-inspired RSC router with composable URL patterns",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"react",
|
|
@@ -201,8 +201,8 @@
|
|
|
201
201
|
"@testing-library/dom": "^10.4.1",
|
|
202
202
|
"@testing-library/react": "^16.3.2",
|
|
203
203
|
"@types/node": "^24.10.1",
|
|
204
|
-
"@types/react": "^19.
|
|
205
|
-
"@types/react-dom": "^19.
|
|
204
|
+
"@types/react": "^19.3.0",
|
|
205
|
+
"@types/react-dom": "^19.3.0",
|
|
206
206
|
"esbuild": "^0.28.1",
|
|
207
207
|
"happy-dom": "^20.10.1",
|
|
208
208
|
"jiti": "^2.7.0",
|
|
@@ -17,8 +17,9 @@ latency. `clientUrls()` makes that shape unrepresentable.
|
|
|
17
17
|
This is the fastest-transition shape Rango has, and the natural fit for
|
|
18
18
|
dashboard / admin / settings-style apps — high navigation frequency inside one
|
|
19
19
|
layout, mostly tab/param/filter switches. Three things compound: the
|
|
20
|
-
definition also matches in the browser, so a soft navigation
|
|
21
|
-
|
|
20
|
+
definition also matches in the browser, so a soft navigation renders the
|
|
21
|
+
destination component immediately (loader reads suspend into `loading()` or
|
|
22
|
+
an inline `<Suspense>`; `useOutlet().pending` flips for chrome) with no server
|
|
22
23
|
round-trip to start; browser-run `revalidate()` predicates HOLD data across
|
|
23
24
|
navigations that don't invalidate it (a tab switch re-runs nothing — only the
|
|
24
25
|
decision crosses the wire); and any read that does refresh streams behind its
|
|
@@ -112,6 +113,10 @@ import shopUrls from "./shop.client.js";
|
|
|
112
113
|
include("/shop", shopUrls, { name: "shop" });
|
|
113
114
|
```
|
|
114
115
|
|
|
116
|
+
The async form works without a server wrapper module too:
|
|
117
|
+
`include("/shop", () => import("./shop.client.js"), { name: "shop" })` — same
|
|
118
|
+
behavior, no startup win, uniform with code-split server groups.
|
|
119
|
+
|
|
115
120
|
Route names compose through the include (`shop.index`, `shop.product`) and
|
|
116
121
|
flow into the generated route map, so `href`/`reverse` and `Handler<"...">`
|
|
117
122
|
typing work exactly as for server routes (`/typesafety`).
|
|
@@ -123,7 +128,7 @@ typing work exactly as for server routes (`/typesafety`).
|
|
|
123
128
|
| `path()` | Options are `name`, `search`, `trailingSlash`, `ppr` (shell caching — see /ppr skill; loader routes need `loading()` or capture refuses); no response variants |
|
|
124
129
|
| `layout()` | Must contain at least one `path()` |
|
|
125
130
|
| `loader()` | `loader(Def, use?)` or `loader(Def, { ssr: false }, use?)` — see below |
|
|
126
|
-
| `loading()` | Route
|
|
131
|
+
| `loading()` | Route-level boundary around the optimistic render; inline `<Suspense>` at read sites keeps the destination's chrome visible while only the reads wait |
|
|
127
132
|
| `revalidate()` | Valid **inside a loader() use callback only**; runs in the browser |
|
|
128
133
|
| `transition()` | Data-only ViewTransition config — no `when` (that is a server-executed predicate) |
|
|
129
134
|
| `intercept()` | Dot-local named target in the SAME definition; use may contain `loader()`/`loading()` |
|
|
@@ -365,15 +365,18 @@ export function createNavigationBridge(
|
|
|
365
365
|
scroll: options?.scroll,
|
|
366
366
|
state: resolvedState,
|
|
367
367
|
}),
|
|
368
|
-
|
|
369
|
-
? {
|
|
368
|
+
isLeavingIntercept
|
|
369
|
+
? { type: "leave-intercept" as const }
|
|
370
|
+
: {
|
|
370
371
|
type: "navigate" as const,
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
372
|
+
...(hasUsableCache
|
|
373
|
+
? {
|
|
374
|
+
targetCacheSegments: cachedSegments,
|
|
375
|
+
targetCacheHandleData: cachedHandleData,
|
|
376
|
+
}
|
|
377
|
+
: {}),
|
|
378
|
+
optimisticPresented: clientUrlPresentation !== null,
|
|
379
|
+
},
|
|
377
380
|
);
|
|
378
381
|
} catch (error) {
|
|
379
382
|
// Server-side redirect with location state: the current transaction's
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { ViewTransitionClass } from "../types/segments.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Transition type added to the canonical commit of a navigation that already
|
|
5
|
+
* presented an optimistic clientUrls() destination (client-urls/client-root.tsx).
|
|
6
|
+
* The optimistic swap ran in a transition lane and got the route's configured
|
|
7
|
+
* <ViewTransition> animation; the commit then replaces the branch with the
|
|
8
|
+
* destination's own segment — identical pixels — so every router-placed
|
|
9
|
+
* boundary maps this type to "none" (withOptimisticCommitNone) and the user
|
|
10
|
+
* perceives one animated navigation, not two.
|
|
11
|
+
*/
|
|
12
|
+
export const OPTIMISTIC_COMMIT_TRANSITION_TYPE = "rango-optimistic-commit";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Merge the "none" mapping for the optimistic-commit type into a
|
|
16
|
+
* <ViewTransition> class prop. A string class becomes the `default` entry of
|
|
17
|
+
* a map; an absent prop yields the shared type-only map (React falls back to
|
|
18
|
+
* the boundary's `default` prop for other types).
|
|
19
|
+
*/
|
|
20
|
+
const NONE_ONLY: ViewTransitionClass = Object.freeze({
|
|
21
|
+
[OPTIMISTIC_COMMIT_TRANSITION_TYPE]: "none",
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export function withOptimisticCommitNone(
|
|
25
|
+
value: ViewTransitionClass | undefined,
|
|
26
|
+
): ViewTransitionClass {
|
|
27
|
+
if (value === undefined) return NONE_ONLY;
|
|
28
|
+
if (typeof value === "string") {
|
|
29
|
+
return { default: value, [OPTIMISTIC_COMMIT_TRANSITION_TYPE]: "none" };
|
|
30
|
+
}
|
|
31
|
+
return { ...value, [OPTIMISTIC_COMMIT_TRANSITION_TYPE]: "none" };
|
|
32
|
+
}
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
validateExternalRedirect,
|
|
31
31
|
} from "./validate-redirect-origin.js";
|
|
32
32
|
import type { NavigationUpdate } from "./types.js";
|
|
33
|
+
import { OPTIMISTIC_COMMIT_TRANSITION_TYPE } from "./optimistic-commit.js";
|
|
33
34
|
import {
|
|
34
35
|
collectClientRevalidationDecisions,
|
|
35
36
|
setActiveInterceptTargets,
|
|
@@ -98,6 +99,13 @@ export type UpdateMode =
|
|
|
98
99
|
targetCacheHandleData?: Record<string, Record<string, unknown[]>>;
|
|
99
100
|
/** Source URL for intercept restore (popstate cache miss) */
|
|
100
101
|
interceptSourceUrl?: string;
|
|
102
|
+
/**
|
|
103
|
+
* The bridge already presented an optimistic clientUrls() destination
|
|
104
|
+
* for this navigation: transition-lane commits add
|
|
105
|
+
* OPTIMISTIC_COMMIT_TRANSITION_TYPE so router <ViewTransition>
|
|
106
|
+
* boundaries do not animate the identical repaint.
|
|
107
|
+
*/
|
|
108
|
+
optimisticPresented?: boolean;
|
|
101
109
|
}
|
|
102
110
|
| { type: "leave-intercept"; interceptSourceUrl?: string }
|
|
103
111
|
| { type: "stale-revalidation"; interceptSourceUrl?: string }
|
|
@@ -555,6 +563,8 @@ export function createPartialUpdater(
|
|
|
555
563
|
debugLog("[partial-update] updating document");
|
|
556
564
|
|
|
557
565
|
const hasTransition = shouldStartViewTransition(reconciled.segments);
|
|
566
|
+
const optimisticPresented =
|
|
567
|
+
mode.type === "navigate" && mode.optimisticPresented === true;
|
|
558
568
|
// [VT-DIAG] Gated behind INTERNAL_RANGO_DEBUG. Reports which reconciled
|
|
559
569
|
// segment still carries a transition after the server-side when-gate, and
|
|
560
570
|
// whether the commit will be held in a startTransition. If `withTransition`
|
|
@@ -594,6 +604,9 @@ export function createPartialUpdater(
|
|
|
594
604
|
startTransition(() => {
|
|
595
605
|
if (addTransitionType) {
|
|
596
606
|
addTransitionType("navigation");
|
|
607
|
+
if (optimisticPresented) {
|
|
608
|
+
addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
|
|
609
|
+
}
|
|
597
610
|
}
|
|
598
611
|
onUpdate({
|
|
599
612
|
root: newTree,
|
|
@@ -622,6 +635,9 @@ export function createPartialUpdater(
|
|
|
622
635
|
// first. Boundaries newly mounted by this nav still reveal their
|
|
623
636
|
// fallbacks (React shows new boundaries inside transitions).
|
|
624
637
|
startTransition(() => {
|
|
638
|
+
if (optimisticPresented && addTransitionType) {
|
|
639
|
+
addTransitionType(OPTIMISTIC_COMMIT_TRANSITION_TYPE);
|
|
640
|
+
}
|
|
625
641
|
onUpdate({
|
|
626
642
|
root: newTree,
|
|
627
643
|
metadata: payload.metadata!,
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { useContext, useState, useEffect, useRef } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
5
|
import { shallowEqual } from "./shallow-equal.js";
|
|
6
|
+
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
6
7
|
|
|
7
8
|
const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
|
|
8
9
|
|
|
@@ -10,7 +11,9 @@ const EMPTY_PARAMS: Record<string, string> = Object.freeze({});
|
|
|
10
11
|
* Hook to access the current route params.
|
|
11
12
|
*
|
|
12
13
|
* Returns the merged route params from the matched route.
|
|
13
|
-
* Updates when navigation completes, not during pending navigation
|
|
14
|
+
* Updates when navigation completes, not during pending navigation — except
|
|
15
|
+
* inside an optimistically rendered clientUrls() destination, where it
|
|
16
|
+
* reports THAT route's params (see OptimisticLocationContext).
|
|
14
17
|
*
|
|
15
18
|
* @example
|
|
16
19
|
* ```tsx
|
|
@@ -43,6 +46,7 @@ export function useParams<T>(
|
|
|
43
46
|
selector?: (params: Record<string, string | undefined>) => T,
|
|
44
47
|
): T | Record<string, string | undefined> {
|
|
45
48
|
const ctx = useContext(NavigationStoreContext);
|
|
49
|
+
const optimistic = useContext(OptimisticLocationContext);
|
|
46
50
|
|
|
47
51
|
const [value, setValue] = useState<T | Record<string, string>>(() => {
|
|
48
52
|
const params = ctx ? ctx.eventController.getParams() : EMPTY_PARAMS;
|
|
@@ -71,5 +75,8 @@ export function useParams<T>(
|
|
|
71
75
|
return ctx.eventController.subscribe(update);
|
|
72
76
|
}, []);
|
|
73
77
|
|
|
78
|
+
if (optimistic) {
|
|
79
|
+
return selector ? selector(optimistic.params) : optimistic.params;
|
|
80
|
+
}
|
|
74
81
|
return value;
|
|
75
82
|
}
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
|
|
3
3
|
import { useContext, useState, useEffect, useRef } from "react";
|
|
4
4
|
import { NavigationStoreContext } from "./context.js";
|
|
5
|
+
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Hook to access the current pathname.
|
|
8
9
|
*
|
|
9
10
|
* Returns the committed pathname string (excludes search params and hash).
|
|
10
|
-
* Updates when navigation completes, not during pending navigation
|
|
11
|
+
* Updates when navigation completes, not during pending navigation — except
|
|
12
|
+
* inside an optimistically rendered clientUrls() destination, where it
|
|
13
|
+
* reports THAT route's pathname (see OptimisticLocationContext).
|
|
11
14
|
*
|
|
12
15
|
* @example
|
|
13
16
|
* ```tsx
|
|
@@ -17,6 +20,7 @@ import { NavigationStoreContext } from "./context.js";
|
|
|
17
20
|
*/
|
|
18
21
|
export function usePathname(): string {
|
|
19
22
|
const ctx = useContext(NavigationStoreContext);
|
|
23
|
+
const optimistic = useContext(OptimisticLocationContext);
|
|
20
24
|
|
|
21
25
|
const [pathname, setPathname] = useState<string>(() => {
|
|
22
26
|
if (!ctx) {
|
|
@@ -43,5 +47,5 @@ export function usePathname(): string {
|
|
|
43
47
|
return ctx.eventController.subscribe(update);
|
|
44
48
|
}, []);
|
|
45
49
|
|
|
46
|
-
return pathname;
|
|
50
|
+
return optimistic ? optimistic.pathname : pathname;
|
|
47
51
|
}
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
} from "react";
|
|
11
11
|
import { NavigationStoreContext } from "./context.js";
|
|
12
12
|
import type { ReadonlyURLSearchParams } from "../types.js";
|
|
13
|
+
import { OptimisticLocationContext } from "../../client-urls/optimistic-location.js";
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Accepted shapes for the setter: a full replacement for the search string.
|
|
@@ -110,6 +111,7 @@ function normalizeInit(init: SearchParamsInit): URLSearchParams {
|
|
|
110
111
|
*/
|
|
111
112
|
export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
|
|
112
113
|
const ctx = useContext(NavigationStoreContext);
|
|
114
|
+
const optimistic = useContext(OptimisticLocationContext);
|
|
113
115
|
|
|
114
116
|
// Seed from the store location on BOTH sides (mirrors usePathname): the
|
|
115
117
|
// SSR store carries the live request's search, the browser store carries
|
|
@@ -167,8 +169,17 @@ export function useSearchParams(): [ReadonlyURLSearchParams, SetSearchParams] {
|
|
|
167
169
|
});
|
|
168
170
|
}, []);
|
|
169
171
|
|
|
172
|
+
// Inside an optimistically rendered clientUrls() destination the read side
|
|
173
|
+
// is THAT route's search (see OptimisticLocationContext); the setter keeps
|
|
174
|
+
// navigating from the committed location.
|
|
175
|
+
const optimisticSearch = optimistic?.search;
|
|
170
176
|
return useMemo(
|
|
171
|
-
() => [
|
|
172
|
-
|
|
177
|
+
() => [
|
|
178
|
+
optimisticSearch === undefined
|
|
179
|
+
? searchParams
|
|
180
|
+
: new URLSearchParams(optimisticSearch),
|
|
181
|
+
setSearchParams,
|
|
182
|
+
],
|
|
183
|
+
[searchParams, setSearchParams, optimisticSearch],
|
|
173
184
|
);
|
|
174
185
|
}
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
import {
|
|
4
4
|
createElement,
|
|
5
5
|
Fragment,
|
|
6
|
+
Suspense,
|
|
7
|
+
useDeferredValue,
|
|
6
8
|
useEffect,
|
|
9
|
+
useMemo,
|
|
7
10
|
useState,
|
|
8
11
|
type ReactNode,
|
|
9
12
|
} from "react";
|
|
@@ -14,12 +17,18 @@ import {
|
|
|
14
17
|
registerClientUrlGroup,
|
|
15
18
|
type ClientUrlNavigationIntent,
|
|
16
19
|
} from "./navigation.js";
|
|
20
|
+
import {
|
|
21
|
+
OptimisticLocationContext,
|
|
22
|
+
type OptimisticLocation,
|
|
23
|
+
} from "./optimistic-location.js";
|
|
17
24
|
import type {
|
|
18
25
|
ClientUrlInterceptRecord,
|
|
19
26
|
ClientUrlPatterns,
|
|
20
27
|
ClientUrlRouteRecord,
|
|
21
28
|
} from "./types.js";
|
|
22
29
|
|
|
30
|
+
const PENDING_FOREVER: Promise<never> = new Promise<never>(() => {});
|
|
31
|
+
|
|
23
32
|
function findRoute(
|
|
24
33
|
definition: ClientUrlPatterns,
|
|
25
34
|
routeId: string,
|
|
@@ -120,29 +129,61 @@ export function ClientUrlsRoot({
|
|
|
120
129
|
[definition, mount, namePrefix],
|
|
121
130
|
);
|
|
122
131
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
131
|
-
pendingRoute !== null && pendingRoute.loading !== undefined;
|
|
132
|
-
const route = hasPendingLoading
|
|
133
|
-
? pendingRoute
|
|
134
|
-
: findRoute(definition, routeId);
|
|
135
|
-
// ANY in-flight group navigation is pending — including same-route navs
|
|
136
|
-
// (intent.routeId === routeId). For the search-only shape (filters, tabs)
|
|
137
|
-
// the canonical commit is HELD in a transition (isSameStructureNav in
|
|
138
|
-
// partial-update.ts) with no content swap to signal progress — this flag
|
|
139
|
-
// is the only affordance. The urgent setIntent at nav start flips it
|
|
140
|
-
// immediately; the transition-wrapped clear() entangles with the held
|
|
141
|
-
// commit, so pending drops exactly when the data lands.
|
|
132
|
+
// Optimistic destination (design: docs/design/client-urls-optimistic-destination.md).
|
|
133
|
+
// `intent` is set urgently at navigation start so `pending` flips at once
|
|
134
|
+
// for chrome; the CONTENT swap keys off the deferred value so it renders in
|
|
135
|
+
// a transition lane: a destination that suspends with no boundary of its
|
|
136
|
+
// own keeps the previous content visible (React's transition hold — the
|
|
137
|
+
// pre-existing contract for routes without loading()), one with loading()
|
|
138
|
+
// or inline <Suspense> at its reads presents immediately. Same-route intents
|
|
139
|
+
// never swap: held data + transition() own that case.
|
|
142
140
|
const pending = intent !== null;
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
141
|
+
const presented = useDeferredValue(intent);
|
|
142
|
+
const optimisticRoute =
|
|
143
|
+
presented && presented.routeId !== routeId
|
|
144
|
+
? findRoute(definition, presented.routeId)
|
|
145
|
+
: null;
|
|
146
|
+
const route = optimisticRoute ?? findRoute(definition, routeId);
|
|
147
|
+
|
|
148
|
+
// Pending entries for the destination's loaders: useLoader use()s a Promise
|
|
149
|
+
// found in `loaderStreams` (the streaming-loader lane), so a read suspends
|
|
150
|
+
// instead of throwing "not found in context". Nothing resolves them — the
|
|
151
|
+
// canonical commit mounts the destination's own segment with real data and
|
|
152
|
+
// unmounts this branch — so one shared promise serves every loader.
|
|
153
|
+
// Memoized on the intent: use() needs a stable identity across replays.
|
|
154
|
+
const optimistic = useMemo<{
|
|
155
|
+
streams: Record<string, Promise<never>>;
|
|
156
|
+
location: OptimisticLocation;
|
|
157
|
+
} | null>(
|
|
158
|
+
() =>
|
|
159
|
+
optimisticRoute && presented
|
|
160
|
+
? {
|
|
161
|
+
streams: Object.fromEntries(
|
|
162
|
+
optimisticRoute.loaders.map((record) => [
|
|
163
|
+
record.loader.$$id,
|
|
164
|
+
PENDING_FOREVER,
|
|
165
|
+
]),
|
|
166
|
+
),
|
|
167
|
+
location: {
|
|
168
|
+
params: presented.params,
|
|
169
|
+
pathname: presented.pathname,
|
|
170
|
+
search: presented.search,
|
|
171
|
+
},
|
|
172
|
+
}
|
|
173
|
+
: null,
|
|
174
|
+
[optimisticRoute, presented],
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
let content: ReactNode = createElement(route.component, { key: route.id });
|
|
178
|
+
if (optimisticRoute && optimisticRoute.loading !== undefined) {
|
|
179
|
+
// loading() is the route-level boundary around the optimistic render;
|
|
180
|
+
// presence mirrors the projection's hasLoading (a falsy-but-valid node
|
|
181
|
+
// like loading("") is still a configured fallback).
|
|
182
|
+
content = createElement(Suspense, {
|
|
183
|
+
fallback: optimisticRoute.loading,
|
|
184
|
+
children: content,
|
|
185
|
+
});
|
|
186
|
+
}
|
|
146
187
|
|
|
147
188
|
for (let index = route.layouts.length - 1; index >= 0; index--) {
|
|
148
189
|
const layoutKey = `${route.id}-layout-${index}`;
|
|
@@ -154,6 +195,18 @@ export function ClientUrlsRoot({
|
|
|
154
195
|
});
|
|
155
196
|
}
|
|
156
197
|
|
|
198
|
+
if (optimistic) {
|
|
199
|
+
content = createElement(OutletProvider, {
|
|
200
|
+
content: null,
|
|
201
|
+
loaderStreams: optimistic.streams,
|
|
202
|
+
pending,
|
|
203
|
+
children: createElement(OptimisticLocationContext.Provider, {
|
|
204
|
+
value: optimistic.location,
|
|
205
|
+
children: content,
|
|
206
|
+
}),
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
157
210
|
return content;
|
|
158
211
|
}
|
|
159
212
|
|
|
@@ -11,6 +11,11 @@ import type { ClientUrlPatterns } from "./types.js";
|
|
|
11
11
|
|
|
12
12
|
export interface ClientUrlNavigationIntent {
|
|
13
13
|
readonly routeId: string;
|
|
14
|
+
/** Destination params from the local trie match (definition-local). */
|
|
15
|
+
readonly params: Readonly<Record<string, string>>;
|
|
16
|
+
/** Absolute destination pathname (mount included) and search ("?..." or ""). */
|
|
17
|
+
readonly pathname: string;
|
|
18
|
+
readonly search: string;
|
|
14
19
|
}
|
|
15
20
|
|
|
16
21
|
interface ActiveClientUrlGroup {
|
|
@@ -112,7 +117,12 @@ export function beginClientUrlNavigation(
|
|
|
112
117
|
if (activeInterceptTargets.has(canonicalName)) return null;
|
|
113
118
|
}
|
|
114
119
|
|
|
115
|
-
const intent: ClientUrlNavigationIntent = {
|
|
120
|
+
const intent: ClientUrlNavigationIntent = {
|
|
121
|
+
routeId: match.routeKey,
|
|
122
|
+
params: match.params,
|
|
123
|
+
pathname: targetUrl.pathname,
|
|
124
|
+
search: targetUrl.search,
|
|
125
|
+
};
|
|
116
126
|
group.intent = intent;
|
|
117
127
|
group.setIntent(intent);
|
|
118
128
|
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, type Context } from "react";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Route identity of an optimistically rendered clientUrls() destination:
|
|
7
|
+
* the values the local trie match produced for the URL the user navigated
|
|
8
|
+
* to. Provided by ClientUrlsRoot around the optimistic branch ONLY, so
|
|
9
|
+
* useParams / usePathname / useSearchParams inside that branch describe the
|
|
10
|
+
* route being rendered while the same hooks in chrome outside it keep the
|
|
11
|
+
* committed location until the canonical response commits (or redirects, in
|
|
12
|
+
* which case the branch — and these values — are discarded).
|
|
13
|
+
*/
|
|
14
|
+
export interface OptimisticLocation {
|
|
15
|
+
readonly params: Readonly<Record<string, string>>;
|
|
16
|
+
readonly pathname: string;
|
|
17
|
+
/** Search string including the leading "?" (or ""). */
|
|
18
|
+
readonly search: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const OptimisticLocationContext: Context<OptimisticLocation | null> =
|
|
22
|
+
createContext<OptimisticLocation | null>(null);
|
|
@@ -359,6 +359,19 @@ export function isClientUrlReference(
|
|
|
359
359
|
}
|
|
360
360
|
}
|
|
361
361
|
|
|
362
|
+
/**
|
|
363
|
+
* A clientUrls() definition object or, on the server, its client reference.
|
|
364
|
+
* Run this BEFORE any duck-typing of the value (`typeof === "function"`,
|
|
365
|
+
* `.handler` reads): a client reference is a callable Proxy that throws on
|
|
366
|
+
* unknown property reads, so a later shape check would either invoke it as a
|
|
367
|
+
* thunk or surface React's "cannot dot into a client module" error.
|
|
368
|
+
*/
|
|
369
|
+
export function isClientUrlSource(
|
|
370
|
+
value: unknown,
|
|
371
|
+
): value is ClientUrlDefinitionSource {
|
|
372
|
+
return isClientUrlPatterns(value) || isClientUrlReference(value);
|
|
373
|
+
}
|
|
374
|
+
|
|
362
375
|
/**
|
|
363
376
|
* Strip vite's HMR timestamp query from a module id. After an HMR update of
|
|
364
377
|
* a clientUrls module, the RSC graph re-imports it under
|
package/src/router.ts
CHANGED
|
@@ -3,10 +3,7 @@ import { createCacheScope } from "./cache/cache-scope.js";
|
|
|
3
3
|
import { resolveCacheProfiles } from "./cache/profile-registry.js";
|
|
4
4
|
import { isCachedFunction } from "./cache/taint.js";
|
|
5
5
|
import { assertClientComponent } from "./component-utils.js";
|
|
6
|
-
import {
|
|
7
|
-
isClientUrlPatterns,
|
|
8
|
-
isClientUrlReference,
|
|
9
|
-
} from "./client-urls/server-projection.js";
|
|
6
|
+
import { isClientUrlSource } from "./client-urls/server-projection.js";
|
|
10
7
|
import type { ClientUrlPatterns } from "./client-urls/types.js";
|
|
11
8
|
import { DefaultDocument } from "./components/DefaultDocument.js";
|
|
12
9
|
import type { SerializedManifest } from "./debug.js";
|
|
@@ -784,10 +781,7 @@ export function createRouter<TEnv = any>(
|
|
|
784
781
|
// same lazy include materialization, so no ordering, one-definition, or
|
|
785
782
|
// deferral rules exist. Prefixing, wrapping RSC layouts, and middleware
|
|
786
783
|
// scope still come from mounting through include() in urls() yourself.
|
|
787
|
-
if (
|
|
788
|
-
isClientUrlPatterns(patternsOrBuilder) ||
|
|
789
|
-
isClientUrlReference(patternsOrBuilder)
|
|
790
|
-
) {
|
|
784
|
+
if (isClientUrlSource(patternsOrBuilder)) {
|
|
791
785
|
const clientSource = patternsOrBuilder as ClientUrlPatterns;
|
|
792
786
|
patternsOrBuilder = urls(({ include }) => [
|
|
793
787
|
include("/", clientSource, { name: "" }),
|
package/src/segment-system.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as React from "react";
|
|
2
2
|
import { createElement, type ReactNode, type ComponentType } from "react";
|
|
3
3
|
import { OutletProvider } from "./outlet-provider.js";
|
|
4
|
+
import { withOptimisticCommitNone } from "./browser/optimistic-commit.js";
|
|
4
5
|
import { MountContextProvider } from "./browser/react/mount-context.js";
|
|
5
6
|
import type { ResolvedSegment, RootLayoutProps } from "./types.js";
|
|
6
7
|
import { decodeLoaderResults } from "./decode-loader-results.js";
|
|
@@ -145,6 +146,20 @@ function createViewTransitionBoundary(
|
|
|
145
146
|
const { viewTransition: _viewTransition, ...vtProps } = transition;
|
|
146
147
|
return createElement(ReactViewTransition, {
|
|
147
148
|
...vtProps,
|
|
149
|
+
// The commit after an optimistic clientUrls() presentation repaints the
|
|
150
|
+
// same pixels; it must not animate a second time (browser/optimistic-commit.ts).
|
|
151
|
+
// `default` always carries the mapping; an unset direction already falls
|
|
152
|
+
// through to it, so only set directions need their own merge.
|
|
153
|
+
...(vtProps.enter !== undefined && {
|
|
154
|
+
enter: withOptimisticCommitNone(vtProps.enter),
|
|
155
|
+
}),
|
|
156
|
+
...(vtProps.exit !== undefined && {
|
|
157
|
+
exit: withOptimisticCommitNone(vtProps.exit),
|
|
158
|
+
}),
|
|
159
|
+
...(vtProps.update !== undefined && {
|
|
160
|
+
update: withOptimisticCommitNone(vtProps.update),
|
|
161
|
+
}),
|
|
162
|
+
default: withOptimisticCommitNone(vtProps.default),
|
|
148
163
|
children,
|
|
149
164
|
});
|
|
150
165
|
}
|
|
@@ -13,8 +13,7 @@ import type { IncludeProvider } from "./include-provider.js";
|
|
|
13
13
|
import type { IncludeFn } from "./path-helper-types.js";
|
|
14
14
|
import {
|
|
15
15
|
clientUrlIncludePatterns,
|
|
16
|
-
|
|
17
|
-
isClientUrlReference,
|
|
16
|
+
isClientUrlSource,
|
|
18
17
|
} from "../client-urls/server-projection.js";
|
|
19
18
|
import type { ClientUrlPatterns } from "../client-urls/types.js";
|
|
20
19
|
|
|
@@ -77,14 +76,11 @@ export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
|
|
|
77
76
|
): IncludeItem => {
|
|
78
77
|
const { ctx } = requireDslContext("include() must be called inside urls()");
|
|
79
78
|
|
|
80
|
-
// clientUrls() sources mount
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
// projection is available; the include machinery then applies URL and
|
|
86
|
-
// route-name prefixes exactly as for server modules.
|
|
87
|
-
if (isClientUrlPatterns(patterns) || isClientUrlReference(patterns)) {
|
|
79
|
+
// clientUrls() sources mount like any urls() module: the adapter defers
|
|
80
|
+
// materialization to evaluation time (projection installed by then) and
|
|
81
|
+
// the include machinery applies URL/name prefixes as for server modules.
|
|
82
|
+
// Ordering: see isClientUrlSource.
|
|
83
|
+
if (isClientUrlSource(patterns)) {
|
|
88
84
|
patterns = clientUrlIncludePatterns(patterns) as UrlPatterns<TEnv>;
|
|
89
85
|
}
|
|
90
86
|
|
|
@@ -1,14 +1,23 @@
|
|
|
1
1
|
import type { UrlPatterns } from "./pattern-types.js";
|
|
2
|
+
import type { ClientUrlPatterns } from "../client-urls/types.js";
|
|
3
|
+
import {
|
|
4
|
+
clientUrlIncludePatterns,
|
|
5
|
+
isClientUrlSource,
|
|
6
|
+
} from "../client-urls/server-projection.js";
|
|
2
7
|
|
|
3
8
|
/**
|
|
4
9
|
* What an async `include()` provider may resolve to: a `urls()` value directly,
|
|
5
10
|
* or a module namespace whose `default` export is a `urls()` value (the shape
|
|
6
11
|
* produced by `() => import("./routes")` when the route module does
|
|
7
|
-
* `export default urls(...)`).
|
|
12
|
+
* `export default urls(...)`). A `clientUrls()` module resolves the same way
|
|
13
|
+
* (`() => import("./shop.client")`); on the server its `default` is the client
|
|
14
|
+
* reference, adapted exactly as the eager `include(prefix, clientUrlsDefault)`
|
|
15
|
+
* form.
|
|
8
16
|
*/
|
|
9
17
|
export type IncludeModule<TEnv = any> =
|
|
10
18
|
| UrlPatterns<TEnv>
|
|
11
|
-
|
|
|
19
|
+
| ClientUrlPatterns
|
|
20
|
+
| { default: UrlPatterns<TEnv> | ClientUrlPatterns };
|
|
12
21
|
|
|
13
22
|
/**
|
|
14
23
|
* An async/lazy include provider: a thunk returning a `urls()` value (or a
|
|
@@ -36,9 +45,19 @@ function isUrlPatterns(value: unknown): value is UrlPatterns {
|
|
|
36
45
|
);
|
|
37
46
|
}
|
|
38
47
|
|
|
48
|
+
/**
|
|
49
|
+
* A `urls()` value as is; a `clientUrls()` source through the same adapter the
|
|
50
|
+
* eager include() path applies (ordering: see isClientUrlSource).
|
|
51
|
+
*/
|
|
52
|
+
function toUrlPatterns(value: unknown): UrlPatterns | undefined {
|
|
53
|
+
if (isClientUrlSource(value)) return clientUrlIncludePatterns(value);
|
|
54
|
+
return isUrlPatterns(value) ? value : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
39
57
|
/**
|
|
40
58
|
* Normalize an async provider's resolved value to a `UrlPatterns`. Accepts a
|
|
41
|
-
* `urls()` value directly or a module whose `default` export is
|
|
59
|
+
* `urls()`/`clientUrls()` value directly or a module whose `default` export is
|
|
60
|
+
* one.
|
|
42
61
|
*/
|
|
43
62
|
export function resolveIncludeModule<TEnv = any>(
|
|
44
63
|
mod: IncludeModule<TEnv>,
|
|
@@ -53,8 +72,8 @@ export function resolveIncludeModule<TEnv = any>(
|
|
|
53
72
|
// 404s with a misleading error). A bare `() => urls(...)` provider (no
|
|
54
73
|
// module) has no `default`, so it still resolves via the mod-as-value branch.
|
|
55
74
|
const def = (mod as { default?: unknown })?.default;
|
|
56
|
-
|
|
57
|
-
if (
|
|
75
|
+
const resolved = toUrlPatterns(def) ?? toUrlPatterns(mod);
|
|
76
|
+
if (resolved) return resolved as UrlPatterns<TEnv>;
|
|
58
77
|
// The common failure is a module namespace whose `default` is missing or not a
|
|
59
78
|
// urls() value (e.g. only named exports); `typeof` alone says "object" and
|
|
60
79
|
// hides that, so name the keys present. "provider" (not "async provider") —
|
|
@@ -65,7 +84,7 @@ export function resolveIncludeModule<TEnv = any>(
|
|
|
65
84
|
: typeof mod;
|
|
66
85
|
throw new Error(
|
|
67
86
|
`[@rangojs/router] include() provider${id ? ` for "${id}"` : ""} must ` +
|
|
68
|
-
`resolve to a urls() value — either returned directly or
|
|
69
|
-
|
|
87
|
+
`resolve to a urls() or clientUrls() value — either returned directly or ` +
|
|
88
|
+
`as the module's \`default\` export (e.g. \`export default urls(...)\`). Got ${got}.`,
|
|
70
89
|
);
|
|
71
90
|
}
|
|
@@ -160,7 +160,9 @@ export type TextResponsePathFn<TEnv> = <
|
|
|
160
160
|
/**
|
|
161
161
|
* What an async include() provider resolves to. Route types (`TRoutes`) are
|
|
162
162
|
* inferred from the resolved `urls()` value so `href()` and named routes stay
|
|
163
|
-
* type-safe through a code-split module (`() => import("./routes")`).
|
|
163
|
+
* type-safe through a code-split module (`() => import("./routes")`). A
|
|
164
|
+
* clientUrls() module's default export types as ClientUrlPatterns, so
|
|
165
|
+
* `() => import("./shop.client")` infers the group's names the same way.
|
|
164
166
|
*/
|
|
165
167
|
type IncludeResolved<
|
|
166
168
|
TEnv,
|
|
@@ -168,7 +170,12 @@ type IncludeResolved<
|
|
|
168
170
|
TResponses extends Record<string, unknown>,
|
|
169
171
|
> =
|
|
170
172
|
| UrlPatterns<TEnv, TRoutes, TResponses>
|
|
171
|
-
|
|
|
173
|
+
| ClientUrlPatterns<TRoutes>
|
|
174
|
+
| {
|
|
175
|
+
default:
|
|
176
|
+
| UrlPatterns<TEnv, TRoutes, TResponses>
|
|
177
|
+
| ClientUrlPatterns<TRoutes>;
|
|
178
|
+
};
|
|
172
179
|
|
|
173
180
|
/** include() argument: an eager `urls()` value or an async provider thunk. */
|
|
174
181
|
export type IncludeArg<
|