@rangojs/router 0.0.0-experimental.fa8a383a → 0.0.0-experimental.fb4fdc18

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 (175) hide show
  1. package/README.md +188 -35
  2. package/dist/bin/rango.js +130 -47
  3. package/dist/vite/index.js +1884 -537
  4. package/dist/vite/index.js.bak +5448 -0
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +7 -5
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/cache-guide/SKILL.md +32 -0
  9. package/skills/caching/SKILL.md +8 -0
  10. package/skills/handler-use/SKILL.md +362 -0
  11. package/skills/hooks/SKILL.md +33 -20
  12. package/skills/i18n/SKILL.md +276 -0
  13. package/skills/intercept/SKILL.md +20 -0
  14. package/skills/layout/SKILL.md +22 -0
  15. package/skills/links/SKILL.md +93 -17
  16. package/skills/loader/SKILL.md +123 -46
  17. package/skills/middleware/SKILL.md +36 -3
  18. package/skills/migrate-nextjs/SKILL.md +562 -0
  19. package/skills/migrate-react-router/SKILL.md +769 -0
  20. package/skills/parallel/SKILL.md +133 -0
  21. package/skills/prerender/SKILL.md +110 -68
  22. package/skills/rango/SKILL.md +26 -22
  23. package/skills/response-routes/SKILL.md +8 -0
  24. package/skills/route/SKILL.md +75 -0
  25. package/skills/router-setup/SKILL.md +87 -2
  26. package/skills/server-actions/SKILL.md +739 -0
  27. package/skills/streams-and-websockets/SKILL.md +283 -0
  28. package/skills/typesafety/SKILL.md +19 -1
  29. package/src/__internal.ts +1 -1
  30. package/src/browser/app-shell.ts +52 -0
  31. package/src/browser/app-version.ts +14 -0
  32. package/src/browser/event-controller.ts +44 -4
  33. package/src/browser/navigation-bridge.ts +95 -7
  34. package/src/browser/navigation-client.ts +128 -53
  35. package/src/browser/navigation-store.ts +68 -9
  36. package/src/browser/partial-update.ts +93 -12
  37. package/src/browser/prefetch/cache.ts +129 -21
  38. package/src/browser/prefetch/fetch.ts +156 -18
  39. package/src/browser/prefetch/queue.ts +92 -29
  40. package/src/browser/prefetch/resource-ready.ts +77 -0
  41. package/src/browser/rango-state.ts +53 -13
  42. package/src/browser/react/Link.tsx +72 -8
  43. package/src/browser/react/NavigationProvider.tsx +82 -21
  44. package/src/browser/react/context.ts +7 -2
  45. package/src/browser/react/filter-segment-order.ts +51 -7
  46. package/src/browser/react/use-handle.ts +9 -58
  47. package/src/browser/react/use-navigation.ts +22 -2
  48. package/src/browser/react/use-params.ts +17 -4
  49. package/src/browser/react/use-router.ts +29 -9
  50. package/src/browser/react/use-segments.ts +11 -8
  51. package/src/browser/rsc-router.tsx +60 -9
  52. package/src/browser/scroll-restoration.ts +10 -8
  53. package/src/browser/segment-reconciler.ts +36 -14
  54. package/src/browser/server-action-bridge.ts +8 -6
  55. package/src/browser/types.ts +46 -5
  56. package/src/build/generate-manifest.ts +6 -6
  57. package/src/build/generate-route-types.ts +3 -0
  58. package/src/build/route-trie.ts +52 -25
  59. package/src/build/route-types/include-resolution.ts +8 -1
  60. package/src/build/route-types/router-processing.ts +211 -72
  61. package/src/build/route-types/scan-filter.ts +8 -1
  62. package/src/cache/cache-runtime.ts +15 -11
  63. package/src/cache/cache-scope.ts +46 -5
  64. package/src/cache/cf/cf-cache-store.ts +5 -7
  65. package/src/cache/taint.ts +55 -0
  66. package/src/client.tsx +84 -230
  67. package/src/context-var.ts +72 -2
  68. package/src/handle.ts +40 -0
  69. package/src/index.rsc.ts +6 -1
  70. package/src/index.ts +49 -6
  71. package/src/outlet-context.ts +1 -1
  72. package/src/prerender/store.ts +5 -4
  73. package/src/prerender.ts +138 -77
  74. package/src/response-utils.ts +28 -0
  75. package/src/reverse.ts +28 -2
  76. package/src/route-definition/dsl-helpers.ts +210 -35
  77. package/src/route-definition/helpers-types.ts +73 -20
  78. package/src/route-definition/index.ts +3 -0
  79. package/src/route-definition/redirect.ts +9 -1
  80. package/src/route-definition/resolve-handler-use.ts +155 -0
  81. package/src/route-types.ts +18 -0
  82. package/src/router/content-negotiation.ts +100 -1
  83. package/src/router/handler-context.ts +102 -25
  84. package/src/router/intercept-resolution.ts +9 -4
  85. package/src/router/lazy-includes.ts +6 -6
  86. package/src/router/loader-resolution.ts +159 -21
  87. package/src/router/manifest.ts +22 -13
  88. package/src/router/match-api.ts +128 -192
  89. package/src/router/match-handlers.ts +1 -0
  90. package/src/router/match-middleware/background-revalidation.ts +12 -1
  91. package/src/router/match-middleware/cache-lookup.ts +74 -14
  92. package/src/router/match-middleware/cache-store.ts +21 -4
  93. package/src/router/match-middleware/segment-resolution.ts +53 -0
  94. package/src/router/match-result.ts +112 -9
  95. package/src/router/metrics.ts +6 -1
  96. package/src/router/middleware-types.ts +20 -33
  97. package/src/router/middleware.ts +56 -12
  98. package/src/router/navigation-snapshot.ts +182 -0
  99. package/src/router/pattern-matching.ts +101 -17
  100. package/src/router/prerender-match.ts +110 -10
  101. package/src/router/preview-match.ts +30 -102
  102. package/src/router/request-classification.ts +310 -0
  103. package/src/router/revalidation.ts +15 -1
  104. package/src/router/route-snapshot.ts +245 -0
  105. package/src/router/router-context.ts +1 -0
  106. package/src/router/router-interfaces.ts +36 -4
  107. package/src/router/router-options.ts +37 -11
  108. package/src/router/segment-resolution/fresh.ts +114 -18
  109. package/src/router/segment-resolution/helpers.ts +29 -24
  110. package/src/router/segment-resolution/revalidation.ts +257 -127
  111. package/src/router/trie-matching.ts +18 -13
  112. package/src/router/types.ts +1 -0
  113. package/src/router/url-params.ts +49 -0
  114. package/src/router.ts +55 -7
  115. package/src/rsc/handler.ts +478 -383
  116. package/src/rsc/helpers.ts +69 -41
  117. package/src/rsc/loader-fetch.ts +23 -3
  118. package/src/rsc/manifest-init.ts +5 -1
  119. package/src/rsc/progressive-enhancement.ts +18 -2
  120. package/src/rsc/response-route-handler.ts +14 -1
  121. package/src/rsc/rsc-rendering.ts +20 -1
  122. package/src/rsc/server-action.ts +12 -0
  123. package/src/rsc/ssr-setup.ts +2 -2
  124. package/src/rsc/types.ts +15 -1
  125. package/src/segment-content-promise.ts +67 -0
  126. package/src/segment-loader-promise.ts +122 -0
  127. package/src/segment-system.tsx +22 -62
  128. package/src/server/context.ts +76 -4
  129. package/src/server/handle-store.ts +19 -0
  130. package/src/server/loader-registry.ts +9 -8
  131. package/src/server/request-context.ts +185 -57
  132. package/src/ssr/index.tsx +8 -1
  133. package/src/static-handler.ts +18 -6
  134. package/src/types/cache-types.ts +4 -4
  135. package/src/types/handler-context.ts +145 -68
  136. package/src/types/loader-types.ts +41 -15
  137. package/src/types/request-scope.ts +126 -0
  138. package/src/types/route-entry.ts +12 -1
  139. package/src/types/segments.ts +18 -1
  140. package/src/urls/include-helper.ts +24 -14
  141. package/src/urls/path-helper-types.ts +39 -6
  142. package/src/urls/path-helper.ts +47 -12
  143. package/src/urls/pattern-types.ts +12 -0
  144. package/src/urls/response-types.ts +18 -16
  145. package/src/use-loader.tsx +77 -5
  146. package/src/vite/debug.ts +184 -0
  147. package/src/vite/discovery/bundle-postprocess.ts +30 -33
  148. package/src/vite/discovery/discover-routers.ts +36 -4
  149. package/src/vite/discovery/gate-state.ts +171 -0
  150. package/src/vite/discovery/prerender-collection.ts +175 -74
  151. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  152. package/src/vite/discovery/state.ts +13 -4
  153. package/src/vite/index.ts +4 -0
  154. package/src/vite/plugin-types.ts +60 -5
  155. package/src/vite/plugins/cjs-to-esm.ts +5 -0
  156. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  157. package/src/vite/plugins/client-ref-hashing.ts +16 -4
  158. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  159. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  160. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  161. package/src/vite/plugins/expose-action-id.ts +52 -28
  162. package/src/vite/plugins/expose-id-utils.ts +12 -0
  163. package/src/vite/plugins/expose-ids/handler-transform.ts +30 -0
  164. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  165. package/src/vite/plugins/expose-internal-ids.ts +563 -316
  166. package/src/vite/plugins/performance-tracks.ts +96 -0
  167. package/src/vite/plugins/refresh-cmd.ts +88 -26
  168. package/src/vite/plugins/use-cache-transform.ts +56 -43
  169. package/src/vite/plugins/version-injector.ts +37 -11
  170. package/src/vite/rango.ts +63 -11
  171. package/src/vite/router-discovery.ts +732 -86
  172. package/src/vite/utils/banner.ts +1 -1
  173. package/src/vite/utils/package-resolution.ts +41 -1
  174. package/src/vite/utils/prerender-utils.ts +38 -5
  175. package/src/vite/utils/shared-utils.ts +3 -2
@@ -41,7 +41,11 @@ import {
41
41
  } from "./helpers.js";
42
42
  import { getRouterContext } from "../router-context.js";
43
43
  import { resolveSink, safeEmit } from "../telemetry.js";
44
- import { track } from "../../server/context.js";
44
+ import {
45
+ track,
46
+ RSCRouterContext,
47
+ runInsideLoaderScope,
48
+ } from "../../server/context.js";
45
49
 
46
50
  // ---------------------------------------------------------------------------
47
51
  // Telemetry helpers
@@ -85,6 +89,27 @@ function observeStreamedHandler(
85
89
  });
86
90
  }
87
91
 
92
+ /**
93
+ * Trace a parallel slot that's being force-rendered on a full refetch (client
94
+ * has no cached state). User revalidate fns are bypassed in this case — see
95
+ * the call sites for the load-bearing rationale.
96
+ */
97
+ function traceFullRefetchedParallelSlot(
98
+ parallelId: string,
99
+ belongsToRoute: boolean,
100
+ ): void {
101
+ if (!isTraceActive()) return;
102
+ pushRevalidationTraceEntry({
103
+ segmentId: parallelId,
104
+ segmentType: "parallel",
105
+ belongsToRoute,
106
+ source: "parallel",
107
+ defaultShouldRevalidate: true,
108
+ finalShouldRevalidate: true,
109
+ reason: "full-refetch",
110
+ });
111
+ }
112
+
88
113
  // ---------------------------------------------------------------------------
89
114
  // Revalidation telemetry helper
90
115
  // ---------------------------------------------------------------------------
@@ -232,7 +257,9 @@ export async function resolveLoadersWithRevalidation<TEnv>(
232
257
  params: ctx.params,
233
258
  loaderId: loader.$$id,
234
259
  loaderData: deps.wrapLoaderPromise(
235
- resolveLoaderData(loaderEntry, ctx, ctx.pathname),
260
+ runInsideLoaderScope(() =>
261
+ resolveLoaderData(loaderEntry, ctx, ctx.pathname),
262
+ ),
236
263
  entry,
237
264
  segmentId,
238
265
  ctx.pathname,
@@ -262,29 +289,46 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
262
289
  ): Promise<{ segments: ResolvedSegment[]; matchedIds: string[] }> {
263
290
  const allLoaderSegments: ResolvedSegment[] = [];
264
291
  const allMatchedIds: string[] = [];
292
+ const seenIds = new Set<string>();
265
293
 
266
294
  async function collectEntryLoaders(
267
295
  entry: EntryData,
268
296
  belongsToRoute: boolean,
269
297
  shortCodeOverride?: string,
270
298
  ): Promise<void> {
271
- const { segments, matchedIds } = await resolveLoadersWithRevalidation(
272
- entry,
273
- context,
274
- belongsToRoute,
275
- clientSegmentIds,
276
- prevParams,
277
- request,
278
- prevUrl,
279
- nextUrl,
280
- routeKey,
281
- deps,
282
- actionContext,
283
- shortCodeOverride,
284
- stale,
285
- );
286
- allLoaderSegments.push(...segments);
287
- allMatchedIds.push(...matchedIds);
299
+ // Skip if all loaders from this entry have already been resolved
300
+ // via a parent (e.g., cache boundary wrapping a layout with shared loaders).
301
+ const loaderEntries = entry.loader ?? [];
302
+ const sc = shortCodeOverride ?? entry.shortCode;
303
+ const allAlreadySeen =
304
+ loaderEntries.length > 0 &&
305
+ loaderEntries.every((le, i) =>
306
+ seenIds.has(`${sc}D${i}.${le.loader.$$id}`),
307
+ );
308
+ if (!allAlreadySeen) {
309
+ const { segments, matchedIds } = await resolveLoadersWithRevalidation(
310
+ entry,
311
+ context,
312
+ belongsToRoute,
313
+ clientSegmentIds,
314
+ prevParams,
315
+ request,
316
+ prevUrl,
317
+ nextUrl,
318
+ routeKey,
319
+ deps,
320
+ actionContext,
321
+ shortCodeOverride,
322
+ stale,
323
+ );
324
+ for (const seg of segments) {
325
+ if (!seenIds.has(seg.id)) {
326
+ seenIds.add(seg.id);
327
+ allLoaderSegments.push(seg);
328
+ }
329
+ }
330
+ allMatchedIds.push(...matchedIds);
331
+ }
288
332
 
289
333
  const seenParallelEntryIds = new Set<string>();
290
334
  for (const parallelEntry of getParallelEntries(entry.parallel)) {
@@ -296,6 +340,39 @@ export async function resolveLoadersOnlyWithRevalidation<TEnv>(
296
340
  const childBelongsToRoute = belongsToRoute || entry.type === "route";
297
341
  for (const layoutEntry of entry.layout) {
298
342
  await collectEntryLoaders(layoutEntry, childBelongsToRoute);
343
+ // Inherit route loaders for orphan layouts with parallels.
344
+ // Resolve directly — do NOT re-enter collectEntryLoaders with the
345
+ // route entry, as that would re-iterate route.layout and loop.
346
+ if (
347
+ entry.type === "route" &&
348
+ entry.loader &&
349
+ entry.loader.length > 0 &&
350
+ Object.keys(layoutEntry.parallel).length > 0
351
+ ) {
352
+ const inherited = await resolveLoadersWithRevalidation(
353
+ entry,
354
+ context,
355
+ childBelongsToRoute,
356
+ clientSegmentIds,
357
+ prevParams,
358
+ request,
359
+ prevUrl,
360
+ nextUrl,
361
+ routeKey,
362
+ deps,
363
+ actionContext,
364
+ layoutEntry.shortCode,
365
+ stale,
366
+ );
367
+ for (const seg of inherited.segments) {
368
+ if (!seenIds.has(seg.id)) {
369
+ seenIds.add(seg.id);
370
+ seg._inherited = true;
371
+ allLoaderSegments.push(seg);
372
+ }
373
+ }
374
+ allMatchedIds.push(...inherited.matchedIds);
375
+ }
299
376
  }
300
377
  }
301
378
 
@@ -392,44 +469,30 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
392
469
 
393
470
  const isFullRefetch = clientSegmentIds.size === 0;
394
471
  const isNewParent = !clientSegmentIds.has(entry.shortCode);
395
- if (
396
- isFullRefetch ||
397
- clientSegmentIds.has(parallelId) ||
398
- belongsToRoute ||
399
- isNewParent
400
- ) {
401
- matchedIds.push(parallelId);
402
- }
472
+ // Always announce the slot in matchedIds — it's unconditionally appended
473
+ // to `segments` below, and a segment present in segments but missing from
474
+ // matched lets the client prune it (then it's missing from clientSegmentIds
475
+ // on the next request, perpetuating the staleness).
476
+ matchedIds.push(parallelId);
403
477
 
404
- const shouldResolve = await (async () => {
405
- if (isFullRefetch) {
406
- if (isTraceActive()) {
407
- pushRevalidationTraceEntry({
408
- segmentId: parallelId,
409
- segmentType: "parallel",
410
- belongsToRoute,
411
- source: "parallel",
412
- defaultShouldRevalidate: true,
413
- finalShouldRevalidate: true,
414
- reason: "full-refetch",
415
- });
416
- }
417
- return true;
418
- }
478
+ let shouldResolve: boolean;
479
+ if (isFullRefetch) {
480
+ // Client has nothing cached — slot MUST render. User revalidate fns are
481
+ // bypassed here because returning false would leave the segment blank
482
+ // with no client-side fallback.
483
+ traceFullRefetchedParallelSlot(parallelId, belongsToRoute);
484
+ shouldResolve = true;
485
+ } else {
486
+ // For non-empty client sets, consult user revalidate fns. When the slot
487
+ // is unknown to the client, override the type-derived default so the
488
+ // soft chain seeds with the right "new segment" / "parent-chain" value.
489
+ let defaultOverride: { value: boolean; reason: string } | undefined;
419
490
  if (!clientSegmentIds.has(parallelId)) {
420
- const result = belongsToRoute || isNewParent;
421
- if (isTraceActive()) {
422
- pushRevalidationTraceEntry({
423
- segmentId: parallelId,
424
- segmentType: "parallel",
425
- belongsToRoute,
426
- source: "parallel",
427
- defaultShouldRevalidate: result,
428
- finalShouldRevalidate: result,
429
- reason: result ? "new-segment" : "skip-parent-chain",
430
- });
431
- }
432
- return result;
491
+ const value = belongsToRoute || isNewParent;
492
+ defaultOverride = {
493
+ value,
494
+ reason: value ? "new-segment" : "skip-parent-chain",
495
+ };
433
496
  }
434
497
 
435
498
  const dummySegment: ResolvedSegment = {
@@ -447,7 +510,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
447
510
  : {}),
448
511
  };
449
512
 
450
- return await evaluateRevalidation({
513
+ shouldResolve = await evaluateRevalidation({
451
514
  segment: dummySegment,
452
515
  prevParams,
453
516
  getPrevSegment: null,
@@ -463,8 +526,9 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
463
526
  actionContext,
464
527
  stale,
465
528
  traceSource: "parallel",
529
+ defaultOverride,
466
530
  });
467
- })();
531
+ }
468
532
  emitRevalidationDecision(
469
533
  parallelId,
470
534
  context.pathname,
@@ -473,8 +537,11 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
473
537
  );
474
538
 
475
539
  let component: ReactNode | undefined;
540
+ let handlerRan = false;
476
541
  if (shouldResolve) {
477
542
  component = await tryStaticSlot(parallelEntry, slot, parallelId);
543
+ // tryStaticSlot returning a value means the static cache supplied the
544
+ // component — handler did NOT run. handlerRan stays false.
478
545
  }
479
546
  if (component === undefined) {
480
547
  const hasLoadingFallback =
@@ -485,29 +552,37 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
485
552
  // Handler evicted (production static slot) but static lookup missed.
486
553
  // Nothing to render — use null so the client keeps its cached version.
487
554
  component = null;
488
- } else if (hasLoadingFallback) {
489
- const result =
490
- typeof handler === "function" ? handler(context) : handler;
491
- if (result instanceof Promise) {
492
- const tracked = deps.trackHandler(result, {
493
- segmentId: parallelId,
494
- segmentType: "parallel",
495
- });
496
- observeStreamedHandler(
497
- tracked,
498
- parallelId,
499
- "parallel",
500
- context.pathname,
501
- routeKey,
502
- params,
503
- );
504
- component = tracked as ReactNode;
555
+ } else {
556
+ // Slot-keyed pushes — slot owns its own bucket, parent layout owns
557
+ // its own. On slot-only revalidations the partial merge updates only
558
+ // the slot's bucket; the parent's bucket stays intact.
559
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
560
+ parallelId;
561
+ handlerRan = true;
562
+ if (hasLoadingFallback) {
563
+ const result =
564
+ typeof handler === "function" ? handler(context) : handler;
565
+ if (result instanceof Promise) {
566
+ const tracked = deps.trackHandler(result, {
567
+ segmentId: parallelId,
568
+ segmentType: "parallel",
569
+ });
570
+ observeStreamedHandler(
571
+ tracked,
572
+ parallelId,
573
+ "parallel",
574
+ context.pathname,
575
+ routeKey,
576
+ params,
577
+ );
578
+ component = tracked as ReactNode;
579
+ } else {
580
+ component = result as ReactNode;
581
+ }
505
582
  } else {
506
- component = result as ReactNode;
583
+ component =
584
+ typeof handler === "function" ? await handler(context) : handler;
507
585
  }
508
- } else {
509
- component =
510
- typeof handler === "function" ? await handler(context) : handler;
511
586
  }
512
587
  }
513
588
 
@@ -521,6 +596,7 @@ export async function resolveParallelSegmentsWithRevalidation<TEnv>(
521
596
  transition: parallelEntry.transition,
522
597
  params,
523
598
  slot,
599
+ _handlerRan: handlerRan,
524
600
  belongsToRoute,
525
601
  parallelName: `${parallelEntry.id}.${slot}`,
526
602
  ...(parallelEntry.mountPath
@@ -575,6 +651,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
575
651
  ): Promise<{ segment: ResolvedSegment; matchedId: string }> {
576
652
  const matchedId = entry.shortCode;
577
653
 
654
+ let handlerRan = false;
578
655
  const component = await revalidate(
579
656
  async () => {
580
657
  const hasSegment = clientSegmentIds.has(entry.shortCode);
@@ -651,6 +728,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
651
728
  return shouldRevalidate;
652
729
  },
653
730
  async () => {
731
+ handlerRan = true;
654
732
  const doneHandler = track(`handler:${entry.id}`, 2);
655
733
  (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
656
734
  entry.shortCode;
@@ -665,13 +743,20 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
665
743
  return staticComponent;
666
744
  }
667
745
  const routeEntry = entry as Extract<EntryData, { type: "route" }>;
746
+ // For Passthrough routes at runtime, use the live handler instead of
747
+ // the build handler. At build time (context.build === true), always
748
+ // use the build handler from routeEntry.handler.
749
+ const handler =
750
+ !context.build && routeEntry.liveHandler
751
+ ? routeEntry.liveHandler
752
+ : routeEntry.handler;
668
753
  if (!routeEntry.loading) {
669
- const result = handleHandlerResult(await routeEntry.handler(context));
754
+ const result = handleHandlerResult(await handler(context));
670
755
  doneHandler();
671
756
  return result;
672
757
  }
673
758
  if (!actionContext) {
674
- const result = handleHandlerResult(routeEntry.handler(context));
759
+ const result = handleHandlerResult(handler(context));
675
760
  if (result instanceof Promise) {
676
761
  result.finally(doneHandler).catch(() => {});
677
762
  const tracked = deps.trackHandler(result, {
@@ -694,9 +779,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
694
779
  debugLog("segment.action", "resolving action route with awaited value", {
695
780
  entryId: entry.id,
696
781
  });
697
- const actionResult = handleHandlerResult(
698
- await routeEntry.handler(context),
699
- );
782
+ const actionResult = handleHandlerResult(await handler(context));
700
783
  doneHandler();
701
784
  return {
702
785
  content: Promise.resolve(actionResult),
@@ -705,10 +788,12 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
705
788
  () => null,
706
789
  );
707
790
 
791
+ // Normalize void handlers (undefined) to null so the reconciler's
792
+ // component === null checks work consistently for both void and explicit null.
708
793
  const resolvedComponent =
709
794
  component && typeof component === "object" && "content" in component
710
- ? (component as { content: ReactNode }).content
711
- : component;
795
+ ? ((component as { content: ReactNode }).content ?? null)
796
+ : (component ?? null);
712
797
 
713
798
  const segment: ResolvedSegment = {
714
799
  id: entry.shortCode,
@@ -725,6 +810,7 @@ export async function resolveEntryHandlerWithRevalidation<TEnv>(
725
810
  ? { layoutName: entry.id }
726
811
  : {}),
727
812
  ...(entry.mountPath ? { mountPath: entry.mountPath } : {}),
813
+ _handlerRan: handlerRan,
728
814
  };
729
815
 
730
816
  return { segment, matchedId };
@@ -805,11 +891,11 @@ export async function resolveSegmentWithRevalidation<TEnv>(
805
891
  prevUrl,
806
892
  nextUrl,
807
893
  routeKey,
808
- loaderPromises,
809
894
  true,
810
895
  deps,
811
896
  actionContext,
812
897
  stale,
898
+ entry,
813
899
  );
814
900
  segments.push(...orphanResult.segments);
815
901
  matchedIds.push(...orphanResult.matchedIds);
@@ -889,7 +975,6 @@ export async function resolveSegmentWithRevalidation<TEnv>(
889
975
  prevUrl,
890
976
  nextUrl,
891
977
  routeKey,
892
- loaderPromises,
893
978
  false,
894
979
  deps,
895
980
  actionContext,
@@ -916,11 +1001,12 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
916
1001
  prevUrl: URL,
917
1002
  nextUrl: URL,
918
1003
  routeKey: string,
919
- loaderPromises: Map<string, Promise<any>>,
920
1004
  belongsToRoute: boolean,
921
1005
  deps: SegmentResolutionDeps<TEnv>,
922
1006
  actionContext?: ActionContext,
923
1007
  stale?: boolean,
1008
+ /** Parent route entry — its loaders are inherited so parallel slots can access them. */
1009
+ parentRouteEntry?: EntryData,
924
1010
  ): Promise<SegmentRevalidationResult> {
925
1011
  invariant(
926
1012
  orphan.type === "layout" || orphan.type === "cache",
@@ -948,6 +1034,37 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
948
1034
  segments.push(...loaderResult.segments);
949
1035
  matchedIds.push(...loaderResult.matchedIds);
950
1036
 
1037
+ // Inherit parent route's loaders so parallel slots inside this layout
1038
+ // can access them via useLoader(). See resolveOrphanLayout in fresh.ts.
1039
+ if (
1040
+ parentRouteEntry &&
1041
+ parentRouteEntry.loader &&
1042
+ parentRouteEntry.loader.length > 0 &&
1043
+ Object.keys(orphan.parallel).length > 0
1044
+ ) {
1045
+ const inheritedResult = await resolveLoadersWithRevalidation(
1046
+ parentRouteEntry,
1047
+ context,
1048
+ belongsToRoute,
1049
+ clientSegmentIds,
1050
+ prevParams,
1051
+ request,
1052
+ prevUrl,
1053
+ nextUrl,
1054
+ routeKey,
1055
+ deps,
1056
+ actionContext,
1057
+ orphan.shortCode,
1058
+ stale,
1059
+ );
1060
+ // Tag as inherited so buildMatchResult can deduplicate when safe
1061
+ for (const s of inheritedResult.segments) {
1062
+ s._inherited = true;
1063
+ }
1064
+ segments.push(...inheritedResult.segments);
1065
+ matchedIds.push(...inheritedResult.matchedIds);
1066
+ }
1067
+
951
1068
  // Handler-first: resolve orphan layout handler before its parallels
952
1069
  // so ctx.set() values are visible to parallel children.
953
1070
  matchedIds.push(orphan.shortCode);
@@ -1034,6 +1151,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1034
1151
  );
1035
1152
 
1036
1153
  if (!resolvedParallelEntries.has(parallelEntry.id)) {
1154
+ // shortCodeOverride must match the parent layout, not the parallel entry.
1037
1155
  const loaderResult = await resolveLoadersWithRevalidation(
1038
1156
  parallelEntry,
1039
1157
  context,
@@ -1046,7 +1164,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1046
1164
  routeKey,
1047
1165
  deps,
1048
1166
  actionContext,
1049
- undefined,
1167
+ orphan.shortCode,
1050
1168
  stale,
1051
1169
  );
1052
1170
  segments.push(...loaderResult.segments);
@@ -1068,21 +1186,20 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1068
1186
  const parallelId = `${orphan.shortCode}.${slot}`;
1069
1187
  matchedIds.push(parallelId);
1070
1188
 
1071
- const shouldResolve = await (async () => {
1072
- if (!clientSegmentIds.has(parallelId)) {
1073
- if (isTraceActive()) {
1074
- pushRevalidationTraceEntry({
1075
- segmentId: parallelId,
1076
- segmentType: "parallel",
1077
- belongsToRoute,
1078
- source: "parallel",
1079
- defaultShouldRevalidate: true,
1080
- finalShouldRevalidate: true,
1081
- reason: "new-segment",
1082
- });
1083
- }
1084
- return true;
1085
- }
1189
+ const isFullRefetch = clientSegmentIds.size === 0;
1190
+ let shouldResolve: boolean;
1191
+ if (isFullRefetch) {
1192
+ // Same load-bearing rationale as the main parallel path: full refetch
1193
+ // means the client has nothing to fall back to, so the slot must render.
1194
+ traceFullRefetchedParallelSlot(parallelId, belongsToRoute);
1195
+ shouldResolve = true;
1196
+ } else {
1197
+ // When slot is unknown to the client, seed the soft chain with `true`
1198
+ // (orphan parallels always belong to the route — we want them rendered
1199
+ // unless the user explicitly opts out via revalidate()).
1200
+ const defaultOverride = clientSegmentIds.has(parallelId)
1201
+ ? undefined
1202
+ : { value: true, reason: "new-segment" };
1086
1203
 
1087
1204
  const dummySegment: ResolvedSegment = {
1088
1205
  id: parallelId,
@@ -1099,7 +1216,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1099
1216
  : {}),
1100
1217
  };
1101
1218
 
1102
- return await evaluateRevalidation({
1219
+ shouldResolve = await evaluateRevalidation({
1103
1220
  segment: dummySegment,
1104
1221
  prevParams,
1105
1222
  getPrevSegment: null,
@@ -1115,8 +1232,9 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1115
1232
  actionContext,
1116
1233
  stale,
1117
1234
  traceSource: "parallel",
1235
+ defaultOverride,
1118
1236
  });
1119
- })();
1237
+ }
1120
1238
  emitRevalidationDecision(
1121
1239
  parallelId,
1122
1240
  context.pathname,
@@ -1125,6 +1243,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1125
1243
  );
1126
1244
 
1127
1245
  let component: ReactNode | undefined;
1246
+ let handlerRan = false;
1128
1247
  if (shouldResolve) {
1129
1248
  component = await tryStaticSlot(parallelEntry, slot, parallelId);
1130
1249
  }
@@ -1136,29 +1255,35 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1136
1255
  } else if (handler === undefined) {
1137
1256
  // Handler evicted (production static slot) but static lookup missed.
1138
1257
  component = null;
1139
- } else if (hasLoadingFallback) {
1140
- const result =
1141
- typeof handler === "function" ? handler(context) : handler;
1142
- if (result instanceof Promise) {
1143
- const tracked = deps.trackHandler(result, {
1144
- segmentId: parallelId,
1145
- segmentType: "parallel",
1146
- });
1147
- observeStreamedHandler(
1148
- tracked,
1149
- parallelId,
1150
- "parallel",
1151
- context.pathname,
1152
- routeKey,
1153
- params,
1154
- );
1155
- component = tracked as ReactNode;
1258
+ } else {
1259
+ // Slot-keyed pushes — see resolveParallelSegmentsWithRevalidation.
1260
+ (context as InternalHandlerContext<any, TEnv>)._currentSegmentId =
1261
+ parallelId;
1262
+ handlerRan = true;
1263
+ if (hasLoadingFallback) {
1264
+ const result =
1265
+ typeof handler === "function" ? handler(context) : handler;
1266
+ if (result instanceof Promise) {
1267
+ const tracked = deps.trackHandler(result, {
1268
+ segmentId: parallelId,
1269
+ segmentType: "parallel",
1270
+ });
1271
+ observeStreamedHandler(
1272
+ tracked,
1273
+ parallelId,
1274
+ "parallel",
1275
+ context.pathname,
1276
+ routeKey,
1277
+ params,
1278
+ );
1279
+ component = tracked as ReactNode;
1280
+ } else {
1281
+ component = result as ReactNode;
1282
+ }
1156
1283
  } else {
1157
- component = result as ReactNode;
1284
+ component =
1285
+ typeof handler === "function" ? await handler(context) : handler;
1158
1286
  }
1159
- } else {
1160
- component =
1161
- typeof handler === "function" ? await handler(context) : handler;
1162
1287
  }
1163
1288
  }
1164
1289
 
@@ -1172,6 +1297,7 @@ export async function resolveOrphanLayoutWithRevalidation<TEnv>(
1172
1297
  transition: parallelEntry.transition,
1173
1298
  params,
1174
1299
  slot,
1300
+ _handlerRan: handlerRan,
1175
1301
  belongsToRoute,
1176
1302
  parallelName: `${parallelEntry.id}.${slot}`,
1177
1303
  ...(parallelEntry.mountPath
@@ -1229,6 +1355,10 @@ export async function resolveAllSegmentsWithRevalidation<TEnv>(
1229
1355
  }
1230
1356
 
1231
1357
  const nonParallelEntry = entry as Exclude<EntryData, { type: "parallel" }>;
1358
+ if (entry.type === "cache") {
1359
+ const store = RSCRouterContext.getStore();
1360
+ if (store) store.insideCacheScope = true;
1361
+ }
1232
1362
  const doneEntry = track(`segment:${entry.id}`, 1);
1233
1363
  const resolved = await resolveWithErrorBoundary(
1234
1364
  nonParallelEntry,
@@ -6,6 +6,7 @@
6
6
  */
7
7
 
8
8
  import type { TrieNode, TrieLeaf } from "../build/route-trie.js";
9
+ import { safeDecodeURIComponent } from "./url-params.js";
9
10
 
10
11
  export interface TrieMatchResult {
11
12
  /** Route name */
@@ -14,7 +15,9 @@ export interface TrieMatchResult {
14
15
  sp: string;
15
16
  /** Matched route params */
16
17
  params: Record<string, string>;
17
- /** Optional param names (absent params have empty string value) */
18
+ /** Optional param names declared on the route. Absent params are omitted
19
+ * from `params` (read as `undefined`), matching the
20
+ * `ExtractParams<"/:locale?/...">` type. */
18
21
  optionalParams?: string[];
19
22
  /** Ancestry shortCodes for layout pruning */
20
23
  ancestry: string[];
@@ -173,20 +176,25 @@ function validateAndBuild(
173
176
  originalPathname: string,
174
177
  pathnameHasTrailingSlash: boolean,
175
178
  ): TrieMatchResult | null {
176
- // Build named params by zipping leaf.pa with positional paramValues
179
+ // Build named params by zipping leaf.pa with positional paramValues.
180
+ // Params are URL-decoded at this boundary so ctx.params holds the values
181
+ // apps expect (matching Express/React Router) and round-trip cleanly
182
+ // through ctx.reverse.
177
183
  const params: Record<string, string> = {};
178
184
  if (leaf.pa) {
179
185
  for (let i = 0; i < leaf.pa.length && i < paramValues.length; i++) {
180
- params[leaf.pa[i]] = paramValues[i];
186
+ params[leaf.pa[i]] = safeDecodeURIComponent(paramValues[i]);
181
187
  }
182
188
  }
183
189
 
184
190
  // Add wildcard param (wildcard leaves have pn from TrieNode.w type)
185
191
  if (wildcardValue !== undefined && "pn" in leaf) {
186
- params[(leaf as TrieLeaf & { pn: string }).pn] = wildcardValue;
192
+ params[(leaf as TrieLeaf & { pn: string }).pn] =
193
+ safeDecodeURIComponent(wildcardValue);
187
194
  }
188
195
 
189
- // Validate constraints
196
+ // Validate constraints against decoded values so constraint lists can be
197
+ // written in decoded form (e.g. ["en-GB", "en US"]).
190
198
  if (leaf.cv) {
191
199
  for (const paramName in leaf.cv) {
192
200
  const allowed = leaf.cv[paramName]!;
@@ -197,14 +205,11 @@ function validateAndBuild(
197
205
  }
198
206
  }
199
207
 
200
- // Fill in empty strings for optional params that weren't matched
201
- if (leaf.op) {
202
- for (const name of leaf.op) {
203
- if (!(name in params)) {
204
- params[name] = "";
205
- }
206
- }
207
- }
208
+ // Optional params that weren't matched are left absent from `params` so
209
+ // `ctx.params.locale` reads as `undefined`, matching the
210
+ // `ExtractParams<"/:locale?/...">` type (`{ locale?: string }`). Both
211
+ // internal consumers — the constraint check above and `reverse()`
212
+ // already treat missing/undefined as the absent form.
208
213
 
209
214
  // Trailing slash handling
210
215
  const tsMode = leaf.ts as "never" | "always" | "ignore" | undefined;