@rangojs/router 0.0.0-experimental.1b930379 → 0.0.0-experimental.1fa245e2

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 (136) hide show
  1. package/AGENTS.md +4 -0
  2. package/README.md +76 -18
  3. package/dist/bin/rango.js +138 -50
  4. package/dist/vite/index.js +558 -319
  5. package/package.json +16 -15
  6. package/skills/cache-guide/SKILL.md +32 -0
  7. package/skills/caching/SKILL.md +45 -4
  8. package/skills/links/SKILL.md +3 -1
  9. package/skills/loader/SKILL.md +53 -43
  10. package/skills/middleware/SKILL.md +2 -0
  11. package/skills/parallel/SKILL.md +126 -0
  12. package/skills/prerender/SKILL.md +110 -68
  13. package/skills/route/SKILL.md +31 -0
  14. package/skills/router-setup/SKILL.md +87 -2
  15. package/skills/typesafety/SKILL.md +10 -0
  16. package/src/__internal.ts +1 -1
  17. package/src/browser/app-version.ts +14 -0
  18. package/src/browser/event-controller.ts +5 -0
  19. package/src/browser/navigation-bridge.ts +19 -13
  20. package/src/browser/navigation-client.ts +115 -58
  21. package/src/browser/navigation-store.ts +43 -8
  22. package/src/browser/navigation-transaction.ts +11 -9
  23. package/src/browser/partial-update.ts +80 -15
  24. package/src/browser/prefetch/cache.ts +57 -5
  25. package/src/browser/prefetch/fetch.ts +38 -23
  26. package/src/browser/prefetch/queue.ts +92 -20
  27. package/src/browser/prefetch/resource-ready.ts +77 -0
  28. package/src/browser/react/Link.tsx +53 -9
  29. package/src/browser/react/NavigationProvider.tsx +40 -4
  30. package/src/browser/react/context.ts +7 -2
  31. package/src/browser/react/use-handle.ts +9 -58
  32. package/src/browser/react/use-router.ts +21 -8
  33. package/src/browser/rsc-router.tsx +134 -59
  34. package/src/browser/scroll-restoration.ts +41 -42
  35. package/src/browser/segment-reconciler.ts +6 -1
  36. package/src/browser/server-action-bridge.ts +8 -6
  37. package/src/browser/types.ts +36 -5
  38. package/src/build/generate-manifest.ts +6 -6
  39. package/src/build/generate-route-types.ts +3 -0
  40. package/src/build/route-types/include-resolution.ts +8 -1
  41. package/src/build/route-types/router-processing.ts +223 -74
  42. package/src/build/route-types/scan-filter.ts +8 -1
  43. package/src/cache/cache-runtime.ts +15 -11
  44. package/src/cache/cache-scope.ts +48 -7
  45. package/src/cache/cf/cf-cache-store.ts +453 -11
  46. package/src/cache/cf/index.ts +5 -1
  47. package/src/cache/document-cache.ts +17 -7
  48. package/src/cache/index.ts +1 -0
  49. package/src/cache/taint.ts +55 -0
  50. package/src/client.tsx +2 -56
  51. package/src/context-var.ts +72 -2
  52. package/src/debug.ts +2 -2
  53. package/src/handle.ts +40 -0
  54. package/src/index.rsc.ts +3 -1
  55. package/src/index.ts +8 -0
  56. package/src/prerender/store.ts +5 -4
  57. package/src/prerender.ts +138 -77
  58. package/src/reverse.ts +22 -1
  59. package/src/route-definition/dsl-helpers.ts +73 -25
  60. package/src/route-definition/helpers-types.ts +10 -6
  61. package/src/route-definition/index.ts +3 -0
  62. package/src/route-definition/redirect.ts +11 -3
  63. package/src/route-definition/resolve-handler-use.ts +149 -0
  64. package/src/route-map-builder.ts +7 -1
  65. package/src/route-types.ts +11 -0
  66. package/src/router/content-negotiation.ts +100 -1
  67. package/src/router/find-match.ts +4 -2
  68. package/src/router/handler-context.ts +79 -23
  69. package/src/router/intercept-resolution.ts +11 -4
  70. package/src/router/lazy-includes.ts +4 -1
  71. package/src/router/loader-resolution.ts +122 -10
  72. package/src/router/logging.ts +5 -2
  73. package/src/router/manifest.ts +9 -3
  74. package/src/router/match-api.ts +124 -189
  75. package/src/router/match-middleware/background-revalidation.ts +30 -2
  76. package/src/router/match-middleware/cache-lookup.ts +88 -16
  77. package/src/router/match-middleware/cache-store.ts +53 -10
  78. package/src/router/match-middleware/intercept-resolution.ts +9 -7
  79. package/src/router/match-middleware/segment-resolution.ts +61 -5
  80. package/src/router/match-result.ts +22 -6
  81. package/src/router/metrics.ts +6 -1
  82. package/src/router/middleware-types.ts +6 -8
  83. package/src/router/middleware.ts +4 -6
  84. package/src/router/navigation-snapshot.ts +182 -0
  85. package/src/router/prerender-match.ts +110 -10
  86. package/src/router/preview-match.ts +30 -102
  87. package/src/router/request-classification.ts +310 -0
  88. package/src/router/route-snapshot.ts +245 -0
  89. package/src/router/router-context.ts +6 -1
  90. package/src/router/router-interfaces.ts +36 -4
  91. package/src/router/router-options.ts +37 -11
  92. package/src/router/segment-resolution/fresh.ts +183 -20
  93. package/src/router/segment-resolution/helpers.ts +29 -24
  94. package/src/router/segment-resolution/loader-cache.ts +1 -0
  95. package/src/router/segment-resolution/revalidation.ts +412 -297
  96. package/src/router/segment-wrappers.ts +2 -0
  97. package/src/router/types.ts +1 -0
  98. package/src/router.ts +59 -6
  99. package/src/rsc/handler.ts +460 -368
  100. package/src/rsc/manifest-init.ts +5 -1
  101. package/src/rsc/progressive-enhancement.ts +4 -0
  102. package/src/rsc/rsc-rendering.ts +5 -0
  103. package/src/rsc/server-action.ts +2 -0
  104. package/src/rsc/ssr-setup.ts +2 -2
  105. package/src/rsc/types.ts +8 -1
  106. package/src/segment-system.tsx +140 -4
  107. package/src/server/context.ts +140 -14
  108. package/src/server/loader-registry.ts +9 -8
  109. package/src/server/request-context.ts +144 -18
  110. package/src/ssr/index.tsx +4 -0
  111. package/src/static-handler.ts +18 -6
  112. package/src/types/cache-types.ts +4 -4
  113. package/src/types/handler-context.ts +137 -33
  114. package/src/types/loader-types.ts +36 -9
  115. package/src/types/route-entry.ts +8 -1
  116. package/src/types/segments.ts +2 -0
  117. package/src/urls/path-helper-types.ts +9 -2
  118. package/src/urls/path-helper.ts +48 -13
  119. package/src/urls/pattern-types.ts +12 -0
  120. package/src/urls/response-types.ts +16 -6
  121. package/src/use-loader.tsx +73 -4
  122. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  123. package/src/vite/discovery/discover-routers.ts +5 -1
  124. package/src/vite/discovery/prerender-collection.ts +14 -1
  125. package/src/vite/discovery/state.ts +13 -6
  126. package/src/vite/index.ts +4 -0
  127. package/src/vite/plugin-types.ts +51 -79
  128. package/src/vite/plugins/expose-action-id.ts +1 -3
  129. package/src/vite/plugins/performance-tracks.ts +88 -0
  130. package/src/vite/plugins/refresh-cmd.ts +88 -26
  131. package/src/vite/plugins/version-plugin.ts +13 -1
  132. package/src/vite/rango.ts +163 -211
  133. package/src/vite/router-discovery.ts +153 -42
  134. package/src/vite/utils/banner.ts +3 -3
  135. package/src/vite/utils/prerender-utils.ts +18 -0
  136. package/src/vite/utils/shared-utils.ts +3 -2
@@ -10,7 +10,11 @@ import type { ReactNode } from "react";
10
10
  import { invariant } from "../../errors";
11
11
  import { revalidate } from "../loader-resolution.js";
12
12
  import { evaluateRevalidation } from "../revalidation.js";
13
- import type { EntryData } from "../../server/context";
13
+ import {
14
+ getParallelEntries,
15
+ getParallelSlotEntries,
16
+ type EntryData,
17
+ } from "../../server/context";
14
18
  import type {
15
19
  HandlerContext,
16
20
  InternalHandlerContext,
@@ -37,7 +41,11 @@ import {
37
41
  } from "./helpers.js";
38
42
  import { getRouterContext } from "../router-context.js";
39
43
  import { resolveSink, safeEmit } from "../telemetry.js";
40
- import { track } from "../../server/context.js";
44
+ import {
45
+ track,
46
+ RSCRouterContext,
47
+ runInsideLoaderScope,
48
+ } from "../../server/context.js";
41
49
 
42
50
  // ---------------------------------------------------------------------------
43
51
  // Telemetry helpers
@@ -228,7 +236,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
228
236
  params: ctx.params,
229
237
  loaderId: loader.$$id,
230
238
  loaderData: deps.wrapLoaderPromise(
231
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
239
+ runInsideLoaderScope(() =>
240
+ resolveLoaderData(loaderEntry, ctx, ctx.pathname),
241
+ ),
232
242
  entry,
233
243
  segmentId,
234
244
  ctx.pathname,
@@ -258,26 +268,75 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
258
268
  ): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
259
269
  const allLoaderSegments: ResolvedSegment[] = [];
260
270
  const allMatchedIds: string[] = [];
271
+ const seenIds = new Set<string>();
272
+
273
+ async function collectEntryLoaders(
274
+ entry: EntryData,
275
+ belongsToRoute: boolean,
276
+ shortCodeOverride?: string,
277
+ ): Promise<void> {
278
+ // Skip if all loaders from this entry have already been resolved
279
+ // via a parent (e.g., cache boundary wrapping a layout with shared loaders).
280
+ const loaderEntries = entry.loader ?? [];
281
+ const sc = shortCodeOverride ?? entry.shortCode;
282
+ const allAlreadySeen =
283
+ loaderEntries.length > 0 &&
284
+ loaderEntries.every((le, i) =>
285
+ seenIds.has(`${sc}D${i}.${le.loader.$$id}`),
286
+ );
287
+ if (!allAlreadySeen) {
288
+ const { segments, matchedIds } = await resolveLoadersWithRevalidation(
289
+ entry,
290
+ context,
291
+ belongsToRoute,
292
+ clientSegmentIds,
293
+ prevParams,
294
+ request,
295
+ prevUrl,
296
+ nextUrl,
297
+ routeKey,
298
+ deps,
299
+ actionContext,
300
+ shortCodeOverride,
301
+ stale,
302
+ );
303
+ for (const seg of segments) {
304
+ if (!seenIds.has(seg.id)) {
305
+ seenIds.add(seg.id);
306
+ allLoaderSegments.push(seg);
307
+ }
308
+ }
309
+ allMatchedIds.push(...matchedIds);
310
+ }
311
+
312
+ const seenParallelEntryIds = new Set<string>();
313
+ for (const parallelEntry of getParallelEntries(entry.parallel)) {
314
+ if (seenParallelEntryIds.has(parallelEntry.id)) continue;
315
+ seenParallelEntryIds.add(parallelEntry.id);
316
+ await collectEntryLoaders(parallelEntry, belongsToRoute, entry.shortCode);
317
+ }
318
+
319
+ const childBelongsToRoute = belongsToRoute || entry.type === "route";
320
+ for (const layoutEntry of entry.layout) {
321
+ await collectEntryLoaders(layoutEntry, childBelongsToRoute);
322
+ // Inherit route loaders for orphan layouts with parallels
323
+ if (
324
+ entry.type === "route" &&
325
+ entry.loader &&
326
+ entry.loader.length > 0 &&
327
+ Object.keys(layoutEntry.parallel).length > 0
328
+ ) {
329
+ await collectEntryLoaders(
330
+ entry,
331
+ childBelongsToRoute,
332
+ layoutEntry.shortCode,
333
+ );
334
+ }
335
+ }
336
+ }
261
337
 
262
338
  for (const entry of entries) {
263
- const belongsToRoute = entry.type === "route";
264
- const { segments, matchedIds } = await resolveLoadersWithRevalidation(
265
- entry,
266
- context,
267
- belongsToRoute,
268
- clientSegmentIds,
269
- prevParams,
270
- request,
271
- prevUrl,
272
- nextUrl,
273
- routeKey,
274
- deps,
275
- actionContext,
276
- undefined, // shortCodeOverride
277
- stale,
278
- );
279
- allLoaderSegments.push(...segments);
280
- allMatchedIds.push(...matchedIds);
339
+ await collectEntryLoaders(entry, entry.type === "route");
281
340
  }
282
341
 
283
342
  return { segments: allLoaderSegments, matchedIds: allMatchedIds };
@@ -301,22 +360,20 @@ export function buildEntryRevalidateMap(
301
360
  map.set(entry.shortCode, { entry, revalidate: entry.revalidate });
302
361
 
303
362
  if (entry.type !== "parallel") {
304
- for (const parallelEntry of entry.parallel) {
305
- if (parallelEntry.type === "parallel") {
306
- const slots = Object.keys(parallelEntry.handler) as `@${string}`[];
307
- for (const slot of slots) {
308
- const parallelId = `${parallelEntry.shortCode}.${slot}`;
309
- map.set(parallelId, {
310
- entry: parallelEntry,
311
- revalidate: parallelEntry.revalidate,
312
- });
313
- }
314
- }
363
+ for (const { slot, entry: parallelEntry } of getParallelSlotEntries(
364
+ entry.parallel,
365
+ )) {
366
+ const parallelParentShortCode = parentShortCode ?? entry.shortCode;
367
+ const parallelId = `${parallelParentShortCode}.${slot}`;
368
+ map.set(parallelId, {
369
+ entry: parallelEntry,
370
+ revalidate: parallelEntry.revalidate,
371
+ });
315
372
  }
316
373
  }
317
374
 
318
375
  for (const layoutEntry of entry.layout) {
319
- processEntry(layoutEntry);
376
+ processEntry(layoutEntry, entry.shortCode);
320
377
  }
321
378
  }
322
379
 
@@ -348,7 +405,10 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
348
405
  const segments: ResolvedSegment[] = [];
349
406
  const matchedIds: string[] = [];
350
407
 
351
- for (const parallelEntry of entry.parallel) {
408
+ const resolvedParallelEntries = new Set<string>();
409
+ for (const { slot, entry: parallelEntry } of getParallelSlotEntries(
410
+ entry.parallel,
411
+ )) {
352
412
  invariant(
353
413
  parallelEntry.type === "parallel",
354
414
  `Expected parallel entry, got: ${parallelEntry.type}`,
@@ -359,141 +419,61 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
359
419
  | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
360
420
  | ReactNode
361
421
  >;
422
+ // In production, static handler bodies are evicted and the slot value
423
+ // may be undefined. The static store holds the pre-rendered component.
424
+ // We defer the handler check until after tryStaticSlot.
425
+ const handler = slots[slot];
426
+
427
+ const parallelId = `${entry.shortCode}.${slot}`;
428
+
429
+ const isFullRefetch = clientSegmentIds.size === 0;
430
+ const isNewParent = !clientSegmentIds.has(entry.shortCode);
431
+ if (
432
+ isFullRefetch ||
433
+ clientSegmentIds.has(parallelId) ||
434
+ belongsToRoute ||
435
+ isNewParent
436
+ ) {
437
+ matchedIds.push(parallelId);
438
+ }
362
439
 
363
- for (const [slot, handler] of Object.entries(slots)) {
364
- const parallelId = `${entry.shortCode}.${slot}`;
365
-
366
- const isFullRefetch = clientSegmentIds.size === 0;
367
- // When the parent layout is new (not in client's segment set),
368
- // all its parallel children must be resolved and tracked.
369
- // Without this, navigating to a new layout with parallels
370
- // (e.g., BlogLayout with @sidebar) from a different route
371
- // would silently drop those parallel segments.
372
- const isNewParent = !clientSegmentIds.has(entry.shortCode);
373
- if (
374
- isFullRefetch ||
375
- clientSegmentIds.has(parallelId) ||
376
- belongsToRoute ||
377
- isNewParent
378
- ) {
379
- matchedIds.push(parallelId);
380
- }
381
-
382
- const shouldResolve = await (async () => {
383
- if (isFullRefetch) {
384
- if (isTraceActive()) {
385
- pushRevalidationTraceEntry({
386
- segmentId: parallelId,
387
- segmentType: "parallel",
388
- belongsToRoute,
389
- source: "parallel",
390
- defaultShouldRevalidate: true,
391
- finalShouldRevalidate: true,
392
- reason: "full-refetch",
393
- });
394
- }
395
- return true;
396
- }
397
- if (!clientSegmentIds.has(parallelId)) {
398
- const result = belongsToRoute || isNewParent;
399
- if (isTraceActive()) {
400
- pushRevalidationTraceEntry({
401
- segmentId: parallelId,
402
- segmentType: "parallel",
403
- belongsToRoute,
404
- source: "parallel",
405
- defaultShouldRevalidate: result,
406
- finalShouldRevalidate: result,
407
- reason: result ? "new-segment" : "skip-parent-chain",
408
- });
409
- }
410
- return result;
440
+ const shouldResolve = await (async () => {
441
+ if (isFullRefetch) {
442
+ if (isTraceActive()) {
443
+ pushRevalidationTraceEntry({
444
+ segmentId: parallelId,
445
+ segmentType: "parallel",
446
+ belongsToRoute,
447
+ source: "parallel",
448
+ defaultShouldRevalidate: true,
449
+ finalShouldRevalidate: true,
450
+ reason: "full-refetch",
451
+ });
411
452
  }
412
-
413
- const dummySegment: ResolvedSegment = {
414
- id: parallelId,
415
- namespace: parallelEntry.id,
416
- type: "parallel",
417
- index: 0,
418
- component: null as any,
419
- params,
420
- slot,
421
- belongsToRoute,
422
- parallelName: `${parallelEntry.id}.${slot}`,
423
- ...(parallelEntry.mountPath
424
- ? { mountPath: parallelEntry.mountPath }
425
- : {}),
426
- };
427
-
428
- return await evaluateRevalidation({
429
- segment: dummySegment,
430
- prevParams,
431
- getPrevSegment: null,
432
- request,
433
- prevUrl,
434
- nextUrl,
435
- revalidations: parallelEntry.revalidate.map((fn, i) => ({
436
- name: `revalidate${i}`,
437
- fn,
438
- })),
439
- routeKey,
440
- context,
441
- actionContext,
442
- stale,
443
- traceSource: "parallel",
444
- });
445
- })();
446
- emitRevalidationDecision(
447
- parallelId,
448
- context.pathname,
449
- routeKey,
450
- shouldResolve,
451
- );
452
-
453
- let component: ReactNode | undefined;
454
- if (shouldResolve) {
455
- component = await tryStaticSlot(parallelEntry, slot, parallelId);
453
+ return true;
456
454
  }
457
- if (component === undefined) {
458
- const hasLoadingFallback =
459
- parallelEntry.loading !== undefined &&
460
- parallelEntry.loading !== false;
461
- if (!shouldResolve) {
462
- component = null;
463
- } else if (hasLoadingFallback) {
464
- const result =
465
- typeof handler === "function" ? handler(context) : handler;
466
- if (result instanceof Promise) {
467
- const tracked = deps.trackHandler(result, {
468
- segmentId: parallelId,
469
- segmentType: "parallel",
470
- });
471
- observeStreamedHandler(
472
- tracked,
473
- parallelId,
474
- "parallel",
475
- context.pathname,
476
- routeKey,
477
- params,
478
- );
479
- component = tracked as ReactNode;
480
- } else {
481
- component = result as ReactNode;
482
- }
483
- } else {
484
- component =
485
- typeof handler === "function" ? await handler(context) : handler;
455
+ if (!clientSegmentIds.has(parallelId)) {
456
+ const result = belongsToRoute || isNewParent;
457
+ if (isTraceActive()) {
458
+ pushRevalidationTraceEntry({
459
+ segmentId: parallelId,
460
+ segmentType: "parallel",
461
+ belongsToRoute,
462
+ source: "parallel",
463
+ defaultShouldRevalidate: result,
464
+ finalShouldRevalidate: result,
465
+ reason: result ? "new-segment" : "skip-parent-chain",
466
+ });
486
467
  }
468
+ return result;
487
469
  }
488
470
 
489
- segments.push({
471
+ const dummySegment: ResolvedSegment = {
490
472
  id: parallelId,
491
473
  namespace: parallelEntry.id,
492
474
  type: "parallel",
493
475
  index: 0,
494
- component,
495
- loading: parallelEntry.loading === false ? null : parallelEntry.loading,
496
- transition: parallelEntry.transition,
476
+ component: null as any,
497
477
  params,
498
478
  slot,
499
479
  belongsToRoute,
@@ -501,28 +481,111 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
501
481
  ...(parallelEntry.mountPath
502
482
  ? { mountPath: parallelEntry.mountPath }
503
483
  : {}),
504
- });
505
- }
484
+ };
506
485
 
507
- if (!parallelEntry.loading) {
508
- const loaderResult = await resolveLoadersWithRevalidation(
509
- parallelEntry,
510
- context,
511
- belongsToRoute,
512
- clientSegmentIds,
486
+ return await evaluateRevalidation({
487
+ segment: dummySegment,
513
488
  prevParams,
489
+ getPrevSegment: null,
514
490
  request,
515
491
  prevUrl,
516
492
  nextUrl,
493
+ revalidations: parallelEntry.revalidate.map((fn, i) => ({
494
+ name: `revalidate${i}`,
495
+ fn,
496
+ })),
517
497
  routeKey,
518
- deps,
498
+ context,
519
499
  actionContext,
520
- entry.shortCode,
521
500
  stale,
522
- );
523
- segments.push(...loaderResult.segments);
524
- matchedIds.push(...loaderResult.matchedIds);
501
+ traceSource: "parallel",
502
+ });
503
+ })();
504
+ emitRevalidationDecision(
505
+ parallelId,
506
+ context.pathname,
507
+ routeKey,
508
+ shouldResolve,
509
+ );
510
+
511
+ let component: ReactNode | undefined;
512
+ if (shouldResolve) {
513
+ component = await tryStaticSlot(parallelEntry, slot, parallelId);
514
+ }
515
+ if (component === undefined) {
516
+ const hasLoadingFallback =
517
+ parallelEntry.loading !== undefined && parallelEntry.loading !== false;
518
+ if (!shouldResolve) {
519
+ component = null;
520
+ } else if (handler === undefined) {
521
+ // Handler evicted (production static slot) but static lookup missed.
522
+ // Nothing to render — use null so the client keeps its cached version.
523
+ component = null;
524
+ } else if (hasLoadingFallback) {
525
+ const result =
526
+ typeof handler === "function" ? handler(context) : handler;
527
+ if (result instanceof Promise) {
528
+ const tracked = deps.trackHandler(result, {
529
+ segmentId: parallelId,
530
+ segmentType: "parallel",
531
+ });
532
+ observeStreamedHandler(
533
+ tracked,
534
+ parallelId,
535
+ "parallel",
536
+ context.pathname,
537
+ routeKey,
538
+ params,
539
+ );
540
+ component = tracked as ReactNode;
541
+ } else {
542
+ component = result as ReactNode;
543
+ }
544
+ } else {
545
+ component =
546
+ typeof handler === "function" ? await handler(context) : handler;
547
+ }
525
548
  }
549
+
550
+ segments.push({
551
+ id: parallelId,
552
+ namespace: parallelEntry.id,
553
+ type: "parallel",
554
+ index: 0,
555
+ component,
556
+ loading: parallelEntry.loading === false ? null : parallelEntry.loading,
557
+ transition: parallelEntry.transition,
558
+ params,
559
+ slot,
560
+ belongsToRoute,
561
+ parallelName: `${parallelEntry.id}.${slot}`,
562
+ ...(parallelEntry.mountPath
563
+ ? { mountPath: parallelEntry.mountPath }
564
+ : {}),
565
+ });
566
+
567
+ if (resolvedParallelEntries.has(parallelEntry.id)) {
568
+ continue;
569
+ }
570
+
571
+ const loaderResult = await resolveLoadersWithRevalidation(
572
+ parallelEntry,
573
+ context,
574
+ belongsToRoute,
575
+ clientSegmentIds,
576
+ prevParams,
577
+ request,
578
+ prevUrl,
579
+ nextUrl,
580
+ routeKey,
581
+ deps,
582
+ actionContext,
583
+ entry.shortCode,
584
+ stale,
585
+ );
586
+ segments.push(...loaderResult.segments);
587
+ matchedIds.push(...loaderResult.matchedIds);
588
+ resolvedParallelEntries.add(parallelEntry.id);
526
589
  }
527
590
 
528
591
  return { segments, matchedIds };
@@ -608,6 +671,8 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
608
671
  context,
609
672
  actionContext,
610
673
  stale,
674
+ traceSource:
675
+ entry.type === "route" ? "route-handler" : "layout-handler",
611
676
  });
612
677
  emitRevalidationDecision(
613
678
  entry.shortCode,
@@ -636,13 +701,20 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
636
701
  return staticComponent;
637
702
  }
638
703
  const routeEntry = entry as Extract<EntryData, { type: "route" }>;
704
+ // For Passthrough routes at runtime, use the live handler instead of
705
+ // the build handler. At build time (context.build === true), always
706
+ // use the build handler from routeEntry.handler.
707
+ const handler =
708
+ !context.build && routeEntry.liveHandler
709
+ ? routeEntry.liveHandler
710
+ : routeEntry.handler;
639
711
  if (!routeEntry.loading) {
640
- const result = handleHandlerResult(await routeEntry.handler(context));
712
+ const result = handleHandlerResult(await handler(context));
641
713
  doneHandler();
642
714
  return result;
643
715
  }
644
716
  if (!actionContext) {
645
- const result = handleHandlerResult(routeEntry.handler(context));
717
+ const result = handleHandlerResult(handler(context));
646
718
  if (result instanceof Promise) {
647
719
  result.finally(doneHandler).catch(() => {});
648
720
  const tracked = deps.trackHandler(result, {
@@ -665,9 +737,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
665
737
  debugLog("segment.action", "resolving action route with awaited value", {
666
738
  entryId: entry.id,
667
739
  });
668
- const actionResult = handleHandlerResult(
669
- await routeEntry.handler(context),
670
- );
740
+ const actionResult = handleHandlerResult(await handler(context));
671
741
  doneHandler();
672
742
  return {
673
743
  content: Promise.resolve(actionResult),
@@ -676,10 +746,12 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
676
746
  () => null,
677
747
  );
678
748
 
749
+ // Normalize void handlers (undefined) to null so the reconciler's
750
+ // component === null checks work consistently for both void and explicit null.
679
751
  const resolvedComponent =
680
752
  component && typeof component === "object" && "content" in component
681
- ? (component as { content: ReactNode }).content
682
- : component;
753
+ ? ((component as { content: ReactNode }).content ?? null)
754
+ : (component ?? null);
683
755
 
684
756
  const segment: ResolvedSegment = {
685
757
  id: entry.shortCode,
@@ -781,6 +853,7 @@ export async function resolveSegmentWithRevalidation<TEnv>(
781
853
  deps,
782
854
  actionContext,
783
855
  stale,
856
+ entry,
784
857
  );
785
858
  segments.push(...orphanResult.segments);
786
859
  matchedIds.push(...orphanResult.matchedIds);
@@ -892,6 +965,8 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
892
965
  deps: SegmentResolutionDeps<TEnv>,
893
966
  actionContext?: ActionContext,
894
967
  stale?: boolean,
968
+ /** Parent route entry — its loaders are inherited so parallel slots can access them. */
969
+ parentRouteEntry?: EntryData,
895
970
  ): Promise<SegmentRevalidationResult> {
896
971
  invariant(
897
972
  orphan.type === "layout" || orphan.type === "cache",
@@ -919,6 +994,33 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
919
994
  segments.push(...loaderResult.segments);
920
995
  matchedIds.push(...loaderResult.matchedIds);
921
996
 
997
+ // Inherit parent route's loaders so parallel slots inside this layout
998
+ // can access them via useLoader(). See resolveOrphanLayout in fresh.ts.
999
+ if (
1000
+ parentRouteEntry &&
1001
+ parentRouteEntry.loader &&
1002
+ parentRouteEntry.loader.length > 0 &&
1003
+ Object.keys(orphan.parallel).length > 0
1004
+ ) {
1005
+ const inheritedResult = await resolveLoadersWithRevalidation(
1006
+ parentRouteEntry,
1007
+ context,
1008
+ belongsToRoute,
1009
+ clientSegmentIds,
1010
+ prevParams,
1011
+ request,
1012
+ prevUrl,
1013
+ nextUrl,
1014
+ routeKey,
1015
+ deps,
1016
+ actionContext,
1017
+ orphan.shortCode,
1018
+ stale,
1019
+ );
1020
+ segments.push(...inheritedResult.segments);
1021
+ matchedIds.push(...inheritedResult.matchedIds);
1022
+ }
1023
+
922
1024
  // Handler-first: resolve orphan layout handler before its parallels
923
1025
  // so ctx.set() values are visible to parallel children.
924
1026
  matchedIds.push(orphan.shortCode);
@@ -995,143 +1097,72 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
995
1097
  ...(orphan.mountPath ? { mountPath: orphan.mountPath } : {}),
996
1098
  });
997
1099
 
998
- for (const parallelEntry of orphan.parallel) {
1100
+ const resolvedParallelEntries = new Set<string>();
1101
+ for (const { slot, entry: parallelEntry } of getParallelSlotEntries(
1102
+ orphan.parallel,
1103
+ )) {
999
1104
  invariant(
1000
1105
  parallelEntry.type === "parallel",
1001
1106
  `Expected parallel entry, got: ${parallelEntry.type}`,
1002
1107
  );
1003
1108
 
1004
- const loaderResult = await resolveLoadersWithRevalidation(
1005
- parallelEntry,
1006
- context,
1007
- belongsToRoute,
1008
- clientSegmentIds,
1009
- prevParams,
1010
- request,
1011
- prevUrl,
1012
- nextUrl,
1013
- routeKey,
1014
- deps,
1015
- actionContext,
1016
- undefined,
1017
- stale,
1018
- );
1019
- segments.push(...loaderResult.segments);
1020
- matchedIds.push(...loaderResult.matchedIds);
1109
+ if (!resolvedParallelEntries.has(parallelEntry.id)) {
1110
+ const loaderResult = await resolveLoadersWithRevalidation(
1111
+ parallelEntry,
1112
+ context,
1113
+ belongsToRoute,
1114
+ clientSegmentIds,
1115
+ prevParams,
1116
+ request,
1117
+ prevUrl,
1118
+ nextUrl,
1119
+ routeKey,
1120
+ deps,
1121
+ actionContext,
1122
+ undefined,
1123
+ stale,
1124
+ );
1125
+ segments.push(...loaderResult.segments);
1126
+ matchedIds.push(...loaderResult.matchedIds);
1127
+ resolvedParallelEntries.add(parallelEntry.id);
1128
+ }
1021
1129
 
1022
1130
  const slots = parallelEntry.handler as Record<
1023
1131
  `@${string}`,
1024
1132
  | ((ctx: HandlerContext<any, TEnv>) => ReactNode | Promise<ReactNode>)
1025
1133
  | ReactNode
1026
1134
  >;
1135
+ // Handler may be undefined in production after static handler eviction.
1136
+ const handler = slots[slot];
1027
1137
 
1028
- for (const [slot, handler] of Object.entries(slots)) {
1029
- // Use orphan.shortCode (the parent layout) to match the SSR path
1030
- // (resolveParallelEntry receives parentShortCode = orphan.shortCode).
1031
- // Using parallelEntry.shortCode would generate IDs the client doesn't know about.
1032
- const parallelId = `${orphan.shortCode}.${slot}`;
1033
- matchedIds.push(parallelId);
1034
-
1035
- const shouldResolve = await (async () => {
1036
- if (!clientSegmentIds.has(parallelId)) {
1037
- if (isTraceActive()) {
1038
- pushRevalidationTraceEntry({
1039
- segmentId: parallelId,
1040
- segmentType: "parallel",
1041
- belongsToRoute,
1042
- source: "parallel",
1043
- defaultShouldRevalidate: true,
1044
- finalShouldRevalidate: true,
1045
- reason: "new-segment",
1046
- });
1047
- }
1048
- return true;
1049
- }
1050
-
1051
- const dummySegment: ResolvedSegment = {
1052
- id: parallelId,
1053
- namespace: parallelEntry.id,
1054
- type: "parallel",
1055
- index: 0,
1056
- component: null as any,
1057
- params,
1058
- slot,
1059
- belongsToRoute,
1060
- parallelName: `${parallelEntry.id}.${slot}`,
1061
- ...(parallelEntry.mountPath
1062
- ? { mountPath: parallelEntry.mountPath }
1063
- : {}),
1064
- };
1065
-
1066
- return await evaluateRevalidation({
1067
- segment: dummySegment,
1068
- prevParams,
1069
- getPrevSegment: null,
1070
- request,
1071
- prevUrl,
1072
- nextUrl,
1073
- revalidations: parallelEntry.revalidate.map((fn, i) => ({
1074
- name: `revalidate${i}`,
1075
- fn,
1076
- })),
1077
- routeKey,
1078
- context,
1079
- actionContext,
1080
- stale,
1081
- traceSource: "parallel",
1082
- });
1083
- })();
1084
- emitRevalidationDecision(
1085
- parallelId,
1086
- context.pathname,
1087
- routeKey,
1088
- shouldResolve,
1089
- );
1138
+ // Use orphan.shortCode (the parent layout) to match the SSR path
1139
+ // (resolveParallelEntry receives parentShortCode = orphan.shortCode).
1140
+ // Using parallelEntry.shortCode would generate IDs the client doesn't know about.
1141
+ const parallelId = `${orphan.shortCode}.${slot}`;
1142
+ matchedIds.push(parallelId);
1090
1143
 
1091
- let component: ReactNode | undefined;
1092
- if (shouldResolve) {
1093
- component = await tryStaticSlot(parallelEntry, slot, parallelId);
1094
- }
1095
- if (component === undefined) {
1096
- const hasLoadingFallback =
1097
- parallelEntry.loading !== undefined &&
1098
- parallelEntry.loading !== false;
1099
- if (!shouldResolve) {
1100
- component = null;
1101
- } else if (hasLoadingFallback) {
1102
- const result =
1103
- typeof handler === "function" ? handler(context) : handler;
1104
- if (result instanceof Promise) {
1105
- const tracked = deps.trackHandler(result, {
1106
- segmentId: parallelId,
1107
- segmentType: "parallel",
1108
- });
1109
- observeStreamedHandler(
1110
- tracked,
1111
- parallelId,
1112
- "parallel",
1113
- context.pathname,
1114
- routeKey,
1115
- params,
1116
- );
1117
- component = tracked as ReactNode;
1118
- } else {
1119
- component = result as ReactNode;
1120
- }
1121
- } else {
1122
- component =
1123
- typeof handler === "function" ? await handler(context) : handler;
1144
+ const shouldResolve = await (async () => {
1145
+ if (!clientSegmentIds.has(parallelId)) {
1146
+ if (isTraceActive()) {
1147
+ pushRevalidationTraceEntry({
1148
+ segmentId: parallelId,
1149
+ segmentType: "parallel",
1150
+ belongsToRoute,
1151
+ source: "parallel",
1152
+ defaultShouldRevalidate: true,
1153
+ finalShouldRevalidate: true,
1154
+ reason: "new-segment",
1155
+ });
1124
1156
  }
1157
+ return true;
1125
1158
  }
1126
1159
 
1127
- segments.push({
1160
+ const dummySegment: ResolvedSegment = {
1128
1161
  id: parallelId,
1129
1162
  namespace: parallelEntry.id,
1130
1163
  type: "parallel",
1131
1164
  index: 0,
1132
- component,
1133
- loading: parallelEntry.loading === false ? null : parallelEntry.loading,
1134
- transition: parallelEntry.transition,
1165
+ component: null as any,
1135
1166
  params,
1136
1167
  slot,
1137
1168
  belongsToRoute,
@@ -1139,8 +1170,87 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1139
1170
  ...(parallelEntry.mountPath
1140
1171
  ? { mountPath: parallelEntry.mountPath }
1141
1172
  : {}),
1173
+ };
1174
+
1175
+ return await evaluateRevalidation({
1176
+ segment: dummySegment,
1177
+ prevParams,
1178
+ getPrevSegment: null,
1179
+ request,
1180
+ prevUrl,
1181
+ nextUrl,
1182
+ revalidations: parallelEntry.revalidate.map((fn, i) => ({
1183
+ name: `revalidate${i}`,
1184
+ fn,
1185
+ })),
1186
+ routeKey,
1187
+ context,
1188
+ actionContext,
1189
+ stale,
1190
+ traceSource: "parallel",
1142
1191
  });
1192
+ })();
1193
+ emitRevalidationDecision(
1194
+ parallelId,
1195
+ context.pathname,
1196
+ routeKey,
1197
+ shouldResolve,
1198
+ );
1199
+
1200
+ let component: ReactNode | undefined;
1201
+ if (shouldResolve) {
1202
+ component = await tryStaticSlot(parallelEntry, slot, parallelId);
1203
+ }
1204
+ if (component === undefined) {
1205
+ const hasLoadingFallback =
1206
+ parallelEntry.loading !== undefined && parallelEntry.loading !== false;
1207
+ if (!shouldResolve) {
1208
+ component = null;
1209
+ } else if (handler === undefined) {
1210
+ // Handler evicted (production static slot) but static lookup missed.
1211
+ component = null;
1212
+ } else if (hasLoadingFallback) {
1213
+ const result =
1214
+ typeof handler === "function" ? handler(context) : handler;
1215
+ if (result instanceof Promise) {
1216
+ const tracked = deps.trackHandler(result, {
1217
+ segmentId: parallelId,
1218
+ segmentType: "parallel",
1219
+ });
1220
+ observeStreamedHandler(
1221
+ tracked,
1222
+ parallelId,
1223
+ "parallel",
1224
+ context.pathname,
1225
+ routeKey,
1226
+ params,
1227
+ );
1228
+ component = tracked as ReactNode;
1229
+ } else {
1230
+ component = result as ReactNode;
1231
+ }
1232
+ } else {
1233
+ component =
1234
+ typeof handler === "function" ? await handler(context) : handler;
1235
+ }
1143
1236
  }
1237
+
1238
+ segments.push({
1239
+ id: parallelId,
1240
+ namespace: parallelEntry.id,
1241
+ type: "parallel",
1242
+ index: 0,
1243
+ component,
1244
+ loading: parallelEntry.loading === false ? null : parallelEntry.loading,
1245
+ transition: parallelEntry.transition,
1246
+ params,
1247
+ slot,
1248
+ belongsToRoute,
1249
+ parallelName: `${parallelEntry.id}.${slot}`,
1250
+ ...(parallelEntry.mountPath
1251
+ ? { mountPath: parallelEntry.mountPath }
1252
+ : {}),
1253
+ });
1144
1254
  }
1145
1255
 
1146
1256
  return { segments, matchedIds };
@@ -1165,6 +1275,7 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
1165
1275
  localRouteName: string,
1166
1276
  pathname: string,
1167
1277
  deps: SegmentResolutionDeps<TEnv>,
1278
+ stale?: boolean,
1168
1279
  ): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
1169
1280
  const allSegments: ResolvedSegment[] = [];
1170
1281
  const matchedIds: string[] = [];
@@ -1191,6 +1302,10 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
1191
1302
  }
1192
1303
 
1193
1304
  const nonParallelEntry = entry as Exclude<EntryData, { type: "parallel" }>;
1305
+ if (entry.type === "cache") {
1306
+ const store = RSCRouterContext.getStore();
1307
+ if (store) store.insideCacheScope = true;
1308
+ }
1194
1309
  const doneEntry = track(`segment:${entry.id}`, 1);
1195
1310
  const resolved = await resolveWithErrorBoundary(
1196
1311
  nonParallelEntry,
@@ -1209,7 +1324,7 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
1209
1324
  loaderPromises,
1210
1325
  deps,
1211
1326
  actionContext,
1212
- false,
1327
+ stale,
1213
1328
  ),
1214
1329
  (seg) => ({ segments: [seg], matchedIds: [seg.id] }),
1215
1330
  deps,