@rangojs/router 0.0.0-experimental.dfdb0387 → 0.0.0-experimental.ede38110
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/vite/index.js +16 -8
- package/package.json +3 -3
- package/skills/handler-use/SKILL.md +362 -0
- package/skills/intercept/SKILL.md +20 -0
- package/skills/layout/SKILL.md +22 -0
- package/skills/middleware/SKILL.md +32 -3
- package/skills/migrate-nextjs/SKILL.md +560 -0
- package/skills/migrate-react-router/SKILL.md +764 -0
- package/skills/parallel/SKILL.md +59 -0
- package/skills/rango/SKILL.md +24 -22
- package/skills/route/SKILL.md +24 -0
- package/src/browser/navigation-bridge.ts +21 -2
- package/src/browser/partial-update.ts +9 -2
- package/src/browser/prefetch/fetch.ts +20 -4
- package/src/browser/react/use-navigation.ts +11 -10
- package/src/browser/segment-reconciler.ts +10 -14
- package/src/build/route-trie.ts +50 -24
- package/src/client.tsx +82 -174
- package/src/index.ts +37 -9
- package/src/reverse.ts +4 -1
- package/src/route-definition/dsl-helpers.ts +159 -20
- package/src/route-definition/helpers-types.ts +57 -13
- package/src/route-types.ts +7 -0
- package/src/router/handler-context.ts +4 -1
- package/src/router/lazy-includes.ts +5 -5
- package/src/router/manifest.ts +22 -13
- package/src/router/match-middleware/cache-lookup.ts +2 -1
- 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/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/vite/utils/prerender-utils.ts +20 -6
package/src/route-types.ts
CHANGED
|
@@ -176,6 +176,13 @@ export type IncludeItem = {
|
|
|
176
176
|
>;
|
|
177
177
|
/** Root scope flag for dot-local reverse resolution */
|
|
178
178
|
rootScoped?: boolean;
|
|
179
|
+
/**
|
|
180
|
+
* Positional include scope token composed from the parent scope plus this
|
|
181
|
+
* include's sibling index (`${parentScope}I${idx}`). Applied to direct-
|
|
182
|
+
* descendant shortCodes during lazy evaluation so routes inside the
|
|
183
|
+
* include cannot collide with siblings declared outside it.
|
|
184
|
+
*/
|
|
185
|
+
includeScope?: string;
|
|
179
186
|
};
|
|
180
187
|
[IncludeBrand]: void;
|
|
181
188
|
};
|
|
@@ -174,7 +174,10 @@ export function createReverseFunction(
|
|
|
174
174
|
/:([a-zA-Z_][a-zA-Z0-9_]*)(\([^)]*\))?(\?)/g,
|
|
175
175
|
(_, key) => {
|
|
176
176
|
const value = effectiveParams[key];
|
|
177
|
-
|
|
177
|
+
// Empty string is treated as omitted — the trie matcher fills
|
|
178
|
+
// unmatched optional params with "" (not undefined), so reverse
|
|
179
|
+
// must collapse those segments instead of leaving empty slots.
|
|
180
|
+
if (value === undefined || value === "") {
|
|
178
181
|
hadOmittedOptional = true;
|
|
179
182
|
return "";
|
|
180
183
|
}
|
|
@@ -125,9 +125,8 @@ export function evaluateLazyEntry<TEnv = any>(
|
|
|
125
125
|
// Merge captured counters from include() to maintain consistent
|
|
126
126
|
// shortCode indices with sibling entries from pattern extraction
|
|
127
127
|
const lazyCounters: Record<string, number> = {};
|
|
128
|
-
if (lazyContext
|
|
129
|
-
const
|
|
130
|
-
for (const [key, value] of Object.entries(captured)) {
|
|
128
|
+
if (lazyContext?.counters) {
|
|
129
|
+
for (const [key, value] of Object.entries(lazyContext.counters)) {
|
|
131
130
|
lazyCounters[key] = value;
|
|
132
131
|
}
|
|
133
132
|
}
|
|
@@ -141,8 +140,9 @@ export function evaluateLazyEntry<TEnv = any>(
|
|
|
141
140
|
namespace: "lazy",
|
|
142
141
|
parent: getIsolatedLazyParent(lazyContext?.parent as EntryData | null),
|
|
143
142
|
counters: lazyCounters,
|
|
144
|
-
cacheProfiles:
|
|
145
|
-
rootScoped:
|
|
143
|
+
cacheProfiles: lazyContext?.cacheProfiles,
|
|
144
|
+
rootScoped: lazyContext?.rootScoped,
|
|
145
|
+
includeScope: lazyContext?.includeScope,
|
|
146
146
|
},
|
|
147
147
|
() => {
|
|
148
148
|
// Run the lazy patterns handler with the original context prefixes
|
package/src/router/manifest.ts
CHANGED
|
@@ -126,28 +126,37 @@ export async function loadManifest(
|
|
|
126
126
|
// were created during pattern extraction. This prevents shortCode
|
|
127
127
|
// collisions between lazy and non-lazy entries under the same parent
|
|
128
128
|
// (e.g., ArticlesLayout and BlogLayout both under NavLayout).
|
|
129
|
-
if (lazyContext
|
|
130
|
-
const
|
|
131
|
-
for (const [key, value] of Object.entries(captured)) {
|
|
129
|
+
if (lazyContext?.counters) {
|
|
130
|
+
for (const [key, value] of Object.entries(lazyContext.counters)) {
|
|
132
131
|
Store.counters[key] = Math.max(Store.counters[key] ?? 0, value);
|
|
133
132
|
}
|
|
134
133
|
}
|
|
135
134
|
|
|
136
135
|
// Propagate cache profiles for DSL-time cache("profileName") resolution.
|
|
137
136
|
// Non-lazy entries carry profiles directly; lazy entries carry them
|
|
138
|
-
// in the captured lazyContext from include() time.
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
137
|
+
// in the captured lazyContext from include() time. Always write
|
|
138
|
+
// (including clearing to undefined) so a prior lazy build's profile
|
|
139
|
+
// map cannot leak into a later non-lazy build on the same ALS-backed
|
|
140
|
+
// Store — which would otherwise let cache("name") resolve a profile
|
|
141
|
+
// from an unrelated entry.
|
|
142
|
+
Store.cacheProfiles = entry.cacheProfiles ?? lazyContext?.cacheProfiles;
|
|
144
143
|
|
|
145
144
|
// Propagate rootScoped from lazyContext so that routes inside
|
|
146
145
|
// nested { name: "sub" } under { name: "" } keep inherited root scope
|
|
147
|
-
// when the manifest is rebuilt on each request.
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
146
|
+
// when the manifest is rebuilt on each request. Always write
|
|
147
|
+
// (including clearing to undefined, which makes getRootScoped()
|
|
148
|
+
// return its true default) so a prior lazy build's scope cannot leak
|
|
149
|
+
// into a later non-lazy build on the same ALS-backed Store — which
|
|
150
|
+
// would otherwise mis-register plain routes as non-root-scoped and
|
|
151
|
+
// break dot-local reverse resolution.
|
|
152
|
+
Store.rootScoped = lazyContext?.rootScoped;
|
|
153
|
+
|
|
154
|
+
// Propagate includeScope from lazyContext so that direct-descendant
|
|
155
|
+
// shortCodes of this include use the correct scoped counter namespace
|
|
156
|
+
// on every manifest rebuild. Always write (including clearing to
|
|
157
|
+
// undefined) so a prior lazy build's scope cannot leak into a later
|
|
158
|
+
// non-lazy build on the same ALS-backed Store.
|
|
159
|
+
Store.includeScope = lazyContext?.includeScope;
|
|
151
160
|
|
|
152
161
|
const handlerExecStart = performance.now();
|
|
153
162
|
const useItems = await getContext().runWithStore(
|
|
@@ -251,6 +251,7 @@ async function* yieldFromStore<TEnv>(
|
|
|
251
251
|
ctx.url,
|
|
252
252
|
ctx.routeKey,
|
|
253
253
|
ctx.actionContext,
|
|
254
|
+
ctx.stale || undefined,
|
|
254
255
|
),
|
|
255
256
|
);
|
|
256
257
|
state.matchedIds = [
|
|
@@ -598,7 +599,7 @@ export function withCacheLookup<TEnv>(
|
|
|
598
599
|
routeKey: ctx.routeKey,
|
|
599
600
|
context: ctx.handlerContext,
|
|
600
601
|
actionContext: ctx.actionContext,
|
|
601
|
-
stale: cacheResult.shouldRevalidate || undefined,
|
|
602
|
+
stale: cacheResult.shouldRevalidate || ctx.stale || undefined,
|
|
602
603
|
traceSource: "cache-hit",
|
|
603
604
|
});
|
|
604
605
|
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Stable Promise wrappers keyed on the component itself. Objects (React
|
|
5
|
+
* elements, functions, lazy payloads) land in a WeakMap so entries GC when
|
|
6
|
+
* the underlying component is released; primitives (string, number, boolean,
|
|
7
|
+
* null) land in a Map so memoization still applies to text-/null-backed
|
|
8
|
+
* segments like those in partial-update flows. Keeping this cache outside
|
|
9
|
+
* the segment eliminates preservation fields on ResolvedSegment — it survives
|
|
10
|
+
* reconciliation naturally because the component ref is what's stable.
|
|
11
|
+
*
|
|
12
|
+
* Browser-only. On the server each SSR render needs a fresh pending promise
|
|
13
|
+
* so Suspense can emit the loading fallback HTML before content streams. A
|
|
14
|
+
* shared already-resolved promise has `.status === "fulfilled"` attached by
|
|
15
|
+
* React on its first observation — subsequent `use()` calls return
|
|
16
|
+
* synchronously without suspending, so the Suspense fallback never makes it
|
|
17
|
+
* into the initial HTML. Route-definition components share refs across
|
|
18
|
+
* requests, so a global cache would leak tracked state between renders.
|
|
19
|
+
*/
|
|
20
|
+
const IS_BROWSER = typeof window !== "undefined";
|
|
21
|
+
const objectContentCache = IS_BROWSER
|
|
22
|
+
? new WeakMap<object, Promise<ReactNode>>()
|
|
23
|
+
: null;
|
|
24
|
+
const primitiveContentCache = IS_BROWSER
|
|
25
|
+
? new Map<unknown, Promise<ReactNode>>()
|
|
26
|
+
: null;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Return a stable Promise wrapping `component`, memoized on the component ref.
|
|
30
|
+
*
|
|
31
|
+
* A fresh `Promise.resolve(component)` each render would suspend for one
|
|
32
|
+
* microtask and briefly commit the loading fallback inside Suspender — the
|
|
33
|
+
* intercept / parallel-slot flicker this indirection prevents. Reusing the
|
|
34
|
+
* same Promise ref keeps React's `use()` in "known fulfilled" state after
|
|
35
|
+
* the first observation.
|
|
36
|
+
*
|
|
37
|
+
* @internal
|
|
38
|
+
*/
|
|
39
|
+
export function getMemoizedContentPromise(
|
|
40
|
+
component: ReactNode,
|
|
41
|
+
): Promise<ReactNode> {
|
|
42
|
+
if (component instanceof Promise) {
|
|
43
|
+
return component as Promise<ReactNode>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (!objectContentCache || !primitiveContentCache) {
|
|
47
|
+
return Promise.resolve(component);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (component !== null && typeof component === "object") {
|
|
51
|
+
const cached = objectContentCache.get(component);
|
|
52
|
+
if (cached) {
|
|
53
|
+
return cached;
|
|
54
|
+
}
|
|
55
|
+
const promise = Promise.resolve(component);
|
|
56
|
+
objectContentCache.set(component, promise);
|
|
57
|
+
return promise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const cached = primitiveContentCache.get(component);
|
|
61
|
+
if (cached) {
|
|
62
|
+
return cached;
|
|
63
|
+
}
|
|
64
|
+
const promise = Promise.resolve(component);
|
|
65
|
+
primitiveContentCache.set(component, promise);
|
|
66
|
+
return promise;
|
|
67
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { ResolvedSegment } from "./types.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache of aggregate Promise.all results keyed on the first loader's
|
|
5
|
+
* `loaderData` reference. Each entry holds the source refs it was built from
|
|
6
|
+
* plus the resulting Promise/array; lookup scans entries for the matching
|
|
7
|
+
* source array (typically a single entry, since distinct loader groups rarely
|
|
8
|
+
* share a first source). Object first-refs live in a WeakMap (auto-GC);
|
|
9
|
+
* primitive first-refs (strings/numbers/booleans/null) live in a Map so
|
|
10
|
+
* loaders that resolve to primitive data are memoized too — bounded in
|
|
11
|
+
* practice by the application's loader set.
|
|
12
|
+
*
|
|
13
|
+
* Keying externally means reconciliation's fresh segment objects no longer
|
|
14
|
+
* drop memoization — the cache survives as long as the underlying loader
|
|
15
|
+
* segments do, and GC collects entries when those loaders are released
|
|
16
|
+
* (object keys only).
|
|
17
|
+
*
|
|
18
|
+
* Browser-only. On the server each SSR render needs a fresh Promise so
|
|
19
|
+
* Suspense can actually suspend and emit the loading fallback HTML before
|
|
20
|
+
* content streams. A shared already-resolved promise has `.status` attached
|
|
21
|
+
* by React on first `use()`; subsequent observations return synchronously
|
|
22
|
+
* and skip the fallback. The zero-loader case is especially prone because
|
|
23
|
+
* every empty-loader site would otherwise share one promise across requests.
|
|
24
|
+
*/
|
|
25
|
+
const IS_BROWSER = typeof window !== "undefined";
|
|
26
|
+
|
|
27
|
+
interface LoaderCacheEntry {
|
|
28
|
+
sources: any[];
|
|
29
|
+
promise: Promise<any[]> | any[];
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const objectLoaderCache = IS_BROWSER
|
|
33
|
+
? new WeakMap<object, LoaderCacheEntry[]>()
|
|
34
|
+
: null;
|
|
35
|
+
const primitiveLoaderCache = IS_BROWSER
|
|
36
|
+
? new Map<unknown, LoaderCacheEntry[]>()
|
|
37
|
+
: null;
|
|
38
|
+
|
|
39
|
+
// In the browser, a single shared empty aggregate is safe (and desirable) —
|
|
40
|
+
// reusing the same resolved promise keeps React's `use()` in a known-fulfilled
|
|
41
|
+
// state across renders. On the server it would leak `.status = "fulfilled"`
|
|
42
|
+
// across requests and skip the Suspense fallback, so we rebuild on each call.
|
|
43
|
+
const SHARED_EMPTY_LOADER_PROMISE: Promise<any[]> | null = IS_BROWSER
|
|
44
|
+
? Promise.resolve([])
|
|
45
|
+
: null;
|
|
46
|
+
|
|
47
|
+
function hasSameReferences(a: any[], b: any[]): boolean {
|
|
48
|
+
if (a.length !== b.length) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
for (let i = 0; i < a.length; i++) {
|
|
52
|
+
if (a[i] !== b[i]) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildLoaderPromise(loaders: ResolvedSegment[]): Promise<any[]> {
|
|
60
|
+
if (loaders.length === 0) {
|
|
61
|
+
return Promise.resolve([]);
|
|
62
|
+
}
|
|
63
|
+
return Promise.all(
|
|
64
|
+
loaders.map((loader) =>
|
|
65
|
+
loader.loaderData instanceof Promise
|
|
66
|
+
? loader.loaderData
|
|
67
|
+
: Promise.resolve(loader.loaderData),
|
|
68
|
+
),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isObjectLike(value: unknown): value is object {
|
|
73
|
+
return (
|
|
74
|
+
value !== null && (typeof value === "object" || typeof value === "function")
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Memoize an aggregate Promise.all for a set of loader segments. Reusing the
|
|
80
|
+
* same aggregate across renders — invalidated only when any underlying
|
|
81
|
+
* loader.loaderData ref changes — keeps React's `use()` in "known fulfilled"
|
|
82
|
+
* state and prevents a fresh Promise.all from suspending (and briefly
|
|
83
|
+
* committing the Suspense fallback) on every partial update that doesn't
|
|
84
|
+
* actually change loader data.
|
|
85
|
+
*
|
|
86
|
+
* @internal
|
|
87
|
+
*/
|
|
88
|
+
export function getMemoizedLoaderPromise(
|
|
89
|
+
loaders: ResolvedSegment[],
|
|
90
|
+
): Promise<any[]> | any[] {
|
|
91
|
+
if (loaders.length === 0) {
|
|
92
|
+
return SHARED_EMPTY_LOADER_PROMISE ?? buildLoaderPromise(loaders);
|
|
93
|
+
}
|
|
94
|
+
if (!objectLoaderCache || !primitiveLoaderCache) {
|
|
95
|
+
return buildLoaderPromise(loaders);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const sources = loaders.map((loader) => loader.loaderData);
|
|
99
|
+
const first = sources[0];
|
|
100
|
+
const entries = isObjectLike(first)
|
|
101
|
+
? objectLoaderCache.get(first)
|
|
102
|
+
: primitiveLoaderCache.get(first);
|
|
103
|
+
|
|
104
|
+
if (entries) {
|
|
105
|
+
for (const entry of entries) {
|
|
106
|
+
if (hasSameReferences(entry.sources, sources)) {
|
|
107
|
+
return entry.promise;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const promise = buildLoaderPromise(loaders);
|
|
113
|
+
const newEntry: LoaderCacheEntry = { sources, promise };
|
|
114
|
+
if (entries) {
|
|
115
|
+
entries.push(newEntry);
|
|
116
|
+
} else if (isObjectLike(first)) {
|
|
117
|
+
objectLoaderCache.set(first, [newEntry]);
|
|
118
|
+
} else {
|
|
119
|
+
primitiveLoaderCache.set(first, [newEntry]);
|
|
120
|
+
}
|
|
121
|
+
return promise;
|
|
122
|
+
}
|
package/src/segment-system.tsx
CHANGED
|
@@ -2,11 +2,7 @@ import * as React from "react";
|
|
|
2
2
|
import { createElement, type ReactNode, type ComponentType } from "react";
|
|
3
3
|
import { OutletProvider } from "./client.js";
|
|
4
4
|
import { MountContextProvider } from "./browser/react/mount-context.js";
|
|
5
|
-
import type {
|
|
6
|
-
ResolvedSegment,
|
|
7
|
-
LoaderDataResult,
|
|
8
|
-
RootLayoutProps,
|
|
9
|
-
} from "./types.js";
|
|
5
|
+
import type { ResolvedSegment, RootLayoutProps } from "./types.js";
|
|
10
6
|
import { isLoaderDataResult } from "./types.js";
|
|
11
7
|
import { invariant } from "./errors.js";
|
|
12
8
|
import {
|
|
@@ -14,6 +10,8 @@ import {
|
|
|
14
10
|
LoaderBoundary,
|
|
15
11
|
} from "./route-content-wrapper.js";
|
|
16
12
|
import { RootErrorBoundary } from "./root-error-boundary.js";
|
|
13
|
+
import { getMemoizedContentPromise } from "./segment-content-promise.js";
|
|
14
|
+
import { getMemoizedLoaderPromise } from "./segment-loader-promise.js";
|
|
17
15
|
|
|
18
16
|
// ViewTransition is only available in React experimental.
|
|
19
17
|
// Access via namespace import to avoid compile-time errors on stable React.
|
|
@@ -61,20 +59,6 @@ function restoreParallelLoaderMarkers(
|
|
|
61
59
|
return nextSegments ?? segments;
|
|
62
60
|
}
|
|
63
61
|
|
|
64
|
-
function hasSameReferences(a: unknown[] | undefined, b: unknown[]): boolean {
|
|
65
|
-
if (!a || a.length !== b.length) {
|
|
66
|
-
return false;
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
for (let i = 0; i < a.length; i++) {
|
|
70
|
-
if (a[i] !== b[i]) {
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
return true;
|
|
76
|
-
}
|
|
77
|
-
|
|
78
62
|
/**
|
|
79
63
|
* Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
|
|
80
64
|
*/
|
|
@@ -278,10 +262,7 @@ export async function renderSegments(
|
|
|
278
262
|
loading !== null && loading !== undefined && loading !== false
|
|
279
263
|
? createElement(RouteContentWrapper, {
|
|
280
264
|
key: `suspense-loading-${id}`,
|
|
281
|
-
content:
|
|
282
|
-
resolvedComponent instanceof Promise
|
|
283
|
-
? resolvedComponent
|
|
284
|
-
: Promise.resolve(resolvedComponent),
|
|
265
|
+
content: getMemoizedContentPromise(resolvedComponent),
|
|
285
266
|
fallback: loading,
|
|
286
267
|
segmentId: id,
|
|
287
268
|
})
|
|
@@ -305,16 +286,7 @@ export async function renderSegments(
|
|
|
305
286
|
|
|
306
287
|
// Prepare loader data if there are loaders
|
|
307
288
|
const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
|
|
308
|
-
const loaderDataPromise =
|
|
309
|
-
loaderEntries.length > 0
|
|
310
|
-
? Promise.all(
|
|
311
|
-
loaderEntries.map((loader) =>
|
|
312
|
-
loader.loaderData instanceof Promise
|
|
313
|
-
? loader.loaderData
|
|
314
|
-
: Promise.resolve(loader.loaderData),
|
|
315
|
-
),
|
|
316
|
-
)
|
|
317
|
-
: Promise.resolve([]);
|
|
289
|
+
const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
|
|
318
290
|
|
|
319
291
|
// Use LoaderBoundary when loading is defined to maintain consistent tree structure
|
|
320
292
|
// This ensures cached segments (which may not have loader segments) have the same
|
|
@@ -396,34 +368,12 @@ export async function renderSegments(
|
|
|
396
368
|
continue;
|
|
397
369
|
}
|
|
398
370
|
|
|
399
|
-
|
|
400
|
-
const
|
|
401
|
-
p.
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
hasSameReferences(p.parallelLoaderSources, parallelLoaderSources);
|
|
406
|
-
|
|
407
|
-
const parallelLoaderDataPromise = shouldReuseParallelPromise
|
|
408
|
-
? p.loaderDataPromise
|
|
409
|
-
: forceAwait || isAction
|
|
410
|
-
? await Promise.all(
|
|
411
|
-
ownedLoaders.map((l) =>
|
|
412
|
-
l.loaderData instanceof Promise
|
|
413
|
-
? l.loaderData
|
|
414
|
-
: Promise.resolve(l.loaderData),
|
|
415
|
-
),
|
|
416
|
-
)
|
|
417
|
-
: Promise.all(
|
|
418
|
-
ownedLoaders.map((l) =>
|
|
419
|
-
l.loaderData instanceof Promise
|
|
420
|
-
? l.loaderData
|
|
421
|
-
: Promise.resolve(l.loaderData),
|
|
422
|
-
),
|
|
423
|
-
);
|
|
424
|
-
|
|
425
|
-
p.loaderDataPromise = parallelLoaderDataPromise;
|
|
426
|
-
p.parallelLoaderSources = parallelLoaderSources;
|
|
371
|
+
p.loaderIds = ownedLoaders.map((l) => l.loaderId!);
|
|
372
|
+
const aggregated = getMemoizedLoaderPromise(ownedLoaders);
|
|
373
|
+
p.loaderDataPromise =
|
|
374
|
+
(forceAwait || isAction) && aggregated instanceof Promise
|
|
375
|
+
? await aggregated
|
|
376
|
+
: aggregated;
|
|
427
377
|
}
|
|
428
378
|
}
|
|
429
379
|
|
package/src/server/context.ts
CHANGED
|
@@ -280,6 +280,22 @@ interface HelperContext {
|
|
|
280
280
|
/** True when resolving handlers inside a cache() DSL boundary.
|
|
281
281
|
* Read by ctx.get() to guard non-cacheable variable reads. */
|
|
282
282
|
insideCacheScope?: boolean;
|
|
283
|
+
/**
|
|
284
|
+
* Include scope string applied to direct-descendant shortCodes.
|
|
285
|
+
*
|
|
286
|
+
* Each `include(...)` call allocates a sibling-positional token like `I0`,
|
|
287
|
+
* `I1` from its parent's include counter and stores the composed scope
|
|
288
|
+
* (`${parentScope}I${idx}`) in its lazyContext. When the include's handler
|
|
289
|
+
* evaluates lazily, the store's `includeScope` is set from that context so
|
|
290
|
+
* every direct-descendant shortCode is generated as
|
|
291
|
+
* `${parent.shortCode}${includeScope}${prefix}${index}` — preventing
|
|
292
|
+
* collisions with siblings declared outside the include.
|
|
293
|
+
*
|
|
294
|
+
* The scope is NOT propagated through `store.run(...)`, so layouts /
|
|
295
|
+
* parallels / caches inside the include absorb the scope into their own
|
|
296
|
+
* shortCodes and their children start fresh.
|
|
297
|
+
*/
|
|
298
|
+
includeScope?: string;
|
|
283
299
|
}
|
|
284
300
|
// Use a global symbol key so the AsyncLocalStorage instance survives HMR
|
|
285
301
|
// module re-evaluation. Without this, Vite's RSC module runner may create
|
|
@@ -382,6 +398,8 @@ export const getContext = (): {
|
|
|
382
398
|
const mountPrefix =
|
|
383
399
|
store.mountIndex !== undefined ? `M${store.mountIndex}` : "";
|
|
384
400
|
|
|
401
|
+
const includeScope = store.includeScope ?? "";
|
|
402
|
+
|
|
385
403
|
if (!parent) {
|
|
386
404
|
// Root entry: prefix with mount index and use mount-scoped counter
|
|
387
405
|
const counterKey = mountPrefix
|
|
@@ -392,12 +410,16 @@ export const getContext = (): {
|
|
|
392
410
|
store.counters[counterKey] = index + 1;
|
|
393
411
|
return `${mountPrefix}${prefix}${index}`;
|
|
394
412
|
} else {
|
|
395
|
-
// Child entry: use parent-scoped counter
|
|
396
|
-
|
|
413
|
+
// Child entry: use parent-scoped counter with includeScope appended.
|
|
414
|
+
// When we're evaluating a lazy include's direct children, includeScope
|
|
415
|
+
// is a per-include token like "I0" / "I1I0" that partitions the
|
|
416
|
+
// parent's counter namespace so routes inside one include cannot
|
|
417
|
+
// collide with siblings declared outside it.
|
|
418
|
+
const counterKey = `${parent.shortCode}${includeScope}_${type}`;
|
|
397
419
|
store.counters[counterKey] ??= 0;
|
|
398
420
|
const index = store.counters[counterKey];
|
|
399
421
|
store.counters[counterKey] = index + 1;
|
|
400
|
-
return `${parent.shortCode}${prefix}${index}`;
|
|
422
|
+
return `${parent.shortCode}${includeScope}${prefix}${index}`;
|
|
401
423
|
}
|
|
402
424
|
},
|
|
403
425
|
runWithStore: <T>(
|
|
@@ -424,6 +446,7 @@ export const getContext = (): {
|
|
|
424
446
|
rootScoped: store.rootScoped,
|
|
425
447
|
trackedIncludes: store.trackedIncludes,
|
|
426
448
|
cacheProfiles: store.cacheProfiles,
|
|
449
|
+
includeScope: store.includeScope,
|
|
427
450
|
},
|
|
428
451
|
callback,
|
|
429
452
|
);
|
package/src/types/route-entry.ts
CHANGED
|
@@ -8,10 +8,21 @@ export interface LazyIncludeContext {
|
|
|
8
8
|
urlPrefix: string;
|
|
9
9
|
namePrefix: string | undefined;
|
|
10
10
|
parent: unknown; // EntryData - avoid circular import
|
|
11
|
+
/** Counter snapshot from pattern extraction for consistent shortCode indices */
|
|
12
|
+
counters?: Record<string, number>;
|
|
11
13
|
cacheProfiles?: Record<
|
|
12
14
|
string,
|
|
13
15
|
import("../cache/profile-registry.js").CacheProfile
|
|
14
16
|
>;
|
|
17
|
+
/** Root scope flag for dot-local reverse resolution */
|
|
18
|
+
rootScoped?: boolean;
|
|
19
|
+
/**
|
|
20
|
+
* Positional include scope token composed from the parent scope plus this
|
|
21
|
+
* include's sibling index (`${parentScope}I${idx}`). Applied to direct-
|
|
22
|
+
* descendant shortCodes during lazy evaluation so routes inside the
|
|
23
|
+
* include cannot collide with siblings declared outside it.
|
|
24
|
+
*/
|
|
25
|
+
includeScope?: string;
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
/**
|
package/src/types/segments.ts
CHANGED
|
@@ -56,7 +56,6 @@ export interface ResolvedSegment {
|
|
|
56
56
|
// Intercept loader fields (for streaming loader data in parallel segments)
|
|
57
57
|
loaderDataPromise?: Promise<any[]> | any[]; // Loader data promise or resolved array
|
|
58
58
|
loaderIds?: string[]; // IDs ($$id) of loaders for this segment
|
|
59
|
-
parallelLoaderSources?: any[]; // Internal: preserves stable aggregate promise across renders
|
|
60
59
|
// Error-specific fields
|
|
61
60
|
error?: ErrorInfo; // For error segments: the error information
|
|
62
61
|
// NotFound-specific fields
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
runWithPrefixes,
|
|
5
5
|
getUrlPrefix,
|
|
6
6
|
getNamePrefix,
|
|
7
|
-
getRootScoped,
|
|
8
7
|
} from "../server/context";
|
|
9
8
|
import {
|
|
10
9
|
INTERNAL_INCLUDE_SCOPE_PREFIX,
|
|
@@ -149,22 +148,32 @@ export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
|
|
|
149
148
|
});
|
|
150
149
|
}
|
|
151
150
|
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
// and
|
|
161
|
-
|
|
151
|
+
// Allocate an include-scope token for this include() call. The token is
|
|
152
|
+
// appended to the parent's shortCode prefix whenever the include's
|
|
153
|
+
// direct-descendant shortCodes are generated (see getShortCode in
|
|
154
|
+
// context.ts), partitioning the parent's counter namespace so routes
|
|
155
|
+
// inside an include cannot collide with siblings declared outside it.
|
|
156
|
+
//
|
|
157
|
+
// Scopes compose: a nested include inside an outer include with scope
|
|
158
|
+
// "I0" allocates against the `${parent.shortCode}I0_include` counter
|
|
159
|
+
// and produces scope "I0I0", "I0I1", etc.
|
|
160
|
+
const parentScope = ctx.includeScope ?? "";
|
|
161
|
+
let includeScope = parentScope;
|
|
162
162
|
if (capturedParent?.shortCode) {
|
|
163
|
-
const
|
|
164
|
-
ctx.counters[
|
|
165
|
-
ctx.counters[
|
|
163
|
+
const includeCounterKey = `${capturedParent.shortCode}${parentScope}_include`;
|
|
164
|
+
ctx.counters[includeCounterKey] ??= 0;
|
|
165
|
+
const includeIdx = ctx.counters[includeCounterKey];
|
|
166
|
+
ctx.counters[includeCounterKey] = includeIdx + 1;
|
|
167
|
+
includeScope = `${parentScope}I${includeIdx}`;
|
|
166
168
|
}
|
|
167
169
|
|
|
170
|
+
// Snapshot parent's counters AFTER allocating the include scope so lazy
|
|
171
|
+
// manifest generation starts with the same counter state this include
|
|
172
|
+
// observed — its descendants still get fresh per-scope counters because
|
|
173
|
+
// they key off `${parent.shortCode}${includeScope}_*` (not shared with
|
|
174
|
+
// siblings outside the include).
|
|
175
|
+
const capturedCounters = { ...ctx.counters };
|
|
176
|
+
|
|
168
177
|
// Compute rootScoped at capture time, mirroring the logic in runWithPrefixes.
|
|
169
178
|
// This ensures lazy evaluation restores the correct scope state.
|
|
170
179
|
const parentRootScoped = ctx.rootScoped;
|
|
@@ -191,6 +200,7 @@ export function createIncludeHelper<TEnv>(): IncludeFn<TEnv> {
|
|
|
191
200
|
counters: capturedCounters,
|
|
192
201
|
cacheProfiles: ctx.cacheProfiles,
|
|
193
202
|
rootScoped: capturedRootScoped,
|
|
203
|
+
includeScope,
|
|
194
204
|
},
|
|
195
205
|
} as IncludeItem;
|
|
196
206
|
};
|
|
@@ -233,12 +233,27 @@ export type PathHelpers<TEnv> = {
|
|
|
233
233
|
include: IncludeFn<TEnv>;
|
|
234
234
|
|
|
235
235
|
/**
|
|
236
|
-
* Define parallel routes that render simultaneously in named slots
|
|
236
|
+
* Define parallel routes that render simultaneously in named slots.
|
|
237
|
+
*
|
|
238
|
+
* A slot value can be a Handler / ReactNode / StaticHandlerDefinition
|
|
239
|
+
* (legacy form, broadcast use applies to every slot) or a slot descriptor
|
|
240
|
+
* `{ handler, use? }` whose `use` is scoped to that slot only. Per-slot
|
|
241
|
+
* merge order is `handler.use` → shared `use` → slot-local `use`, with
|
|
242
|
+
* narrowest scope winning for last-write-wins items like `loading()`.
|
|
237
243
|
*/
|
|
238
244
|
parallel: <
|
|
239
245
|
TSlots extends Record<
|
|
240
246
|
`@${string}`,
|
|
241
|
-
Handler<any, any, TEnv>
|
|
247
|
+
| Handler<any, any, TEnv>
|
|
248
|
+
| ReactNode
|
|
249
|
+
| StaticHandlerDefinition
|
|
250
|
+
| {
|
|
251
|
+
handler:
|
|
252
|
+
| Handler<any, any, TEnv>
|
|
253
|
+
| ReactNode
|
|
254
|
+
| StaticHandlerDefinition;
|
|
255
|
+
use?: () => ParallelUseItem[];
|
|
256
|
+
}
|
|
242
257
|
>,
|
|
243
258
|
>(
|
|
244
259
|
slots: TSlots,
|
|
@@ -264,9 +279,20 @@ export type PathHelpers<TEnv> = {
|
|
|
264
279
|
) => InterceptItem;
|
|
265
280
|
|
|
266
281
|
/**
|
|
267
|
-
* Attach middleware to the current route/layout
|
|
282
|
+
* Attach middleware to the current route/layout, or wrap child segments
|
|
268
283
|
*/
|
|
269
|
-
middleware:
|
|
284
|
+
middleware: {
|
|
285
|
+
(fn: MiddlewareFn<TEnv>): MiddlewareItem;
|
|
286
|
+
(
|
|
287
|
+
fn: MiddlewareFn<TEnv>,
|
|
288
|
+
children: () => UseItems<LayoutUseItem>,
|
|
289
|
+
): MiddlewareItem;
|
|
290
|
+
(fns: MiddlewareFn<TEnv>[]): MiddlewareItem;
|
|
291
|
+
(
|
|
292
|
+
fns: MiddlewareFn<TEnv>[],
|
|
293
|
+
children: () => UseItems<LayoutUseItem>,
|
|
294
|
+
): MiddlewareItem;
|
|
295
|
+
};
|
|
270
296
|
|
|
271
297
|
/**
|
|
272
298
|
* Control when a segment should revalidate during navigation
|
|
@@ -41,14 +41,28 @@ export function substituteRouteParams(
|
|
|
41
41
|
let result = pattern;
|
|
42
42
|
let hadOmittedOptional = false;
|
|
43
43
|
|
|
44
|
-
// First pass: substitute provided params
|
|
44
|
+
// First pass: substitute provided params.
|
|
45
|
+
// Empty string on an optional placeholder is treated as omitted (the trie
|
|
46
|
+
// matcher fills unmatched optionals with "" — letting the second pass
|
|
47
|
+
// strip them keeps slash cleanup consistent). Empty string on required
|
|
48
|
+
// `:key` or wildcard `*key` still substitutes, matching prior behaviour.
|
|
45
49
|
for (const [key, value] of Object.entries(params)) {
|
|
46
50
|
const escaped = escapeRegExp(key);
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
if (value === "") {
|
|
52
|
+
// Only replace required placeholders (negative lookahead for `?`);
|
|
53
|
+
// leave `:key?` for the second pass.
|
|
54
|
+
result = result.replace(
|
|
55
|
+
new RegExp(`:${escaped}(\\([^)]*\\))?(?!\\?)`),
|
|
56
|
+
"",
|
|
57
|
+
);
|
|
58
|
+
result = result.replace(`*${key}`, "");
|
|
59
|
+
} else {
|
|
60
|
+
result = result.replace(
|
|
61
|
+
new RegExp(`:${escaped}(\\([^)]*\\))?\\??`),
|
|
62
|
+
encode(value),
|
|
63
|
+
);
|
|
64
|
+
result = result.replace(`*${key}`, encode(value));
|
|
65
|
+
}
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
// Second pass: strip remaining optional param placeholders not in params
|