@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dcbea258
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 +19 -2
- package/src/browser/navigation-client.ts +34 -6
- package/src/browser/partial-update.ts +14 -2
- package/src/browser/prefetch/cache.ts +16 -6
- package/src/browser/prefetch/fetch.ts +60 -4
- package/src/browser/react/Link.tsx +25 -2
- package/src/browser/segment-reconciler.ts +36 -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 +12 -7
- 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
|
@@ -55,6 +55,9 @@ const hasRoutesInItem = (item: AllUseItems): boolean => {
|
|
|
55
55
|
if (item.type === "layout" && item.uses) {
|
|
56
56
|
return item.uses.some((child) => hasRoutesInItem(child));
|
|
57
57
|
}
|
|
58
|
+
if (item.type === "middleware" && item.uses) {
|
|
59
|
+
return item.uses.some((child) => hasRoutesInItem(child));
|
|
60
|
+
}
|
|
58
61
|
return false;
|
|
59
62
|
};
|
|
60
63
|
|
|
@@ -353,10 +356,37 @@ const cache: RouteHelpers<any, any>["cache"] = (
|
|
|
353
356
|
return { name: namespace, type: "cache", uses: result } as CacheItem;
|
|
354
357
|
};
|
|
355
358
|
|
|
356
|
-
const middleware: RouteHelpers<any, any>["middleware"] = (...
|
|
359
|
+
const middleware: RouteHelpers<any, any>["middleware"] = (...args: any[]) => {
|
|
360
|
+
// Four call forms:
|
|
361
|
+
// middleware(fn) — single fn, sibling
|
|
362
|
+
// middleware(fn, () => [...]) — single fn, wrapping
|
|
363
|
+
// middleware([fn1, fn2]) — array, sibling
|
|
364
|
+
// middleware([fn1, fn2], () => [...]) — array, wrapping
|
|
365
|
+
const isArray = Array.isArray(args[0]);
|
|
366
|
+
|
|
367
|
+
// Reject the removed variadic form before executing anything.
|
|
368
|
+
// middleware(fn1, fn2, fn3) — 3+ args, always wrong.
|
|
369
|
+
// middleware(fn1, fn2) where fn2 is a middleware fn (length >= 1), not a
|
|
370
|
+
// children callback (length === 0) — legacy two-fn form, reject early.
|
|
371
|
+
if (
|
|
372
|
+
args.length > 2 ||
|
|
373
|
+
(!isArray &&
|
|
374
|
+
args.length === 2 &&
|
|
375
|
+
typeof args[1] === "function" &&
|
|
376
|
+
args[1].length > 0)
|
|
377
|
+
) {
|
|
378
|
+
throw new Error(
|
|
379
|
+
"middleware() no longer accepts variadic arguments. " +
|
|
380
|
+
"Use middleware([fn1, fn2, ...]) instead of middleware(fn1, fn2, ...).",
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const fns: MiddlewareFn<any>[] = isArray ? args[0] : [args[0]];
|
|
385
|
+
const children: (() => any[]) | undefined =
|
|
386
|
+
typeof args[1] === "function" ? args[1] : undefined;
|
|
387
|
+
|
|
357
388
|
// Prevent "use cache" functions from being used as middleware.
|
|
358
|
-
|
|
359
|
-
for (const f of fn) {
|
|
389
|
+
for (const f of fns) {
|
|
360
390
|
if (isCachedFunction(f)) {
|
|
361
391
|
throw new Error(
|
|
362
392
|
`A "use cache" function cannot be used as middleware. ` +
|
|
@@ -367,17 +397,80 @@ const middleware: RouteHelpers<any, any>["middleware"] = (...fn) => {
|
|
|
367
397
|
}
|
|
368
398
|
}
|
|
369
399
|
|
|
370
|
-
const
|
|
400
|
+
const store = getContext();
|
|
401
|
+
const ctx = store.getStore();
|
|
371
402
|
if (!ctx) throw new Error("middleware() must be called inside map()");
|
|
372
403
|
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
404
|
+
if (!children) {
|
|
405
|
+
// Sibling mode: attach to parent entry
|
|
406
|
+
const parent = ctx.parent;
|
|
407
|
+
if (!parent || !("middleware" in parent)) {
|
|
408
|
+
invariant(false, "No parent entry available for middleware()");
|
|
409
|
+
}
|
|
410
|
+
const name = `$${store.getNextIndex("middleware")}`;
|
|
411
|
+
parent.middleware.push(...fns);
|
|
412
|
+
return { name, type: "middleware" } as MiddlewareItem;
|
|
377
413
|
}
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
414
|
+
|
|
415
|
+
// Wrapping mode: create a transparent layout that carries the middleware
|
|
416
|
+
const mwIndex = store.getNextIndex("middleware");
|
|
417
|
+
const namespace = `${ctx.namespace}.${mwIndex}`;
|
|
418
|
+
|
|
419
|
+
const urlPrefix = getUrlPrefix();
|
|
420
|
+
const entry = {
|
|
421
|
+
id: namespace,
|
|
422
|
+
shortCode: store.getShortCode("layout"),
|
|
423
|
+
type: "layout",
|
|
424
|
+
parent: ctx.parent,
|
|
425
|
+
handler: RootLayout,
|
|
426
|
+
loading: undefined,
|
|
427
|
+
middleware: [...fns],
|
|
428
|
+
revalidate: [],
|
|
429
|
+
errorBoundary: [],
|
|
430
|
+
notFoundBoundary: [],
|
|
431
|
+
layout: [],
|
|
432
|
+
parallel: {},
|
|
433
|
+
intercept: [],
|
|
434
|
+
loader: [],
|
|
435
|
+
...(urlPrefix ? { mountPath: urlPrefix } : {}),
|
|
436
|
+
} as EntryData;
|
|
437
|
+
|
|
438
|
+
// Run children callback. If the second arg was actually a middleware fn
|
|
439
|
+
// (old variadic form: middleware(mw1, mw2)), this will return a non-array
|
|
440
|
+
// and the invariant below gives a clear migration error.
|
|
441
|
+
const rawResult = store.run(namespace, entry, children);
|
|
442
|
+
|
|
443
|
+
invariant(
|
|
444
|
+
Array.isArray(rawResult),
|
|
445
|
+
"middleware(fn, children) expects the second argument to return an array of use items. " +
|
|
446
|
+
"To pass multiple middleware, use middleware([fn1, fn2]).",
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const result = rawResult.flat(3);
|
|
450
|
+
|
|
451
|
+
invariant(
|
|
452
|
+
result.every((item: any) => isValidUseItem(item)),
|
|
453
|
+
`middleware() children callback must return an array of use items [${namespace}]`,
|
|
454
|
+
);
|
|
455
|
+
|
|
456
|
+
const hasRoutes =
|
|
457
|
+
result &&
|
|
458
|
+
Array.isArray(result) &&
|
|
459
|
+
result.some((item) => item != null && hasRoutesInItem(item));
|
|
460
|
+
|
|
461
|
+
if (!hasRoutes) {
|
|
462
|
+
const parent = ctx.parent;
|
|
463
|
+
if (parent && "layout" in parent) {
|
|
464
|
+
entry.parent = null;
|
|
465
|
+
parent.layout.push(entry);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
name: namespace,
|
|
471
|
+
type: "middleware",
|
|
472
|
+
uses: result,
|
|
473
|
+
} as MiddlewareItem;
|
|
381
474
|
};
|
|
382
475
|
|
|
383
476
|
const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
@@ -398,13 +491,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
|
398
491
|
|
|
399
492
|
const namespace = `${ctx.namespace}.$${store.getNextIndex("parallel")}`;
|
|
400
493
|
|
|
401
|
-
// Unwrap
|
|
494
|
+
// Unwrap slot values. A slot value can be:
|
|
495
|
+
// - a Handler / ReactNode (legacy form)
|
|
496
|
+
// - a Static() definition (build-time only)
|
|
497
|
+
// - a slot descriptor `{ handler, use? }` for slot-local overrides
|
|
498
|
+
// The descriptor's `use` runs after the broadcast `use` for that slot,
|
|
499
|
+
// so single-assignment items like `loading()` placed there win without
|
|
500
|
+
// affecting siblings.
|
|
402
501
|
const unwrappedSlots: Record<string, any> = {};
|
|
502
|
+
const slotLocalUses: Record<string, (() => any[]) | undefined> = {};
|
|
403
503
|
let hasStaticSlot = false;
|
|
404
504
|
const staticSlotIds: Record<string, string> = {};
|
|
405
|
-
for (const [slotName,
|
|
505
|
+
for (const [slotName, rawSlot] of Object.entries(
|
|
406
506
|
slots as Record<string, any>,
|
|
407
507
|
)) {
|
|
508
|
+
let slotHandler: any = rawSlot;
|
|
509
|
+
if (isSlotDescriptor(rawSlot)) {
|
|
510
|
+
slotHandler = rawSlot.handler;
|
|
511
|
+
slotLocalUses[slotName] = rawSlot.use;
|
|
512
|
+
}
|
|
408
513
|
if (isStaticHandler(slotHandler)) {
|
|
409
514
|
hasStaticSlot = true;
|
|
410
515
|
unwrappedSlots[slotName] = slotHandler.handler;
|
|
@@ -471,13 +576,25 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
|
471
576
|
}),
|
|
472
577
|
} satisfies EntryData;
|
|
473
578
|
|
|
474
|
-
// Per-slot
|
|
475
|
-
//
|
|
476
|
-
//
|
|
477
|
-
//
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
579
|
+
// Per-slot merge order (narrowest-scope-wins for single-assignment items
|
|
580
|
+
// like loading()):
|
|
581
|
+
// 1. handler.use — defaults baked into the handler
|
|
582
|
+
// 2. shared `use` — broadcast at the parallel() call site
|
|
583
|
+
// 3. slot-local `use` — per-slot override via `{ handler, use }` descriptor
|
|
584
|
+
// Items that accumulate (loader, middleware, revalidate, …) compose
|
|
585
|
+
// across all three layers regardless of order.
|
|
586
|
+
const rawSlot = (slots as Record<string, any>)[slotName];
|
|
587
|
+
const slotHandlerForUse = isSlotDescriptor(rawSlot)
|
|
588
|
+
? rawSlot.handler
|
|
589
|
+
: rawSlot;
|
|
590
|
+
const slotHandlerUse = resolveHandlerUse(slotHandlerForUse);
|
|
591
|
+
const slotLocalUse = slotLocalUses[slotName];
|
|
592
|
+
const explicitUse = combineExplicitUses(use, slotLocalUse);
|
|
593
|
+
const slotMergedUse = mergeHandlerUse(
|
|
594
|
+
slotHandlerUse,
|
|
595
|
+
explicitUse,
|
|
596
|
+
"parallel",
|
|
597
|
+
);
|
|
481
598
|
if (slotMergedUse) {
|
|
482
599
|
const result = store.run(namespace, slotEntry, slotMergedUse)?.flat(3);
|
|
483
600
|
invariant(
|
|
@@ -491,6 +608,28 @@ const parallel: RouteHelpers<any, any>["parallel"] = (slots, use) => {
|
|
|
491
608
|
return { name: namespace, type: "parallel" } as ParallelItem;
|
|
492
609
|
};
|
|
493
610
|
|
|
611
|
+
function isSlotDescriptor(
|
|
612
|
+
value: unknown,
|
|
613
|
+
): value is { handler: unknown; use?: () => any[] } {
|
|
614
|
+
return (
|
|
615
|
+
typeof value === "object" &&
|
|
616
|
+
value !== null &&
|
|
617
|
+
!("__brand" in value) &&
|
|
618
|
+
"handler" in value &&
|
|
619
|
+
typeof (value as any).handler !== "undefined"
|
|
620
|
+
);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function combineExplicitUses(
|
|
624
|
+
sharedUse: (() => any[]) | undefined,
|
|
625
|
+
slotLocalUse: (() => any[]) | undefined,
|
|
626
|
+
): (() => any[]) | undefined {
|
|
627
|
+
if (!sharedUse && !slotLocalUse) return undefined;
|
|
628
|
+
if (!slotLocalUse) return sharedUse;
|
|
629
|
+
if (!sharedUse) return slotLocalUse;
|
|
630
|
+
return () => [...sharedUse(), ...slotLocalUse()];
|
|
631
|
+
}
|
|
632
|
+
|
|
494
633
|
/**
|
|
495
634
|
* Intercept helper - defines an intercepting route for soft navigation
|
|
496
635
|
*/
|
|
@@ -123,7 +123,7 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
123
123
|
* "@main": async (ctx) => <MainContent data={ctx.use(DataLoader)} />,
|
|
124
124
|
* })
|
|
125
125
|
*
|
|
126
|
-
* // With loaders and loading states
|
|
126
|
+
* // With loaders and loading states (broadcast to every slot)
|
|
127
127
|
* parallel({
|
|
128
128
|
* "@analytics": AnalyticsPanel,
|
|
129
129
|
* "@metrics": MetricsPanel,
|
|
@@ -131,12 +131,36 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
131
131
|
* loader(DashboardLoader),
|
|
132
132
|
* loading(<DashboardSkeleton />),
|
|
133
133
|
* ])
|
|
134
|
+
*
|
|
135
|
+
* // Per-slot scoped use via slot descriptor — for single-assignment items
|
|
136
|
+
* // like loading() that should not broadcast to siblings.
|
|
137
|
+
* parallel({
|
|
138
|
+
* "@meta": MetaSlot,
|
|
139
|
+
* "@sidebar": {
|
|
140
|
+
* handler: SidebarSlot,
|
|
141
|
+
* use: () => [loading(<SidebarSkeleton />)],
|
|
142
|
+
* },
|
|
143
|
+
* })
|
|
134
144
|
* ```
|
|
135
145
|
* @param slots - Object with slot names (prefixed with @) mapped to handlers
|
|
146
|
+
* or `{ handler, use? }` slot descriptors.
|
|
136
147
|
* @param use - Optional callback for loaders, loading, revalidate, etc.
|
|
148
|
+
* Items here apply to every slot in the call (broadcast).
|
|
149
|
+
* For per-slot single-assignment items, use the slot descriptor's
|
|
150
|
+
* own `use` callback — slot-local items run after the broadcast,
|
|
151
|
+
* so they take precedence on `loading()` and other last-write-wins
|
|
152
|
+
* fields.
|
|
137
153
|
*/
|
|
138
154
|
parallel: <
|
|
139
|
-
TSlots extends Record
|
|
155
|
+
TSlots extends Record<
|
|
156
|
+
`@${string}`,
|
|
157
|
+
| Handler<any, any, TEnv>
|
|
158
|
+
| ReactNode
|
|
159
|
+
| {
|
|
160
|
+
handler: Handler<any, any, TEnv> | ReactNode;
|
|
161
|
+
use?: () => UseItems<ParallelUseItem>;
|
|
162
|
+
}
|
|
163
|
+
>,
|
|
140
164
|
>(
|
|
141
165
|
slots: TSlots,
|
|
142
166
|
use?: () => UseItems<ParallelUseItem>,
|
|
@@ -182,21 +206,41 @@ export type RouteHelpers<T extends RouteDefinition, TEnv> = {
|
|
|
182
206
|
): InterceptItem;
|
|
183
207
|
};
|
|
184
208
|
/**
|
|
185
|
-
* Attach middleware to the current route/layout
|
|
209
|
+
* Attach middleware to the current route/layout, or wrap child segments
|
|
210
|
+
*
|
|
211
|
+
* **Sibling mode** — attaches middleware to the parent entry:
|
|
186
212
|
* ```typescript
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
191
|
-
*
|
|
192
|
-
*
|
|
213
|
+
* layout(<DashboardShell />, () => [
|
|
214
|
+
* middleware(authMiddleware),
|
|
215
|
+
* middleware([authMiddleware, loggingMiddleware]),
|
|
216
|
+
* path("/", DashboardPage),
|
|
217
|
+
* ])
|
|
218
|
+
* ```
|
|
193
219
|
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
220
|
+
* **Wrapping mode** — scopes middleware to the children only:
|
|
221
|
+
* ```typescript
|
|
222
|
+
* middleware(authMiddleware, () => [
|
|
223
|
+
* path("/dashboard", DashboardPage),
|
|
224
|
+
* path("/settings", SettingsPage),
|
|
225
|
+
* ])
|
|
226
|
+
*
|
|
227
|
+
* middleware([authMiddleware, loggingMiddleware], () => [
|
|
228
|
+
* path("/admin", AdminPage),
|
|
229
|
+
* ])
|
|
196
230
|
* ```
|
|
197
|
-
* @param fns - One or more middleware functions to execute in order
|
|
198
231
|
*/
|
|
199
|
-
middleware:
|
|
232
|
+
middleware: {
|
|
233
|
+
(fn: MiddlewareFn<TEnv>): MiddlewareItem;
|
|
234
|
+
(
|
|
235
|
+
fn: MiddlewareFn<TEnv>,
|
|
236
|
+
children: () => UseItems<LayoutUseItem>,
|
|
237
|
+
): MiddlewareItem;
|
|
238
|
+
(fns: MiddlewareFn<TEnv>[]): MiddlewareItem;
|
|
239
|
+
(
|
|
240
|
+
fns: MiddlewareFn<TEnv>[],
|
|
241
|
+
children: () => UseItems<LayoutUseItem>,
|
|
242
|
+
): MiddlewareItem;
|
|
243
|
+
};
|
|
200
244
|
/**
|
|
201
245
|
* Control when a segment should revalidate during navigation
|
|
202
246
|
* ```typescript
|
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,9 +126,8 @@ 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
|
}
|
|
@@ -136,8 +135,7 @@ export async function loadManifest(
|
|
|
136
135
|
// Propagate cache profiles for DSL-time cache("profileName") resolution.
|
|
137
136
|
// Non-lazy entries carry profiles directly; lazy entries carry them
|
|
138
137
|
// in the captured lazyContext from include() time.
|
|
139
|
-
const entryProfiles =
|
|
140
|
-
entry.cacheProfiles ?? (lazyContext as any)?.cacheProfiles;
|
|
138
|
+
const entryProfiles = entry.cacheProfiles ?? lazyContext?.cacheProfiles;
|
|
141
139
|
if (entryProfiles) {
|
|
142
140
|
Store.cacheProfiles = entryProfiles;
|
|
143
141
|
}
|
|
@@ -145,8 +143,15 @@ export async function loadManifest(
|
|
|
145
143
|
// Propagate rootScoped from lazyContext so that routes inside
|
|
146
144
|
// nested { name: "sub" } under { name: "" } keep inherited root scope
|
|
147
145
|
// when the manifest is rebuilt on each request.
|
|
148
|
-
if (lazyContext
|
|
149
|
-
Store.rootScoped =
|
|
146
|
+
if (lazyContext?.rootScoped !== undefined) {
|
|
147
|
+
Store.rootScoped = lazyContext.rootScoped;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Propagate includeScope from lazyContext so that direct-descendant
|
|
151
|
+
// shortCodes of this include use the correct scoped counter namespace
|
|
152
|
+
// on every manifest rebuild.
|
|
153
|
+
if (lazyContext?.includeScope !== undefined) {
|
|
154
|
+
Store.includeScope = lazyContext.includeScope;
|
|
150
155
|
}
|
|
151
156
|
|
|
152
157
|
const handlerExecStart = performance.now();
|
|
@@ -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
|
+
}
|