@rangojs/router 0.0.0-experimental.e9c0b2f2 → 0.0.0-experimental.ea9f40f2

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 (222) hide show
  1. package/AGENTS.md +6 -10
  2. package/README.md +289 -938
  3. package/dist/bin/rango.js +271 -46
  4. package/dist/vite/index.js +673 -193
  5. package/package.json +10 -8
  6. package/skills/api-client/SKILL.md +1 -1
  7. package/skills/breadcrumbs/SKILL.md +31 -14
  8. package/skills/cache-guide/SKILL.md +5 -2
  9. package/skills/caching/SKILL.md +59 -4
  10. package/skills/catalog.json +271 -0
  11. package/skills/comparison/SKILL.md +50 -0
  12. package/skills/comparison/agents/openai.yaml +4 -0
  13. package/skills/comparison/references/framework-comparison.md +837 -0
  14. package/skills/composability/SKILL.md +83 -2
  15. package/skills/debug-manifest/SKILL.md +1 -1
  16. package/skills/defer-hydration/SKILL.md +235 -0
  17. package/skills/document-cache/SKILL.md +9 -1
  18. package/skills/fonts/SKILL.md +1 -1
  19. package/skills/handler-use/SKILL.md +8 -8
  20. package/skills/hooks/SKILL.md +54 -892
  21. package/skills/hooks/data.md +273 -0
  22. package/skills/hooks/handle-and-actions.md +103 -0
  23. package/skills/hooks/navigation.md +110 -0
  24. package/skills/hooks/outlets.md +41 -0
  25. package/skills/hooks/state.md +228 -0
  26. package/skills/hooks/urls.md +135 -0
  27. package/skills/host-router/SKILL.md +4 -4
  28. package/skills/i18n/SKILL.md +1 -1
  29. package/skills/intercept/SKILL.md +46 -14
  30. package/skills/layout/SKILL.md +27 -10
  31. package/skills/links/SKILL.md +1 -1
  32. package/skills/loader/SKILL.md +23 -1
  33. package/skills/middleware/SKILL.md +7 -3
  34. package/skills/migrate-nextjs/SKILL.md +167 -6
  35. package/skills/migrate-react-router/SKILL.md +59 -677
  36. package/skills/migrate-react-router/cloudflare-workers.md +129 -0
  37. package/skills/migrate-react-router/component-migration.md +196 -0
  38. package/skills/migrate-react-router/data-and-actions.md +225 -0
  39. package/skills/migrate-react-router/route-mapping.md +271 -0
  40. package/skills/mime-routes/SKILL.md +1 -1
  41. package/skills/observability/SKILL.md +9 -1
  42. package/skills/parallel/SKILL.md +23 -4
  43. package/skills/ppr/SKILL.md +622 -0
  44. package/skills/prerender/SKILL.md +28 -18
  45. package/skills/rango/SKILL.md +84 -25
  46. package/skills/response-routes/SKILL.md +15 -1
  47. package/skills/route/SKILL.md +71 -4
  48. package/skills/router-setup/SKILL.md +14 -3
  49. package/skills/scripts/SKILL.md +1 -1
  50. package/skills/server-actions/SKILL.md +3 -2
  51. package/skills/shell-manifest/SKILL.md +185 -0
  52. package/skills/streams-and-websockets/SKILL.md +1 -1
  53. package/skills/tailwind/SKILL.md +1 -1
  54. package/skills/testing/SKILL.md +2 -1
  55. package/skills/testing/handles.md +4 -2
  56. package/skills/testing/render-handler.md +15 -14
  57. package/skills/testing/reverse-and-types.md +8 -7
  58. package/skills/theme/SKILL.md +1 -1
  59. package/skills/typesafety/SKILL.md +45 -919
  60. package/skills/typesafety/env-and-bindings.md +254 -0
  61. package/skills/typesafety/generated-files-and-cli.md +335 -0
  62. package/skills/typesafety/params-and-search.md +153 -0
  63. package/skills/typesafety/route-types.md +209 -0
  64. package/skills/use-cache/SKILL.md +30 -3
  65. package/skills/vercel/SKILL.md +1 -1
  66. package/skills/view-transitions/SKILL.md +44 -1
  67. package/src/browser/event-controller.ts +62 -10
  68. package/src/browser/logging.ts +28 -0
  69. package/src/browser/merge-segment-loaders.ts +6 -4
  70. package/src/browser/navigation-bridge.ts +65 -16
  71. package/src/browser/navigation-client.ts +32 -2
  72. package/src/browser/navigation-store.ts +128 -14
  73. package/src/browser/network-error-handler.ts +34 -7
  74. package/src/browser/partial-update.ts +76 -17
  75. package/src/browser/prefetch/cache.ts +51 -11
  76. package/src/browser/prefetch/fetch.ts +59 -21
  77. package/src/browser/prefetch/queue.ts +19 -4
  78. package/src/browser/react/Link.tsx +13 -3
  79. package/src/browser/react/NavigationProvider.tsx +108 -4
  80. package/src/browser/response-adapter.ts +38 -9
  81. package/src/browser/rsc-router.tsx +54 -4
  82. package/src/browser/scroll-restoration.ts +7 -5
  83. package/src/browser/segment-reconciler.ts +31 -21
  84. package/src/browser/server-action-bridge.ts +22 -10
  85. package/src/browser/types.ts +54 -1
  86. package/src/build/generate-manifest.ts +155 -131
  87. package/src/build/index.ts +3 -1
  88. package/src/build/route-trie.ts +35 -7
  89. package/src/build/route-types/include-resolution.ts +347 -47
  90. package/src/build/runtime-discovery.ts +4 -1
  91. package/src/cache/cache-key-utils.ts +29 -0
  92. package/src/cache/cache-runtime.ts +262 -71
  93. package/src/cache/cache-scope.ts +2 -17
  94. package/src/cache/cache-tag.ts +60 -14
  95. package/src/cache/cf/cf-cache-store.ts +243 -20
  96. package/src/cache/document-cache.ts +54 -21
  97. package/src/cache/index.ts +1 -0
  98. package/src/cache/memory-segment-store.ts +110 -3
  99. package/src/cache/profile-registry.ts +15 -0
  100. package/src/cache/read-through-swr.ts +15 -1
  101. package/src/cache/segment-codec.ts +4 -4
  102. package/src/cache/shell-snapshot.ts +417 -0
  103. package/src/cache/types.ts +158 -0
  104. package/src/cache/vercel/vercel-cache-store.ts +401 -124
  105. package/src/client.rsc.tsx +0 -3
  106. package/src/client.tsx +0 -3
  107. package/src/cloudflare/tracing.ts +7 -8
  108. package/src/defer.ts +11 -22
  109. package/src/handle.ts +37 -15
  110. package/src/handles/MetaTags.tsx +16 -82
  111. package/src/handles/breadcrumbs.ts +12 -14
  112. package/src/handles/deferred-resolution.ts +127 -0
  113. package/src/handles/is-thenable.ts +7 -8
  114. package/src/handles/meta.ts +7 -44
  115. package/src/host/errors.ts +15 -0
  116. package/src/host/index.ts +1 -0
  117. package/src/index.rsc.ts +8 -2
  118. package/src/index.ts +19 -13
  119. package/src/internal-debug.ts +11 -8
  120. package/src/prerender.ts +17 -4
  121. package/src/redirect-origin.ts +14 -0
  122. package/src/render-error-thrower.tsx +20 -0
  123. package/src/route-content-wrapper.tsx +12 -5
  124. package/src/route-definition/dsl-helpers.ts +21 -32
  125. package/src/route-definition/helper-factories.ts +0 -2
  126. package/src/route-definition/helpers-types.ts +43 -43
  127. package/src/route-definition/index.ts +1 -2
  128. package/src/route-definition/resolve-handler-use.ts +0 -1
  129. package/src/route-definition/use-item-types.ts +3 -6
  130. package/src/route-map-builder.ts +41 -4
  131. package/src/route-types.ts +0 -5
  132. package/src/router/find-match.ts +86 -8
  133. package/src/router/instrument.ts +9 -4
  134. package/src/router/lazy-includes.ts +72 -12
  135. package/src/router/loader-resolution.ts +14 -2
  136. package/src/router/manifest.ts +56 -11
  137. package/src/router/match-api.ts +76 -32
  138. package/src/router/match-handlers.ts +181 -135
  139. package/src/router/match-middleware/background-revalidation.ts +40 -23
  140. package/src/router/match-middleware/cache-store.ts +39 -24
  141. package/src/router/match-result.ts +35 -15
  142. package/src/router/middleware.ts +64 -38
  143. package/src/router/navigation-snapshot.ts +7 -5
  144. package/src/router/parse-pattern.ts +115 -0
  145. package/src/router/pattern-matching.ts +53 -64
  146. package/src/router/prefetch-limits.ts +37 -0
  147. package/src/router/prerender-match.ts +11 -5
  148. package/src/router/preview-match.ts +3 -1
  149. package/src/router/request-classification.ts +23 -8
  150. package/src/router/route-snapshot.ts +14 -2
  151. package/src/router/router-context.ts +3 -1
  152. package/src/router/router-interfaces.ts +32 -1
  153. package/src/router/router-options.ts +30 -0
  154. package/src/router/segment-resolution/fresh.ts +39 -3
  155. package/src/router/segment-resolution/loader-cache.ts +93 -2
  156. package/src/router/segment-resolution/loader-mask.ts +60 -0
  157. package/src/router/segment-resolution/loader-snapshot.ts +259 -0
  158. package/src/router/segment-resolution/mask-nested.ts +83 -0
  159. package/src/router/segment-resolution/revalidation.ts +3 -0
  160. package/src/router/segment-resolution/view-transition-default.ts +35 -15
  161. package/src/router/substitute-pattern-params.ts +54 -35
  162. package/src/router/telemetry-otel.ts +6 -8
  163. package/src/router/telemetry.ts +9 -1
  164. package/src/router/tracing.ts +14 -5
  165. package/src/router/trie-matching.ts +19 -11
  166. package/src/router/url-params.ts +13 -0
  167. package/src/router.ts +47 -16
  168. package/src/rsc/full-payload.ts +70 -0
  169. package/src/rsc/handler.ts +60 -33
  170. package/src/rsc/manifest-init.ts +1 -1
  171. package/src/rsc/nonce.ts +10 -1
  172. package/src/rsc/progressive-enhancement.ts +61 -4
  173. package/src/rsc/redirect-guard.ts +2 -1
  174. package/src/rsc/rsc-rendering.ts +429 -37
  175. package/src/rsc/server-action.ts +25 -2
  176. package/src/rsc/shell-capture.ts +1190 -0
  177. package/src/rsc/shell-serve.ts +181 -0
  178. package/src/rsc/transition-gate.ts +89 -0
  179. package/src/rsc/types.ts +30 -0
  180. package/src/segment-loader-promise.ts +18 -0
  181. package/src/segment-system.tsx +149 -14
  182. package/src/server/context.ts +67 -9
  183. package/src/server/cookie-store.ts +73 -1
  184. package/src/server/loader-registry.ts +13 -1
  185. package/src/server/request-context.ts +169 -10
  186. package/src/ssr/index.tsx +462 -178
  187. package/src/ssr/inject-rsc-eager.ts +167 -0
  188. package/src/ssr/ssr-root.tsx +228 -0
  189. package/src/testing/collect-handle.ts +14 -8
  190. package/src/testing/dispatch.ts +152 -40
  191. package/src/testing/generated-routes.ts +27 -11
  192. package/src/testing/index.ts +6 -0
  193. package/src/testing/render-handler.ts +14 -0
  194. package/src/testing/render-route.tsx +13 -10
  195. package/src/testing/run-transition-when.ts +164 -0
  196. package/src/theme/ThemeProvider.tsx +36 -26
  197. package/src/types/handler-context.ts +1 -1
  198. package/src/types/index.ts +2 -0
  199. package/src/types/route-config.ts +19 -7
  200. package/src/types/segments.ts +100 -0
  201. package/src/urls/include-helper.ts +10 -8
  202. package/src/urls/include-provider.ts +71 -0
  203. package/src/urls/index.ts +1 -0
  204. package/src/urls/path-helper-types.ts +44 -12
  205. package/src/urls/path-helper.ts +5 -0
  206. package/src/urls/pattern-types.ts +36 -0
  207. package/src/urls/type-extraction.ts +43 -18
  208. package/src/urls/urls-function.ts +0 -1
  209. package/src/vercel/tracing.ts +7 -7
  210. package/src/vite/discovery/dev-prerender-cache.ts +117 -0
  211. package/src/vite/discovery/discover-routers.ts +1 -1
  212. package/src/vite/discovery/discovery-errors.ts +61 -0
  213. package/src/vite/index.ts +7 -0
  214. package/src/vite/inject-client-debug.ts +88 -0
  215. package/src/vite/plugins/vercel-output.ts +114 -25
  216. package/src/vite/plugins/version-injector.ts +22 -7
  217. package/src/vite/plugins/virtual-entries.ts +80 -22
  218. package/src/vite/rango.ts +29 -19
  219. package/src/vite/router-discovery.ts +171 -43
  220. package/src/vite/utils/prerender-utils.ts +17 -4
  221. package/src/vite/utils/shared-utils.ts +47 -0
  222. package/src/network-error-thrower.tsx +0 -18
@@ -19,6 +19,10 @@ import {
19
19
  createScanFilter,
20
20
  } from "../build/generate-route-types.js";
21
21
  import { firstCodeMatchIndex } from "../build/route-types/source-scan.js";
22
+ import {
23
+ injectClientDebugFlag,
24
+ internalDebugNoCacheMiddleware,
25
+ } from "./inject-client-debug.js";
22
26
  import { createVersionPlugin } from "./plugins/version-plugin.js";
23
27
  import { createVirtualStubPlugin } from "./plugins/virtual-stub-plugin.js";
24
28
  import {
@@ -42,6 +46,12 @@ import {
42
46
  peekSelfGenWrite,
43
47
  } from "./discovery/self-gen-tracking.js";
44
48
  import { discoverRouters } from "./discovery/discover-routers.js";
49
+ import { describeDiscoveryFailure } from "./discovery/discovery-errors.js";
50
+ import {
51
+ createDevPrerenderCache,
52
+ devPrerenderCacheKey,
53
+ payloadBodiesFromResult,
54
+ } from "./discovery/dev-prerender-cache.js";
45
55
  import {
46
56
  writeCombinedRouteTypesWithTracking,
47
57
  writeRouteTypesFiles,
@@ -310,19 +320,13 @@ export function createRouterDiscoveryPlugin(
310
320
  return {
311
321
  name: "@rangojs/router:discovery",
312
322
 
313
- config() {
314
- const config: any = {
315
- define: {
316
- __RANGO_DEBUG__: JSON.stringify(!!process.env.INTERNAL_RANGO_DEBUG),
317
- },
318
- };
319
- // Prerender/static handler modules are bundled naturally with the
320
- // rest of the RSC entry. A previous design forced them into dedicated
321
- // __prerender-handlers / __static-handlers chunks via manualChunks,
322
- // but Rollup hoisted all shared dependencies into those chunks,
323
- // inflating them to ~1 MB with active runtime code. Handler code is
324
- // evicted in closeBundle regardless of which chunk it lands in.
325
- return config;
323
+ // Make INTERNAL_RANGO_DEBUG reach the CLIENT debug logs by just setting the
324
+ // env var. See injectClientDebugFlag: bakes the resolved flag into the
325
+ // internal-debug module so FE debug no longer depends on Vite delivering the
326
+ // `__RANGO_DEBUG__` define to the client (which it does only as an injected
327
+ // global whose presence varies across consumer setups). Runs in dev and build.
328
+ transform(_code, id) {
329
+ return injectClientDebugFlag(id);
326
330
  },
327
331
 
328
332
  configResolved(config) {
@@ -391,6 +395,11 @@ export function createRouterDiscoveryPlugin(
391
395
  if ((globalThis as any).__rscRouterDiscoveryActive) return;
392
396
  s.devServer = server;
393
397
 
398
+ // Serve the internal-debug module no-cache: consumers resolve it into
399
+ // node_modules, where dev's immutable `?v=` caching pinned browsers to a
400
+ // stale baked INTERNAL_RANGO_DEBUG. See internalDebugNoCacheMiddleware.
401
+ server.middlewares.use(internalDebugNoCacheMiddleware());
402
+
394
403
  // Discovery promise that the handler can await if requests arrive
395
404
  // before discovery completes
396
405
  let resolveDiscovery: () => void;
@@ -549,6 +558,14 @@ export function createRouterDiscoveryPlugin(
549
558
  );
550
559
  console.warn(`[rango] Failed to create temp runner: ${err.message}`);
551
560
  }
561
+ // Reached only on failure (runner unavailable, or create/import threw
562
+ // AFTER the server was created). Close the just-created server so a
563
+ // failed discovery does not leak it until the next call or dev shutdown,
564
+ // and null the refs so the reuse path above starts clean. Mirrors the
565
+ // close pattern used when an existing server is discarded (above).
566
+ await prerenderTempServer?.close().catch(() => {});
567
+ prerenderTempServer = null;
568
+ prerenderNodeRegistry = null;
552
569
  return null;
553
570
  }
554
571
 
@@ -650,6 +667,30 @@ export function createRouterDiscoveryPlugin(
650
667
  return tempRscEnv;
651
668
  }
652
669
 
670
+ // Surface a discovery failure on either dev path (Node RSC runner or the
671
+ // Cloudflare temp Node server). `hashBefore`/`hashAfter` are the discovering
672
+ // environment's dep-optimizer browserHash snapshots: a change across the
673
+ // attempt means a reload-causing re-optimization landed mid-flight, so an
674
+ // empty registry was the transient race (downgraded to a warning) rather
675
+ // than a genuine misconfig (loud, actionable error). Shared so both catch
676
+ // sites frame the same failure identically.
677
+ const emitDiscoveryFailure = (
678
+ err: unknown,
679
+ hashBefore: string | undefined,
680
+ hashAfter: string | undefined,
681
+ ): void => {
682
+ const reoptimizeObserved =
683
+ hashBefore !== undefined &&
684
+ hashAfter !== undefined &&
685
+ hashBefore !== hashAfter;
686
+ const report = describeDiscoveryFailure(err, { reoptimizeObserved });
687
+ if (report.level === "warn") {
688
+ console.warn(report.message);
689
+ } else {
690
+ console.error(report.message);
691
+ }
692
+ };
693
+
653
694
  const discover = async () => {
654
695
  const discoverStart = performance.now();
655
696
  const rscEnv = (server.environments as any)?.rsc;
@@ -665,18 +706,26 @@ export function createRouterDiscoveryPlugin(
665
706
 
666
707
  // Create a temp Node.js server to run runtime discovery and generate
667
708
  // named route types (static parser can't resolve factory calls).
709
+ // The temp server is a separate Vite instance with its own dep
710
+ // optimizer; snapshot ITS browserHash (hoisted so the catch can tell a
711
+ // transient re-optimization apart from a genuine empty registry, the
712
+ // same way the Node path below does).
713
+ let tempRscEnv: any;
714
+ let optimizerHashBefore: string | undefined;
668
715
  try {
669
716
  // Acquire build-time env bindings for dev prerender
670
717
  await timed(debugDiscovery, "acquireBuildEnv", () =>
671
718
  acquireBuildEnv(s, viteCommand, viteMode),
672
719
  );
673
720
 
674
- const tempRscEnv = await timed(
721
+ tempRscEnv = await timed(
675
722
  debugDiscovery,
676
723
  "getOrCreateTempServer",
677
724
  () => getOrCreateTempServer(),
678
725
  );
679
726
  if (tempRscEnv) {
727
+ optimizerHashBefore =
728
+ tempRscEnv.depsOptimizer?.metadata?.browserHash;
680
729
  await timed(debugDiscovery, "discoverRouters (cloudflare)", () =>
681
730
  discoverRouters(s, tempRscEnv),
682
731
  );
@@ -685,8 +734,10 @@ export function createRouterDiscoveryPlugin(
685
734
  );
686
735
  }
687
736
  } catch (err: any) {
688
- console.warn(
689
- `[rango] Cloudflare dev discovery failed: ${err.message}\n${err.stack}`,
737
+ emitDiscoveryFailure(
738
+ err,
739
+ optimizerHashBefore,
740
+ tempRscEnv?.depsOptimizer?.metadata?.browserHash,
690
741
  );
691
742
  }
692
743
 
@@ -698,6 +749,13 @@ export function createRouterDiscoveryPlugin(
698
749
  return;
699
750
  }
700
751
 
752
+ // Snapshot the dep-optimizer hash before discovery so the catch can tell
753
+ // a transient re-optimization race apart from a genuine empty registry.
754
+ // A reload-causing re-optimization regenerates browserHash; if it changed
755
+ // across the attempt, an empty read was almost certainly the race below.
756
+ const optimizerHashBefore: string | undefined =
757
+ rscEnv.depsOptimizer?.metadata?.browserHash;
758
+
701
759
  try {
702
760
  // Acquire build-time env bindings for dev prerender (Node.js path)
703
761
  debugDiscovery?.("dev: node path start");
@@ -705,21 +763,31 @@ export function createRouterDiscoveryPlugin(
705
763
  acquireBuildEnv(s, viteCommand, viteMode),
706
764
  );
707
765
 
708
- // Set the readiness gate BEFORE discovery so early requests
709
- // block until manifest is populated
710
- const serverMod = await timed(
711
- debugDiscovery,
712
- "import @rangojs/router/server",
713
- () => rscEnv.runner.import("@rangojs/router/server"),
766
+ // Discover routers FIRST, then arm the manifest-readiness gate on the
767
+ // server module discovery actually read the registry from.
768
+ //
769
+ // We deliberately do NOT pre-import "@rangojs/router/server" before the
770
+ // entry to arm the gate early. During a Vite dependency re-optimization
771
+ // (dev boot after a lockfile change, or `vite dev --force`), a module
772
+ // imported here before the entry resolves to the pre-optimize copy of
773
+ // the runner's module graph, while discoverRouters' entry import — which
774
+ // awaits the in-flight re-optimization — resolves to the post-optimize
775
+ // copy. createRouter() then populates RouterRegistry on the fresh copy,
776
+ // but a stale pre-imported "@rangojs/router/server" reads the other
777
+ // copy's empty Map and discovery throws a spurious "No routers found"
778
+ // even though the app is configured correctly. discoverRouters imports
779
+ // the entry first and reads the registry off the same instance, keeping
780
+ // read and write on one copy. The virtual manifest module's own gate
781
+ // (s.discoveryDone, armed by beginDiscoveryGate) already blocks early
782
+ // requests during discovery on the Node path, so arming
783
+ // manifestReadyPromise after discovery is sufficient here.
784
+ const serverMod = await timed(debugDiscovery, "discoverRouters", () =>
785
+ discoverRouters(s, rscEnv),
714
786
  );
715
787
  if (serverMod?.setManifestReadyPromise) {
716
788
  serverMod.setManifestReadyPromise(discoveryPromise);
717
789
  }
718
790
 
719
- await timed(debugDiscovery, "discoverRouters", () =>
720
- discoverRouters(s, rscEnv),
721
- );
722
-
723
791
  // Store server origin for dev prerender endpoint (virtual module injection)
724
792
  s.devServerOrigin = getDevServerOrigin();
725
793
 
@@ -737,8 +805,10 @@ export function createRouterDiscoveryPlugin(
737
805
  propagateDiscoveryState(rscEnv),
738
806
  );
739
807
  } catch (err: any) {
740
- console.warn(
741
- `[rango] Router discovery failed: ${err.message}\n${err.stack}`,
808
+ emitDiscoveryFailure(
809
+ err,
810
+ optimizerHashBefore,
811
+ rscEnv.depsOptimizer?.metadata?.browserHash,
742
812
  );
743
813
  } finally {
744
814
  debugDiscovery?.(
@@ -771,6 +841,15 @@ export function createRouterDiscoveryPlugin(
771
841
  // Registry from the main server's RSC environment (populated by discoverRouters)
772
842
  let mainRegistry: Map<string, any> | null = null;
773
843
 
844
+ // Memoized /__rsc_prerender render results, keyed by router-instance
845
+ // identity (#654). The per-request entry re-import below is what makes
846
+ // identity a valid freshness key: an HMR-invalidated chain re-runs
847
+ // createRouter() and replaces the registry instance, so cached bodies
848
+ // for the old instance become unreachable; an untouched chain returns
849
+ // the same instance and the cached body is byte-identical to a fresh
850
+ // render. See dev-prerender-cache.ts for the full invariant.
851
+ const devPrerenderCache = createDevPrerenderCache();
852
+
774
853
  // Push discovery state (manifest, trie, precomputed entries) to the
775
854
  // server module so runtime request handling uses the current routes.
776
855
  // Shared by initial discovery and HMR-triggered re-discovery.
@@ -861,9 +940,28 @@ export function createRouterDiscoveryPlugin(
861
940
 
862
941
  if (!registry) {
863
942
  // No main registry: the RSC env has no module runner (Cloudflare dev).
864
- // Lazily create a Node.js temp server for prerender evaluation.
865
- if (!prerenderNodeRegistry) {
866
- await getOrCreateTempServer();
943
+ // Lazily create a Node.js temp server for prerender evaluation, and
944
+ // re-import the entry through it on EVERY request — the temp server
945
+ // has its own file watcher, so a handler-only edit (a file without
946
+ // urls()/createRouter() that the main watcher's route-file sniff
947
+ // ignores) invalidates its module graph; the re-import re-evaluates
948
+ // exactly the dirty subgraph and re-registers fresh router
949
+ // instances. Before #654 the cached registry was only refreshed on
950
+ // route-file edits, so handler-only edits served stale prerender
951
+ // content on this path. Warm-cache re-imports are module-cache hits.
952
+ const tempRscEnv = await getOrCreateTempServer();
953
+ if (tempRscEnv) {
954
+ try {
955
+ await importEntryAndRegistry(tempRscEnv);
956
+ } catch (err: any) {
957
+ console.warn(
958
+ `[rango] Dev prerender module refresh failed: ${err.message}`,
959
+ );
960
+ res.statusCode = 500;
961
+ res.end(`Prerender handler error: ${err.message}`);
962
+ logResult(500, "temp module refresh failed");
963
+ return;
964
+ }
867
965
  }
868
966
  registry = prerenderNodeRegistry;
869
967
  }
@@ -879,8 +977,38 @@ export function createRouterDiscoveryPlugin(
879
977
  const wantRouteName = url.searchParams.get("routeName");
880
978
  const wantPassthrough = url.searchParams.get("passthrough") === "1";
881
979
 
980
+ // One render warms BOTH variant keys (matchForPrerender computes the
981
+ // intercept segments unconditionally), so a route's main and modal
982
+ // variants cost a single render per HMR generation.
983
+ const variantDims = {
984
+ passthrough: wantPassthrough,
985
+ routeName: wantRouteName,
986
+ };
987
+ const keyMain = devPrerenderCacheKey(pathname, {
988
+ intercept: false,
989
+ ...variantDims,
990
+ });
991
+ const keyIntercept = devPrerenderCacheKey(pathname, {
992
+ intercept: true,
993
+ ...variantDims,
994
+ });
995
+ const requestedKey = wantIntercept ? keyIntercept : keyMain;
996
+
882
997
  for (const [, routerInstance] of registry) {
883
998
  if (!routerInstance.matchForPrerender) continue;
999
+ // Cache is consulted per router IN LOOP ORDER so multi-router
1000
+ // fall-through semantics are identical to the uncached path: a
1001
+ // router that never produced a payload for this key still runs
1002
+ // its matchForPrerender (cheap trie miss / intentionally-uncached
1003
+ // error retry) before the next router is considered.
1004
+ const cached = devPrerenderCache.get(routerInstance, requestedKey);
1005
+ if (cached !== undefined) {
1006
+ res.setHeader("content-type", "application/json");
1007
+ res.setHeader("x-rango-prerender-cache", "HIT");
1008
+ res.end(cached);
1009
+ logResult(200, "cache hit");
1010
+ return;
1011
+ }
884
1012
  try {
885
1013
  const result = await routerInstance.matchForPrerender(
886
1014
  pathname,
@@ -896,19 +1024,19 @@ export function createRouterDiscoveryPlugin(
896
1024
  // This prevents returning the wrong entry when multiple routers
897
1025
  // have prerenderable routes sharing the same pathname.
898
1026
  if (wantRouteName && result.routeName !== wantRouteName) continue;
1027
+ // Pre-encoded MERGED handle string in the intercept body comes
1028
+ // from the producer (handles are Flight-encoded so
1029
+ // Promise/ReactNode values survive the wire).
1030
+ const bodies = payloadBodiesFromResult(result);
1031
+ devPrerenderCache.set(routerInstance, keyMain, bodies.main);
1032
+ devPrerenderCache.set(
1033
+ routerInstance,
1034
+ keyIntercept,
1035
+ bodies.intercept,
1036
+ );
899
1037
  res.setHeader("content-type", "application/json");
900
- let payload: Record<string, unknown>;
901
- if (wantIntercept && result.interceptSegments?.length) {
902
- payload = {
903
- segments: [...result.segments, ...result.interceptSegments],
904
- // Pre-encoded MERGED handle string from the producer (handles are
905
- // Flight-encoded so Promise/ReactNode values survive the wire).
906
- handles: result.interceptHandles ?? "",
907
- };
908
- } else {
909
- payload = { segments: result.segments, handles: result.handles };
910
- }
911
- res.end(JSON.stringify(payload));
1038
+ res.setHeader("x-rango-prerender-cache", "MISS");
1039
+ res.end(wantIntercept ? bodies.intercept : bodies.main);
912
1040
  logResult(200, `match ${result.routeName}`);
913
1041
  return;
914
1042
  } catch (err: any) {
@@ -10,12 +10,10 @@ import {
10
10
  import { resolve } from "node:path";
11
11
 
12
12
  import { escapeRegExp } from "../../regex-escape.js";
13
+ import { encodePathRemainder } from "../../router/url-params.js";
13
14
 
14
15
  export function encodePathParam(value: unknown): string {
15
- return String(value)
16
- .split("/")
17
- .map((segment) => encodeURIComponent(segment))
18
- .join("/");
16
+ return encodePathRemainder(String(value), encodeURIComponent);
19
17
  }
20
18
 
21
19
  export function substituteRouteParams(
@@ -28,6 +26,21 @@ export function substituteRouteParams(
28
26
 
29
27
  for (const [key, value] of Object.entries(params)) {
30
28
  const escaped = escapeRegExp(key);
29
+ // Named catch-all `:key+` / `:key*` (issue #634). Like the runtime reverse,
30
+ // the value is multi-segment: encode each segment and keep the `/` separators
31
+ // (a whole-value encode would turn `a/b` into `a%2Fb`), and consume the
32
+ // trailing modifier so it does not leak into the URL. `*` (zero-or-more)
33
+ // absent collapses via the trailing cleanup below.
34
+ const catchAllRe = new RegExp(`:${escaped}[+*]`);
35
+ if (catchAllRe.test(result)) {
36
+ if (value === "") {
37
+ result = result.replace(catchAllRe, "");
38
+ hadOmittedOptional = true;
39
+ } else {
40
+ result = result.replace(catchAllRe, encodePathRemainder(value, encode));
41
+ }
42
+ continue;
43
+ }
31
44
  if (value === "") {
32
45
  result = result.replace(
33
46
  new RegExp(`:${escaped}(\\([^)]*\\))?(?!\\?)`),
@@ -1,5 +1,6 @@
1
1
  import type { Plugin, ResolvedConfig } from "vite";
2
2
  import * as Vite from "vite";
3
+ import { isAbsolute, resolve } from "node:path";
3
4
  import { getPublishedPackageName } from "./package-resolution.js";
4
5
  import { performanceTracksOptimizeDepsPlugin } from "../plugins/performance-tracks.js";
5
6
  import {
@@ -55,6 +56,52 @@ export const sharedRolldownOptions: {
55
56
  plugins: [versionRolldownPlugin, performanceTracksOptimizeDepsPlugin()],
56
57
  };
57
58
 
59
+ /**
60
+ * Normalize an explicit `hostRouter` option into the specifier emitted as the
61
+ * host entry's `import ... from "<path>"`, and — for the forms that resolve
62
+ * unambiguously against the project root — verify the file exists (failing with
63
+ * a rango message instead of a downstream bundler "failed to resolve").
64
+ *
65
+ * Only a BARE specifier ("src/worker.rsc.tsx") that exists under the root is
66
+ * rewritten (to "./src/...") so it is not read as a package. A bare specifier
67
+ * that does NOT exist under the root is passed through VERBATIM: it may be a
68
+ * Vite alias ("@/worker.rsc.tsx"), an imports-map entry ("#app/worker"), or a
69
+ * workspace package the bundler resolves — rewriting or rejecting those was a
70
+ * regression (aliases worked before normalization existed). "./"/"../", a
71
+ * leading-slash (Vite root-relative), and a filesystem-absolute path are
72
+ * emitted unchanged — Vite resolves each. A leading-slash path and a
73
+ * filesystem-absolute path are indistinguishable on POSIX, so both are treated
74
+ * as "rooted": passed through WITHOUT an existence check (guessing between the
75
+ * two would wrongly reject a valid file — this was a regression the first time
76
+ * the check was added). Only the explicitly-relative forms, which resolve
77
+ * unambiguously against the root, fail fast with a rango message.
78
+ *
79
+ * `exists` is injected so the logic is unit-testable without a real filesystem.
80
+ */
81
+ export function normalizeHostRouterEntry(
82
+ rawInput: string,
83
+ root: string,
84
+ exists: (absPath: string) => boolean,
85
+ ): string {
86
+ const raw = rawInput.replaceAll("\\", "/");
87
+ const isRelative = raw.startsWith("./") || raw.startsWith("../");
88
+ const isRooted = raw.startsWith("/") || isAbsolute(rawInput);
89
+ if (isRooted) return raw;
90
+ if (isRelative) {
91
+ if (!exists(resolve(root, raw))) {
92
+ throw new Error(
93
+ `[rango] hostRouter entry not found: "${rawInput}" (resolved under ` +
94
+ `${root}). Point it at your createHostRouter() module, e.g. ` +
95
+ `rango({ hostRouter: "./src/worker.rsc.tsx" }).`,
96
+ );
97
+ }
98
+ return raw;
99
+ }
100
+ // Bare specifier: an existing root-relative file gets the explicit "./";
101
+ // anything else (alias, imports-map, package) is the bundler's to resolve.
102
+ return exists(resolve(root, raw)) ? "./" + raw : raw;
103
+ }
104
+
58
105
  /**
59
106
  * Create a virtual modules plugin for default entry files.
60
107
  * Provides virtual module content when entries use VIRTUAL_IDS (no custom entry configured).
@@ -1,18 +0,0 @@
1
- "use client";
2
-
3
- import type { ReactNode } from "react";
4
- import type { NetworkError } from "./errors.js";
5
-
6
- interface NetworkErrorThrowerProps {
7
- error: NetworkError;
8
- }
9
-
10
- /**
11
- * Client component that throws a NetworkError during render.
12
- * Errors thrown during render are caught by error boundaries; async errors are not.
13
- */
14
- export function NetworkErrorThrower({
15
- error,
16
- }: NetworkErrorThrowerProps): ReactNode {
17
- throw error;
18
- }