@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.
Files changed (37) hide show
  1. package/dist/vite/index.js +16 -8
  2. package/package.json +3 -3
  3. package/skills/handler-use/SKILL.md +362 -0
  4. package/skills/intercept/SKILL.md +20 -0
  5. package/skills/layout/SKILL.md +22 -0
  6. package/skills/middleware/SKILL.md +32 -3
  7. package/skills/migrate-nextjs/SKILL.md +560 -0
  8. package/skills/migrate-react-router/SKILL.md +764 -0
  9. package/skills/parallel/SKILL.md +59 -0
  10. package/skills/rango/SKILL.md +24 -22
  11. package/skills/route/SKILL.md +24 -0
  12. package/src/browser/navigation-bridge.ts +19 -2
  13. package/src/browser/navigation-client.ts +34 -6
  14. package/src/browser/partial-update.ts +14 -2
  15. package/src/browser/prefetch/cache.ts +16 -6
  16. package/src/browser/prefetch/fetch.ts +60 -4
  17. package/src/browser/react/Link.tsx +25 -2
  18. package/src/browser/segment-reconciler.ts +36 -14
  19. package/src/build/route-trie.ts +50 -24
  20. package/src/client.tsx +82 -174
  21. package/src/index.ts +37 -9
  22. package/src/reverse.ts +4 -1
  23. package/src/route-definition/dsl-helpers.ts +159 -20
  24. package/src/route-definition/helpers-types.ts +57 -13
  25. package/src/route-types.ts +7 -0
  26. package/src/router/handler-context.ts +4 -1
  27. package/src/router/lazy-includes.ts +5 -5
  28. package/src/router/manifest.ts +12 -7
  29. package/src/segment-content-promise.ts +67 -0
  30. package/src/segment-loader-promise.ts +122 -0
  31. package/src/segment-system.tsx +11 -61
  32. package/src/server/context.ts +26 -3
  33. package/src/types/route-entry.ts +11 -0
  34. package/src/types/segments.ts +0 -1
  35. package/src/urls/include-helper.ts +24 -14
  36. package/src/urls/path-helper-types.ts +30 -4
  37. 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"] = (...fn) => {
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
- // Checked before context validation — this is a static invariant.
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 ctx = getContext().getStore();
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
- // Attach to last entry in stack
374
- const parent = ctx.parent;
375
- if (!parent || !("middleware" in parent)) {
376
- invariant(false, "No parent entry available for middleware()");
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
- const name = `$${getContext().getNextIndex("middleware")}`;
379
- parent.middleware.push(...fn);
380
- return { name, type: "middleware" } as MiddlewareItem;
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 any static handler definitions in parallel slots
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, slotHandler] of Object.entries(
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: handler.use defaults first, then explicit use second.
475
- // This matches the "defaults first, overrides second" rule used by
476
- // path(), layout(), and intercept(). Each slot's handler.use is
477
- // scoped to its own entry (no cross-slot bleed).
478
- const slotHandler = (slots as Record<string, any>)[slotName];
479
- const slotHandlerUse = resolveHandlerUse(slotHandler);
480
- const slotMergedUse = mergeHandlerUse(slotHandlerUse, use, "parallel");
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<`@${string}`, Handler<any, any, TEnv> | ReactNode>,
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
- * middleware(async (ctx, next) => {
188
- * const session = await getSession(ctx.request);
189
- * if (!session) return redirect("/login");
190
- * ctx.set("user", session.user);
191
- * next();
192
- * })
213
+ * layout(<DashboardShell />, () => [
214
+ * middleware(authMiddleware),
215
+ * middleware([authMiddleware, loggingMiddleware]),
216
+ * path("/", DashboardPage),
217
+ * ])
218
+ * ```
193
219
  *
194
- * // Chain multiple middleware
195
- * middleware(authMiddleware, loggingMiddleware, rateLimitMiddleware)
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: (...fns: MiddlewareFn<TEnv>[]) => MiddlewareItem;
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
@@ -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
- if (value === undefined) {
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 && (lazyContext as any).counters) {
129
- const captured = (lazyContext as any).counters as Record<string, number>;
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: (lazyContext as any)?.cacheProfiles,
145
- rootScoped: (lazyContext as any)?.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
@@ -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 && (lazyContext as any).counters) {
130
- const captured = (lazyContext as any).counters as Record<string, number>;
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 && (lazyContext as any).rootScoped !== undefined) {
149
- Store.rootScoped = (lazyContext as any).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
+ }