@rangojs/router 0.0.0-experimental.b02a2fec → 0.0.0-experimental.bf1b128c
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/README.md +50 -20
- package/dist/vite/index.js +1338 -462
- package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/package.json +7 -5
- package/skills/breadcrumbs/SKILL.md +3 -1
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/hooks/SKILL.md +28 -20
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/links/SKILL.md +88 -16
- package/skills/loader/SKILL.md +66 -2
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +765 -0
- package/skills/parallel/SKILL.md +66 -0
- package/skills/rango/SKILL.md +24 -22
- package/skills/response-routes/SKILL.md +8 -0
- package/skills/route/SKILL.md +24 -0
- package/skills/streams-and-websockets/SKILL.md +283 -0
- package/skills/typesafety/SKILL.md +3 -1
- package/src/browser/app-shell.ts +52 -0
- package/src/browser/navigation-bridge.ts +71 -5
- package/src/browser/navigation-client.ts +64 -13
- package/src/browser/navigation-store.ts +25 -1
- package/src/browser/partial-update.ts +34 -3
- package/src/browser/prefetch/cache.ts +129 -21
- package/src/browser/prefetch/fetch.ts +148 -16
- package/src/browser/prefetch/queue.ts +36 -5
- package/src/browser/rango-state.ts +53 -13
- package/src/browser/react/Link.tsx +30 -2
- package/src/browser/react/NavigationProvider.tsx +50 -11
- package/src/browser/react/use-navigation.ts +22 -2
- package/src/browser/react/use-params.ts +11 -1
- package/src/browser/react/use-router.ts +8 -1
- package/src/browser/rsc-router.tsx +34 -6
- package/src/browser/segment-reconciler.ts +36 -14
- package/src/browser/types.ts +13 -0
- package/src/build/route-trie.ts +50 -24
- package/src/cache/cf/cf-cache-store.ts +5 -7
- package/src/client.tsx +82 -174
- package/src/index.rsc.ts +3 -0
- package/src/index.ts +40 -9
- package/src/outlet-context.ts +1 -1
- package/src/response-utils.ts +28 -0
- package/src/reverse.ts +7 -3
- package/src/route-definition/dsl-helpers.ts +175 -23
- package/src/route-definition/helpers-types.ts +63 -14
- package/src/route-definition/resolve-handler-use.ts +6 -0
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +24 -4
- package/src/router/lazy-includes.ts +6 -6
- package/src/router/loader-resolution.ts +3 -0
- package/src/router/manifest.ts +22 -13
- package/src/router/match-api.ts +3 -3
- package/src/router/middleware-types.ts +2 -22
- package/src/router/middleware.ts +54 -7
- package/src/router/pattern-matching.ts +60 -9
- package/src/router/revalidation.ts +15 -1
- package/src/router/segment-resolution/revalidation.ts +63 -58
- package/src/router/trie-matching.ts +10 -4
- package/src/router/url-params.ts +49 -0
- package/src/router.ts +1 -2
- package/src/rsc/handler.ts +8 -4
- package/src/rsc/helpers.ts +69 -41
- package/src/rsc/progressive-enhancement.ts +2 -0
- package/src/rsc/response-route-handler.ts +14 -1
- package/src/rsc/rsc-rendering.ts +7 -0
- package/src/rsc/server-action.ts +2 -0
- package/src/segment-content-promise.ts +67 -0
- package/src/segment-loader-promise.ts +122 -0
- package/src/segment-system.tsx +11 -61
- package/src/server/context.ts +26 -3
- package/src/server/request-context.ts +10 -42
- package/src/types/handler-context.ts +12 -39
- package/src/types/loader-types.ts +5 -6
- package/src/types/request-scope.ts +126 -0
- package/src/types/route-entry.ts +11 -0
- package/src/types/segments.ts +0 -1
- package/src/urls/include-helper.ts +24 -14
- package/src/urls/path-helper-types.ts +30 -4
- package/src/urls/response-types.ts +2 -10
- package/src/vite/debug.ts +184 -0
- package/src/vite/discovery/discover-routers.ts +31 -3
- package/src/vite/discovery/gate-state.ts +171 -0
- package/src/vite/discovery/prerender-collection.ts +48 -1
- package/src/vite/discovery/self-gen-tracking.ts +27 -1
- package/src/vite/plugins/cjs-to-esm.ts +5 -0
- package/src/vite/plugins/client-ref-dedup.ts +16 -0
- package/src/vite/plugins/client-ref-hashing.ts +16 -4
- package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
- package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
- package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
- package/src/vite/plugins/expose-action-id.ts +52 -28
- package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
- package/src/vite/plugins/expose-internal-ids.ts +516 -486
- package/src/vite/plugins/performance-tracks.ts +17 -9
- package/src/vite/plugins/use-cache-transform.ts +56 -43
- package/src/vite/plugins/version-injector.ts +37 -11
- package/src/vite/rango.ts +49 -14
- package/src/vite/router-discovery.ts +558 -53
- package/src/vite/utils/banner.ts +1 -1
- package/src/vite/utils/package-resolution.ts +41 -1
- package/src/vite/utils/prerender-utils.ts +20 -6
|
@@ -13,6 +13,10 @@ import type { RouterInstance, RouterNavigateOptions } from "../types.js";
|
|
|
13
13
|
* useRouter() do not re-render on navigation state changes.
|
|
14
14
|
* For reactive navigation state, use useNavigation() instead.
|
|
15
15
|
*
|
|
16
|
+
* Methods read `basename` from the live context on each call so that
|
|
17
|
+
* cross-app navigation (app-switch) sees the current app's basename
|
|
18
|
+
* rather than the one captured at mount time.
|
|
19
|
+
*
|
|
16
20
|
* @example
|
|
17
21
|
* ```tsx
|
|
18
22
|
* const router = useRouter();
|
|
@@ -29,7 +33,10 @@ export function useRouter(): RouterInstance {
|
|
|
29
33
|
throw new Error("useRouter must be used within NavigationProvider");
|
|
30
34
|
}
|
|
31
35
|
|
|
32
|
-
// Stable reference: ctx is
|
|
36
|
+
// Stable reference: ctx itself is stable, and reads on each method call
|
|
37
|
+
// pick up live basename values from the context (backed by a live ref
|
|
38
|
+
// in NavigationProvider), so app-switch transitions are reflected without
|
|
39
|
+
// recreating this object.
|
|
33
40
|
return useMemo<RouterInstance>(() => {
|
|
34
41
|
/** Prefix a root-relative path with basename if not already prefixed. */
|
|
35
42
|
function withBasename(url: string): string {
|
|
@@ -28,6 +28,7 @@ import {
|
|
|
28
28
|
isInterceptSegment,
|
|
29
29
|
splitInterceptSegments,
|
|
30
30
|
} from "./intercept-utils.js";
|
|
31
|
+
import { createAppShellRef } from "./app-shell.js";
|
|
31
32
|
|
|
32
33
|
// Vite HMR types are provided by vite/client
|
|
33
34
|
|
|
@@ -114,6 +115,13 @@ export interface BrowserAppContext {
|
|
|
114
115
|
warmupEnabled?: boolean;
|
|
115
116
|
/** App version for prefetch version mismatch detection */
|
|
116
117
|
version?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Live app-shell ref. Cross-app navigations replace its contents so the
|
|
120
|
+
* NavigationProvider and renderSegments pick up the target app's
|
|
121
|
+
* rootLayout, basename, and version without consumer rerenders. Theme,
|
|
122
|
+
* warmup, and prefetch TTL are document-lifetime (see AppShell).
|
|
123
|
+
*/
|
|
124
|
+
appShellRef?: import("./app-shell.js").AppShellRef;
|
|
117
125
|
}
|
|
118
126
|
|
|
119
127
|
// Module-level state for the initialized app
|
|
@@ -204,13 +212,23 @@ export async function initBrowserApp(
|
|
|
204
212
|
// Create composable utilities
|
|
205
213
|
const client = createNavigationClient(deps);
|
|
206
214
|
|
|
207
|
-
//
|
|
208
|
-
|
|
215
|
+
// Capture the per-router app-shell so cross-app navigations can replace
|
|
216
|
+
// it atomically. rootLayout, basename, and version live here and are
|
|
217
|
+
// read through the ref at call time rather than closed over. Theme,
|
|
218
|
+
// warmup, and prefetch TTL are deliberately excluded — they are
|
|
219
|
+
// document-lifetime and stay stable across smooth cross-app transitions.
|
|
209
220
|
const version = initialPayload.metadata?.version;
|
|
221
|
+
const appShellRef = createAppShellRef({
|
|
222
|
+
routerId: initialPayload.metadata?.routerId,
|
|
223
|
+
rootLayout: initialPayload.metadata?.rootLayout,
|
|
224
|
+
basename: initialPayload.metadata?.basename,
|
|
225
|
+
version,
|
|
226
|
+
});
|
|
210
227
|
|
|
211
228
|
// Initialize the localStorage state key for cache invalidation.
|
|
212
|
-
//
|
|
213
|
-
|
|
229
|
+
// The build version busts cached prefetches on deploy; the routerId
|
|
230
|
+
// namespaces the key so sibling apps on the same origin don't collide.
|
|
231
|
+
initRangoState(version ?? "0", initialPayload.metadata?.routerId);
|
|
214
232
|
setAppVersion(version);
|
|
215
233
|
|
|
216
234
|
// Initialize the in-memory prefetch cache TTL from server config.
|
|
@@ -220,11 +238,17 @@ export async function initBrowserApp(
|
|
|
220
238
|
initPrefetchCache(prefetchCacheTTL);
|
|
221
239
|
}
|
|
222
240
|
|
|
223
|
-
// Create a bound renderSegments that
|
|
241
|
+
// Create a bound renderSegments that reads rootLayout through the shell
|
|
242
|
+
// ref. On app switch the ref is updated before the tree re-renders, so
|
|
243
|
+
// the new app's Document (rootLayout) replaces the previous one.
|
|
224
244
|
const renderSegments = (
|
|
225
245
|
segments: ResolvedSegment[],
|
|
226
246
|
options?: RenderSegmentsOptions,
|
|
227
|
-
) =>
|
|
247
|
+
) =>
|
|
248
|
+
baseRenderSegments(segments, {
|
|
249
|
+
...options,
|
|
250
|
+
rootLayout: appShellRef.get().rootLayout,
|
|
251
|
+
});
|
|
228
252
|
|
|
229
253
|
// Lazy reference for navigation bridge — the action bridge is created first
|
|
230
254
|
// but may need to trigger SPA navigation for action redirects.
|
|
@@ -256,6 +280,7 @@ export async function initBrowserApp(
|
|
|
256
280
|
onUpdate: (update) => store.emitUpdate(update),
|
|
257
281
|
renderSegments,
|
|
258
282
|
version: version,
|
|
283
|
+
appShellRef,
|
|
259
284
|
});
|
|
260
285
|
|
|
261
286
|
// Connect action redirect → navigation bridge (now that both are initialized)
|
|
@@ -416,6 +441,7 @@ export async function initBrowserApp(
|
|
|
416
441
|
initialTheme: effectiveInitialTheme,
|
|
417
442
|
warmupEnabled: initialPayload.metadata?.warmupEnabled ?? true,
|
|
418
443
|
version,
|
|
444
|
+
appShellRef,
|
|
419
445
|
};
|
|
420
446
|
browserAppContext = context;
|
|
421
447
|
|
|
@@ -481,6 +507,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
481
507
|
initialTheme,
|
|
482
508
|
warmupEnabled,
|
|
483
509
|
version,
|
|
510
|
+
appShellRef,
|
|
484
511
|
} = getBrowserAppContext();
|
|
485
512
|
|
|
486
513
|
// Signal that the React tree has hydrated. useEffect only fires after
|
|
@@ -501,6 +528,7 @@ export function RSCRouter(_props: RSCRouterProps): React.ReactElement {
|
|
|
501
528
|
warmupEnabled={warmupEnabled}
|
|
502
529
|
version={version}
|
|
503
530
|
basename={initialPayload.metadata?.basename}
|
|
531
|
+
appShellRef={appShellRef}
|
|
504
532
|
/>
|
|
505
533
|
);
|
|
506
534
|
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
} from "./merge-segment-loaders.js";
|
|
7
7
|
import { assertSegmentStructure } from "./segment-structure-assert.js";
|
|
8
8
|
import { splitInterceptSegments } from "./intercept-utils.js";
|
|
9
|
+
import { debugLog } from "./logging.js";
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* Determines the merging behavior for segment reconciliation.
|
|
@@ -85,14 +86,29 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
85
86
|
const cachedSegments = new Map<string, ResolvedSegment>();
|
|
86
87
|
input.cachedSegments.forEach((s) => cachedSegments.set(s.id, s));
|
|
87
88
|
|
|
89
|
+
const diffSet = new Set(diff);
|
|
90
|
+
debugLog(
|
|
91
|
+
`[reconcile] actor=${actor}, matched=${matched.length}, diff=${diff.length}`,
|
|
92
|
+
);
|
|
93
|
+
debugLog(
|
|
94
|
+
`[reconcile] server segments: ${[...serverSegments.keys()].join(", ")}`,
|
|
95
|
+
);
|
|
96
|
+
debugLog(
|
|
97
|
+
`[reconcile] cached segments: ${[...cachedSegments.keys()].join(", ")}`,
|
|
98
|
+
);
|
|
99
|
+
|
|
88
100
|
const segments = matched
|
|
89
101
|
.map((segId: string) => {
|
|
90
102
|
const fromServer = serverSegments.get(segId);
|
|
91
103
|
const fromCache = cachedSegments.get(segId);
|
|
92
104
|
|
|
93
105
|
if (fromServer) {
|
|
106
|
+
const inDiff = diffSet.has(segId);
|
|
94
107
|
// Merge partial loader data when server returns fewer loaders than cached
|
|
95
108
|
if (shouldMergeLoaders && needsLoaderMerge(fromServer, fromCache)) {
|
|
109
|
+
debugLog(
|
|
110
|
+
`[reconcile] ${segId}: MERGE loaders (server partial, ${inDiff ? "in diff" : "not in diff"})`,
|
|
111
|
+
);
|
|
96
112
|
return mergeSegmentLoaders(fromServer, fromCache);
|
|
97
113
|
}
|
|
98
114
|
|
|
@@ -143,8 +159,14 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
143
159
|
// above fails to preserve a value it should have.
|
|
144
160
|
assertSegmentStructure(fromCache, merged, context);
|
|
145
161
|
|
|
162
|
+
debugLog(
|
|
163
|
+
`[reconcile] ${segId}: SERVER+CACHE merge (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, component=${fromServer.component === null ? "null→cached" : "server"})`,
|
|
164
|
+
);
|
|
146
165
|
return merged;
|
|
147
166
|
}
|
|
167
|
+
debugLog(
|
|
168
|
+
`[reconcile] ${segId}: SERVER only (${inDiff ? "in diff" : "not in diff"}, type=${fromServer.type}, no cache entry)`,
|
|
169
|
+
);
|
|
148
170
|
return fromServer;
|
|
149
171
|
}
|
|
150
172
|
|
|
@@ -158,20 +180,20 @@ export function reconcileSegments(input: ReconcileInput): ReconcileResult {
|
|
|
158
180
|
return fromCache;
|
|
159
181
|
}
|
|
160
182
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
183
|
+
debugLog(
|
|
184
|
+
`[reconcile] ${segId}: CACHE only (not from server, type=${fromCache.type}, component=${fromCache.component != null ? "yes" : "null"})`,
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Return the cached segment as-is, regardless of actor. We used to clear
|
|
188
|
+
// truthy `loading` here to prevent a stale Suspense fallback from
|
|
189
|
+
// committing against cached content, but that swapped the render tree
|
|
190
|
+
// from the LoaderBoundary branch to the plain OutletProvider branch
|
|
191
|
+
// inside renderSegments, causing React to unmount the entire chain
|
|
192
|
+
// (LoaderBoundary > Suspense > LoaderResolver > RouteContentWrapper >
|
|
193
|
+
// Suspender) every time the user opened an intercept or navigated back
|
|
194
|
+
// to a cached page. The flicker is now prevented by renderSegments'
|
|
195
|
+
// promise memoization keeping React's use() in "known fulfilled" state,
|
|
196
|
+
// so preserving `loading` keeps the element tree stable.
|
|
175
197
|
return fromCache;
|
|
176
198
|
})
|
|
177
199
|
.filter(Boolean) as ResolvedSegment[];
|
package/src/browser/types.ts
CHANGED
|
@@ -427,6 +427,12 @@ export interface NavigationStore {
|
|
|
427
427
|
markCacheAsStale(): void;
|
|
428
428
|
markCacheAsStaleAndBroadcast(): void;
|
|
429
429
|
clearHistoryCache(): void;
|
|
430
|
+
/**
|
|
431
|
+
* Clear this tab's nav + prefetch caches without broadcasting or rotating
|
|
432
|
+
* shared state. Intended for app-switch transitions that affect only this
|
|
433
|
+
* tab's session.
|
|
434
|
+
*/
|
|
435
|
+
clearHistoryCacheLocal(): void;
|
|
430
436
|
broadcastCacheInvalidation(): void;
|
|
431
437
|
|
|
432
438
|
// Cross-tab refresh callback (set by navigation bridge)
|
|
@@ -542,6 +548,13 @@ export interface NavigationBridge {
|
|
|
542
548
|
registerLinkInterception(): () => void;
|
|
543
549
|
/** Update the RSC version (e.g. after HMR). Clears prefetch cache. */
|
|
544
550
|
updateVersion(newVersion: string): void;
|
|
551
|
+
/**
|
|
552
|
+
* Replace the active app-shell snapshot (rootLayout, basename, version)
|
|
553
|
+
* atomically. Used on cross-app navigations when the response's routerId
|
|
554
|
+
* indicates the user entered a different app. Theme, warmup, and prefetch
|
|
555
|
+
* TTL are document-lifetime and not part of the shell.
|
|
556
|
+
*/
|
|
557
|
+
updateAppShell(next: import("./app-shell.js").AppShell): void;
|
|
545
558
|
}
|
|
546
559
|
|
|
547
560
|
/**
|
package/src/build/route-trie.ts
CHANGED
|
@@ -98,8 +98,14 @@ export function buildRouteTrie(
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
/**
|
|
101
|
-
* Insert a route into the trie
|
|
102
|
-
*
|
|
101
|
+
* Insert a route into the trie. Optional params expand into two branches at
|
|
102
|
+
* registration time (skip-first, then present), so each terminal lives at the
|
|
103
|
+
* correct depth for its number of bound params and carries a branch-local
|
|
104
|
+
* `pa` listing only those names. The trie's single-slot `node.p` is reused
|
|
105
|
+
* across branches because matching ignores `node.p.n` — the leaf's `pa` is
|
|
106
|
+
* the source of truth for naming. Skip-first ordering lets `mergeLeaf`'s
|
|
107
|
+
* last-wins rule produce greedy-leftmost semantics for free at any shared
|
|
108
|
+
* terminal depth.
|
|
103
109
|
*/
|
|
104
110
|
function insertRoute(
|
|
105
111
|
node: TrieNode,
|
|
@@ -107,14 +113,13 @@ function insertRoute(
|
|
|
107
113
|
index: number,
|
|
108
114
|
leaf: Omit<TrieLeaf, "op" | "cv" | "pa">,
|
|
109
115
|
): void {
|
|
110
|
-
//
|
|
111
|
-
|
|
116
|
+
// op (full optional list) and cv (full constraint map) are route-level and
|
|
117
|
+
// identical on every terminal, so compute them once on the shared base.
|
|
112
118
|
const optionalParams: string[] = [];
|
|
113
119
|
const constraints: Record<string, string[]> = {};
|
|
114
120
|
|
|
115
121
|
for (const seg of segments) {
|
|
116
122
|
if (seg.type === "param") {
|
|
117
|
-
paramNames.push(seg.value);
|
|
118
123
|
if (seg.optional) {
|
|
119
124
|
optionalParams.push(seg.value);
|
|
120
125
|
}
|
|
@@ -124,21 +129,15 @@ function insertRoute(
|
|
|
124
129
|
}
|
|
125
130
|
}
|
|
126
131
|
|
|
127
|
-
const
|
|
132
|
+
const leafBase: Omit<TrieLeaf, "pa"> = {
|
|
128
133
|
...leaf,
|
|
129
|
-
...(paramNames.length > 0 ? { pa: paramNames } : {}),
|
|
130
134
|
...(optionalParams.length > 0 ? { op: optionalParams } : {}),
|
|
131
135
|
...(Object.keys(constraints).length > 0 ? { cv: constraints } : {}),
|
|
132
136
|
};
|
|
133
137
|
|
|
134
|
-
insertSegments(node, segments, index,
|
|
138
|
+
insertSegments(node, segments, index, leafBase, []);
|
|
135
139
|
}
|
|
136
140
|
|
|
137
|
-
/**
|
|
138
|
-
* Recursively insert segments into the trie.
|
|
139
|
-
* For optional params, we add a terminal at the current node (param absent)
|
|
140
|
-
* AND continue inserting into the param child (param present).
|
|
141
|
-
*/
|
|
142
141
|
/**
|
|
143
142
|
* Extract ancestry map from a built trie by visiting all leaf nodes.
|
|
144
143
|
* Returns { routeName: ancestryShortCodes[] } for every route in the trie.
|
|
@@ -218,15 +217,25 @@ function mergeLeaf(node: TrieNode, leaf: TrieLeaf): void {
|
|
|
218
217
|
node.r = mergeLeaves(node.r, leaf);
|
|
219
218
|
}
|
|
220
219
|
|
|
220
|
+
function buildLeaf(
|
|
221
|
+
leafBase: Omit<TrieLeaf, "pa">,
|
|
222
|
+
paramNames: string[],
|
|
223
|
+
): TrieLeaf {
|
|
224
|
+
return paramNames.length > 0
|
|
225
|
+
? { ...leafBase, pa: [...paramNames] }
|
|
226
|
+
: { ...leafBase };
|
|
227
|
+
}
|
|
228
|
+
|
|
221
229
|
function insertSegments(
|
|
222
230
|
node: TrieNode,
|
|
223
231
|
segments: ParsedSegment[],
|
|
224
232
|
index: number,
|
|
225
|
-
|
|
233
|
+
leafBase: Omit<TrieLeaf, "pa">,
|
|
234
|
+
paramNames: string[],
|
|
226
235
|
): void {
|
|
227
|
-
// Base case: all segments consumed, add terminal
|
|
236
|
+
// Base case: all segments consumed, add terminal with branch-local pa
|
|
228
237
|
if (index >= segments.length) {
|
|
229
|
-
mergeLeaf(node,
|
|
238
|
+
mergeLeaf(node, buildLeaf(leafBase, paramNames));
|
|
230
239
|
return;
|
|
231
240
|
}
|
|
232
241
|
|
|
@@ -235,12 +244,19 @@ function insertSegments(
|
|
|
235
244
|
if (segment.type === "static") {
|
|
236
245
|
if (!node.s) node.s = {};
|
|
237
246
|
if (!node.s[segment.value]) node.s[segment.value] = {};
|
|
238
|
-
insertSegments(
|
|
247
|
+
insertSegments(
|
|
248
|
+
node.s[segment.value],
|
|
249
|
+
segments,
|
|
250
|
+
index + 1,
|
|
251
|
+
leafBase,
|
|
252
|
+
paramNames,
|
|
253
|
+
);
|
|
239
254
|
} else if (segment.type === "param") {
|
|
240
255
|
if (segment.optional) {
|
|
241
|
-
//
|
|
242
|
-
|
|
243
|
-
//
|
|
256
|
+
// SKIP first: continue at the same node without binding this name.
|
|
257
|
+
// Skip-first ordering means the present-branch's TAKE overwrites any
|
|
258
|
+
// shared terminal later, giving greedy-leftmost semantics.
|
|
259
|
+
insertSegments(node, segments, index + 1, leafBase, paramNames);
|
|
244
260
|
}
|
|
245
261
|
if (segment.suffix) {
|
|
246
262
|
// Suffix param: keyed by suffix string (e.g., ".html")
|
|
@@ -248,16 +264,26 @@ function insertSegments(
|
|
|
248
264
|
if (!node.xp[segment.suffix]) {
|
|
249
265
|
node.xp[segment.suffix] = { n: segment.value, c: {} };
|
|
250
266
|
}
|
|
251
|
-
insertSegments(node.xp[segment.suffix].c, segments, index + 1,
|
|
267
|
+
insertSegments(node.xp[segment.suffix].c, segments, index + 1, leafBase, [
|
|
268
|
+
...paramNames,
|
|
269
|
+
segment.value,
|
|
270
|
+
]);
|
|
252
271
|
} else {
|
|
253
272
|
if (!node.p) {
|
|
254
273
|
node.p = { n: segment.value, c: {} };
|
|
255
274
|
}
|
|
256
|
-
insertSegments(node.p.c, segments, index + 1,
|
|
275
|
+
insertSegments(node.p.c, segments, index + 1, leafBase, [
|
|
276
|
+
...paramNames,
|
|
277
|
+
segment.value,
|
|
278
|
+
]);
|
|
257
279
|
}
|
|
258
280
|
} else if (segment.type === "wildcard") {
|
|
259
|
-
// Wildcard consumes all remaining segments
|
|
260
|
-
|
|
281
|
+
// Wildcard consumes all remaining segments. Carry any params bound before
|
|
282
|
+
// the wildcard in pa so they zip correctly against paramValues at match.
|
|
283
|
+
const wildLeaf: TrieLeaf & { pn: string } = {
|
|
284
|
+
...buildLeaf(leafBase, paramNames),
|
|
285
|
+
pn: "*",
|
|
286
|
+
};
|
|
261
287
|
const existing = node.w ? ({ ...node.w } as TrieLeaf) : undefined;
|
|
262
288
|
const merged = mergeLeaves(existing, wildLeaf);
|
|
263
289
|
node.w = merged as TrieLeaf & { pn: string };
|
|
@@ -67,13 +67,11 @@ export const MAX_REVALIDATION_INTERVAL = 30;
|
|
|
67
67
|
// Types
|
|
68
68
|
// ============================================================================
|
|
69
69
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
export
|
|
74
|
-
|
|
75
|
-
passThroughOnException(): void;
|
|
76
|
-
}
|
|
70
|
+
// Re-exported from the canonical home so cf-cache-store consumers keep
|
|
71
|
+
// importing `ExecutionContext` from this module without a second interface
|
|
72
|
+
// drifting over time.
|
|
73
|
+
export type { ExecutionContext } from "../../types/request-scope.js";
|
|
74
|
+
import type { ExecutionContext } from "../../types/request-scope.js";
|
|
77
75
|
|
|
78
76
|
/**
|
|
79
77
|
* Minimal Cloudflare KV Namespace interface.
|
package/src/client.tsx
CHANGED
|
@@ -21,6 +21,83 @@ import {
|
|
|
21
21
|
} from "./route-content-wrapper.js";
|
|
22
22
|
import { OutletProvider } from "./outlet-provider.js";
|
|
23
23
|
import { MountContextProvider } from "./browser/react/mount-context.js";
|
|
24
|
+
import { getMemoizedContentPromise } from "./segment-content-promise.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Render the content for a named parallel/intercept slot segment.
|
|
28
|
+
*
|
|
29
|
+
* Shared by Outlet (with `name` prop) and ParallelOutlet — both resolve a
|
|
30
|
+
* segment from context.parallel by slot name and then render it through the
|
|
31
|
+
* same layout/loader/mountPath wrapping pipeline.
|
|
32
|
+
*/
|
|
33
|
+
function renderSlotContent(segment: ResolvedSegment | null): ReactNode {
|
|
34
|
+
if (!segment) return null;
|
|
35
|
+
|
|
36
|
+
const content: ReactNode =
|
|
37
|
+
segment.loading || segment.component instanceof Promise ? (
|
|
38
|
+
<RouteContentWrapper
|
|
39
|
+
content={getMemoizedContentPromise(segment.component)}
|
|
40
|
+
fallback={segment.loading}
|
|
41
|
+
segmentId={segment.id}
|
|
42
|
+
/>
|
|
43
|
+
) : (
|
|
44
|
+
(segment.component ?? null)
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
const hasOwnLoaders = !!(segment.loaderDataPromise && segment.loaderIds);
|
|
48
|
+
const loaderWrapped = hasOwnLoaders ? (
|
|
49
|
+
<LoaderBoundary
|
|
50
|
+
loaderDataPromise={segment.loaderDataPromise!}
|
|
51
|
+
loaderIds={segment.loaderIds!}
|
|
52
|
+
fallback={segment.loading}
|
|
53
|
+
outletKey={segment.id + "-loader"}
|
|
54
|
+
outletContent={null}
|
|
55
|
+
segment={segment}
|
|
56
|
+
>
|
|
57
|
+
{content}
|
|
58
|
+
</LoaderBoundary>
|
|
59
|
+
) : null;
|
|
60
|
+
|
|
61
|
+
let result: ReactNode;
|
|
62
|
+
if (segment.layout) {
|
|
63
|
+
// Layout renders immediately; if loaders exist, the LoaderBoundary becomes
|
|
64
|
+
// the outlet content so layout's <Outlet /> suspends until loaders resolve.
|
|
65
|
+
result = (
|
|
66
|
+
<OutletProvider
|
|
67
|
+
content={hasOwnLoaders ? loaderWrapped : content}
|
|
68
|
+
segment={segment}
|
|
69
|
+
>
|
|
70
|
+
{segment.layout}
|
|
71
|
+
</OutletProvider>
|
|
72
|
+
);
|
|
73
|
+
} else if (hasOwnLoaders) {
|
|
74
|
+
// No layout but has loaders — wrap content with LoaderBoundary for useLoader context.
|
|
75
|
+
// Common for intercept routes that use useLoader without a custom layout.
|
|
76
|
+
result = loaderWrapped;
|
|
77
|
+
} else {
|
|
78
|
+
result = content;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (segment.mountPath) {
|
|
82
|
+
return (
|
|
83
|
+
<MountContextProvider value={segment.mountPath}>
|
|
84
|
+
{result}
|
|
85
|
+
</MountContextProvider>
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return result;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function useSlotSegment(
|
|
93
|
+
context: OutletContextValue | null,
|
|
94
|
+
name: `@${string}` | undefined,
|
|
95
|
+
): ResolvedSegment | null {
|
|
96
|
+
return useMemo(() => {
|
|
97
|
+
if (!name || !context?.parallel) return null;
|
|
98
|
+
return context.parallel.find((seg) => seg.slot === name) ?? null;
|
|
99
|
+
}, [context, name]);
|
|
100
|
+
}
|
|
24
101
|
|
|
25
102
|
/**
|
|
26
103
|
* Outlet component - renders child content in layouts
|
|
@@ -61,95 +138,10 @@ import { MountContextProvider } from "./browser/react/mount-context.js";
|
|
|
61
138
|
*/
|
|
62
139
|
export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
|
|
63
140
|
const context = useContext(OutletContext);
|
|
141
|
+
const namedSegment = useSlotSegment(context, name);
|
|
64
142
|
|
|
65
|
-
// If name provided, render parallel/intercept content for that slot
|
|
66
143
|
if (name) {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (!segment) return null;
|
|
70
|
-
|
|
71
|
-
// Determine the content to render
|
|
72
|
-
let content: ReactNode;
|
|
73
|
-
if (segment.loading || segment.component instanceof Promise) {
|
|
74
|
-
// Use RouteContentWrapper to handle Suspense wrapping properly
|
|
75
|
-
content = (
|
|
76
|
-
<RouteContentWrapper
|
|
77
|
-
content={
|
|
78
|
-
segment.component instanceof Promise
|
|
79
|
-
? segment.component
|
|
80
|
-
: Promise.resolve(segment.component)
|
|
81
|
-
}
|
|
82
|
-
fallback={segment.loading}
|
|
83
|
-
segmentId={segment.id}
|
|
84
|
-
/>
|
|
85
|
-
);
|
|
86
|
-
} else {
|
|
87
|
-
content = segment.component ?? null;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
let result: ReactNode;
|
|
91
|
-
|
|
92
|
-
// If segment has a layout, wrap appropriately
|
|
93
|
-
if (segment.layout) {
|
|
94
|
-
// Check if this segment has loaders that need streaming
|
|
95
|
-
// The layout renders immediately, LoaderBoundary becomes the outlet content
|
|
96
|
-
// When layout renders <Outlet />, it gets the LoaderBoundary which suspends
|
|
97
|
-
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
98
|
-
const loaderAwareContent = (
|
|
99
|
-
<LoaderBoundary
|
|
100
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
101
|
-
loaderIds={segment.loaderIds}
|
|
102
|
-
fallback={segment.loading}
|
|
103
|
-
outletKey={segment.id + "-loader"}
|
|
104
|
-
outletContent={null}
|
|
105
|
-
segment={segment}
|
|
106
|
-
>
|
|
107
|
-
{content}
|
|
108
|
-
</LoaderBoundary>
|
|
109
|
-
);
|
|
110
|
-
|
|
111
|
-
result = (
|
|
112
|
-
<OutletProvider content={loaderAwareContent} segment={segment}>
|
|
113
|
-
{segment.layout}
|
|
114
|
-
</OutletProvider>
|
|
115
|
-
);
|
|
116
|
-
} else {
|
|
117
|
-
// No loaders - wrap in OutletProvider so layout can use <Outlet />
|
|
118
|
-
result = (
|
|
119
|
-
<OutletProvider content={content} segment={segment}>
|
|
120
|
-
{segment.layout}
|
|
121
|
-
</OutletProvider>
|
|
122
|
-
);
|
|
123
|
-
}
|
|
124
|
-
} else if (segment.loaderDataPromise && segment.loaderIds) {
|
|
125
|
-
// No layout but has loaders - wrap content with LoaderBoundary for useLoader context
|
|
126
|
-
// This is common for intercept routes that use useLoader without a custom layout
|
|
127
|
-
result = (
|
|
128
|
-
<LoaderBoundary
|
|
129
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
130
|
-
loaderIds={segment.loaderIds}
|
|
131
|
-
fallback={segment.loading}
|
|
132
|
-
outletKey={segment.id + "-loader"}
|
|
133
|
-
outletContent={null}
|
|
134
|
-
segment={segment}
|
|
135
|
-
>
|
|
136
|
-
{content}
|
|
137
|
-
</LoaderBoundary>
|
|
138
|
-
);
|
|
139
|
-
} else {
|
|
140
|
-
result = content;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// Wrap with MountContextProvider for include() scoped parallel/intercept slots
|
|
144
|
-
if (segment.mountPath) {
|
|
145
|
-
return (
|
|
146
|
-
<MountContextProvider value={segment.mountPath}>
|
|
147
|
-
{result}
|
|
148
|
-
</MountContextProvider>
|
|
149
|
-
);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
return result;
|
|
144
|
+
return renderSlotContent(namedSegment);
|
|
153
145
|
}
|
|
154
146
|
|
|
155
147
|
// Default: render child content
|
|
@@ -163,6 +155,7 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
|
|
|
163
155
|
|
|
164
156
|
return content;
|
|
165
157
|
}
|
|
158
|
+
|
|
166
159
|
/**
|
|
167
160
|
* ParallelOutlet component - renders content for a named parallel slot
|
|
168
161
|
*
|
|
@@ -187,94 +180,9 @@ export function Outlet({ name }: { name?: `@${string}` } = {}): ReactNode {
|
|
|
187
180
|
*/
|
|
188
181
|
export function ParallelOutlet({ name }: { name: `@${string}` }): ReactNode {
|
|
189
182
|
const context = useContext(OutletContext);
|
|
190
|
-
const segment =
|
|
191
|
-
if (!context?.parallel) return null;
|
|
192
|
-
return context.parallel.find((seg) => seg.slot === name) ?? null;
|
|
193
|
-
}, [context, name]);
|
|
194
|
-
|
|
195
|
-
if (!segment) return null;
|
|
196
|
-
|
|
197
|
-
// Determine the content to render
|
|
198
|
-
let content: ReactNode;
|
|
199
|
-
if (segment.loading || segment.component instanceof Promise) {
|
|
200
|
-
// Use RouteContentWrapper to handle Suspense wrapping properly
|
|
201
|
-
content = (
|
|
202
|
-
<RouteContentWrapper
|
|
203
|
-
content={
|
|
204
|
-
segment.component instanceof Promise
|
|
205
|
-
? segment.component
|
|
206
|
-
: Promise.resolve(segment.component)
|
|
207
|
-
}
|
|
208
|
-
fallback={segment.loading}
|
|
209
|
-
segmentId={segment.id}
|
|
210
|
-
/>
|
|
211
|
-
);
|
|
212
|
-
} else {
|
|
213
|
-
content = segment.component ?? null;
|
|
214
|
-
}
|
|
183
|
+
const segment = useSlotSegment(context, name);
|
|
215
184
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
// If segment has a layout, wrap appropriately
|
|
219
|
-
if (segment.layout) {
|
|
220
|
-
// Check if this segment has loaders that need streaming
|
|
221
|
-
// The layout renders immediately, LoaderBoundary becomes the outlet content
|
|
222
|
-
if (segment.loaderDataPromise && segment.loaderIds) {
|
|
223
|
-
const loaderAwareContent = (
|
|
224
|
-
<LoaderBoundary
|
|
225
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
226
|
-
loaderIds={segment.loaderIds}
|
|
227
|
-
fallback={segment.loading}
|
|
228
|
-
outletKey={segment.id + "-loader"}
|
|
229
|
-
outletContent={null}
|
|
230
|
-
segment={segment}
|
|
231
|
-
>
|
|
232
|
-
{content}
|
|
233
|
-
</LoaderBoundary>
|
|
234
|
-
);
|
|
235
|
-
|
|
236
|
-
result = (
|
|
237
|
-
<OutletProvider content={loaderAwareContent} segment={segment}>
|
|
238
|
-
{segment.layout}
|
|
239
|
-
</OutletProvider>
|
|
240
|
-
);
|
|
241
|
-
} else {
|
|
242
|
-
// No loaders - wrap in OutletProvider so layout can use <Outlet />
|
|
243
|
-
result = (
|
|
244
|
-
<OutletProvider content={content} segment={segment}>
|
|
245
|
-
{segment.layout}
|
|
246
|
-
</OutletProvider>
|
|
247
|
-
);
|
|
248
|
-
}
|
|
249
|
-
} else if (segment.loaderDataPromise && segment.loaderIds) {
|
|
250
|
-
// No layout but has loaders - wrap content with LoaderBoundary for useLoader context
|
|
251
|
-
// This is common for intercept routes that use useLoader without a custom layout
|
|
252
|
-
result = (
|
|
253
|
-
<LoaderBoundary
|
|
254
|
-
loaderDataPromise={segment.loaderDataPromise}
|
|
255
|
-
loaderIds={segment.loaderIds}
|
|
256
|
-
fallback={segment.loading}
|
|
257
|
-
outletKey={segment.id + "-loader"}
|
|
258
|
-
outletContent={null}
|
|
259
|
-
segment={segment}
|
|
260
|
-
>
|
|
261
|
-
{content}
|
|
262
|
-
</LoaderBoundary>
|
|
263
|
-
);
|
|
264
|
-
} else {
|
|
265
|
-
result = content;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Wrap with MountContextProvider for include() scoped parallel/intercept slots
|
|
269
|
-
if (segment.mountPath) {
|
|
270
|
-
return (
|
|
271
|
-
<MountContextProvider value={segment.mountPath}>
|
|
272
|
-
{result}
|
|
273
|
-
</MountContextProvider>
|
|
274
|
-
);
|
|
275
|
-
}
|
|
276
|
-
|
|
277
|
-
return result;
|
|
185
|
+
return renderSlotContent(segment);
|
|
278
186
|
}
|
|
279
187
|
|
|
280
188
|
// OutletProvider is defined in outlet-provider.tsx to break a circular
|