@rangojs/router 0.22.1 → 0.23.0

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 (179) hide show
  1. package/dist/bin/rango.js +8 -3
  2. package/dist/testing/vitest.js +31 -1
  3. package/dist/types/browser/defer.d.ts +48 -0
  4. package/dist/types/browser/document-revealed.d.ts +17 -0
  5. package/dist/types/browser/event-controller.d.ts +7 -1
  6. package/dist/types/browser/navigation-transaction.d.ts +21 -7
  7. package/dist/types/browser/partial-update.d.ts +34 -5
  8. package/dist/types/browser/react/NavigationProvider.d.ts +13 -3
  9. package/dist/types/browser/react/filter-segment-order.d.ts +7 -0
  10. package/dist/types/browser/response-adapter.d.ts +7 -0
  11. package/dist/types/browser/types.d.ts +19 -9
  12. package/dist/types/browser/validate-redirect-origin.d.ts +11 -0
  13. package/dist/types/build/generate-route-types.d.ts +1 -1
  14. package/dist/types/build/route-types/router-processing.d.ts +1 -1
  15. package/dist/types/build/route-types/scan-filter.d.ts +16 -2
  16. package/dist/types/cache/cf/cf-cache-store.d.ts +16 -1
  17. package/dist/types/cache/cf/cf-cache-types.d.ts +23 -2
  18. package/dist/types/client-urls/client-urls.d.ts +1 -1
  19. package/dist/types/client-urls/hoisted-revalidate.d.ts +13 -0
  20. package/dist/types/client-urls/navigation.d.ts +0 -37
  21. package/dist/types/client-urls/revalidate-registry.d.ts +4 -0
  22. package/dist/types/client-urls/server-projection.d.ts +29 -1
  23. package/dist/types/client-urls/types.d.ts +34 -69
  24. package/dist/types/client.d.ts +0 -1
  25. package/dist/types/errors.d.ts +5 -0
  26. package/dist/types/handles/deferred-resolution.d.ts +2 -1
  27. package/dist/types/handles/pushed-by.d.ts +23 -0
  28. package/dist/types/loader-store.d.ts +8 -0
  29. package/dist/types/prefetch-markers.d.ts +11 -0
  30. package/dist/types/route-definition/helpers-types.d.ts +27 -0
  31. package/dist/types/router/is-action.d.ts +2 -1
  32. package/dist/types/router/segment-resolution/loader-mask.d.ts +3 -2
  33. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +241 -0
  34. package/dist/types/server/context.d.ts +21 -1
  35. package/dist/types/server/handle-store.d.ts +9 -1
  36. package/dist/types/server/request-context.d.ts +14 -1
  37. package/dist/types/server.d.ts +1 -0
  38. package/dist/types/testing/flight.entry.d.ts +1 -1
  39. package/dist/types/testing/index.d.ts +3 -3
  40. package/dist/types/testing/internal/action-id.d.ts +10 -1
  41. package/dist/types/testing/internal/client-urls-projection.d.ts +16 -0
  42. package/dist/types/testing/internal/test-action.d.ts +8 -0
  43. package/dist/types/testing/run-loader.d.ts +2 -1
  44. package/dist/types/testing/run-revalidate.d.ts +74 -0
  45. package/dist/types/testing/run-transition-when.d.ts +2 -3
  46. package/dist/types/testing/serve-shell-request.d.ts +38 -0
  47. package/dist/types/testing/vitest.d.ts +11 -0
  48. package/dist/types/types/boundaries.d.ts +9 -0
  49. package/dist/types/types/handler-context.d.ts +14 -0
  50. package/dist/types/types/loader-types.d.ts +30 -0
  51. package/dist/types/types/segments.d.ts +21 -4
  52. package/dist/types/urls/path-helper-types.d.ts +25 -4
  53. package/dist/types/urls/type-extraction.d.ts +6 -2
  54. package/dist/types/vite/discovery/discover-routers.d.ts +9 -0
  55. package/dist/types/vite/discovery/state.d.ts +11 -4
  56. package/dist/types/vite/plugins/client-urls-revalidate-hoist.d.ts +54 -0
  57. package/dist/types/vite/plugins/hoist/literal.d.ts +83 -0
  58. package/dist/types/vite/plugins/hoist/scope.d.ts +40 -0
  59. package/dist/types/vite/plugins/hoist/source.d.ts +41 -0
  60. package/dist/types/vite/plugins/transition-when-hoist.d.ts +8 -11
  61. package/dist/types/vite/router-discovery.d.ts +10 -0
  62. package/dist/vite/index.js +842 -308
  63. package/package.json +1 -1
  64. package/skills/caching/SKILL.md +42 -5
  65. package/skills/catalog.json +2 -2
  66. package/skills/client-urls/SKILL.md +113 -24
  67. package/skills/cloudflare/SKILL.md +14 -9
  68. package/skills/hooks/SKILL.md +11 -3
  69. package/skills/links/SKILL.md +24 -0
  70. package/skills/loader/SKILL.md +192 -1
  71. package/skills/migrate-nextjs/SKILL.md +2 -2
  72. package/skills/migrate-react-router/SKILL.md +10 -7
  73. package/skills/migrate-react-router/data-and-actions.md +1 -1
  74. package/skills/ppr/SKILL.md +13 -0
  75. package/skills/prerender/SKILL.md +22 -8
  76. package/skills/rango/SKILL.md +12 -6
  77. package/skills/route/SKILL.md +139 -0
  78. package/skills/testing/SKILL.md +3 -2
  79. package/skills/testing/client-components.md +1 -1
  80. package/skills/testing/predicates.md +67 -32
  81. package/skills/testing/serve-shell-request.md +88 -18
  82. package/skills/testing/setup.md +2 -2
  83. package/skills/typesafety/route-types.md +2 -2
  84. package/skills/view-transitions/SKILL.md +26 -0
  85. package/src/browser/defer.ts +82 -0
  86. package/src/browser/document-revealed.ts +52 -0
  87. package/src/browser/event-controller.ts +43 -32
  88. package/src/browser/navigation-bridge.ts +64 -14
  89. package/src/browser/navigation-client.ts +48 -36
  90. package/src/browser/navigation-store.ts +13 -0
  91. package/src/browser/navigation-transaction.ts +25 -8
  92. package/src/browser/partial-update.ts +528 -135
  93. package/src/browser/prefetch/fetch.ts +2 -1
  94. package/src/browser/react/NavigationProvider.tsx +47 -28
  95. package/src/browser/react/filter-segment-order.ts +13 -2
  96. package/src/browser/response-adapter.ts +20 -0
  97. package/src/browser/rsc-router.tsx +17 -45
  98. package/src/browser/server-action-bridge.ts +19 -63
  99. package/src/browser/types.ts +19 -9
  100. package/src/browser/validate-redirect-origin.ts +16 -0
  101. package/src/build/generate-route-types.ts +1 -0
  102. package/src/build/route-types/router-processing.ts +3 -6
  103. package/src/build/route-types/scan-filter.ts +37 -10
  104. package/src/cache/cf/cf-cache-store.ts +59 -21
  105. package/src/cache/cf/cf-cache-types.ts +24 -2
  106. package/src/cache/document-cache.ts +22 -1
  107. package/src/client-urls/client-urls.ts +22 -6
  108. package/src/client-urls/hoisted-revalidate.ts +25 -0
  109. package/src/client-urls/navigation.ts +0 -96
  110. package/src/client-urls/revalidate-registry.ts +30 -0
  111. package/src/client-urls/server-projection.ts +151 -65
  112. package/src/client-urls/types.ts +39 -71
  113. package/src/client.tsx +9 -5
  114. package/src/errors.ts +15 -0
  115. package/src/handles/deferred-resolution.ts +28 -5
  116. package/src/handles/pushed-by.ts +122 -0
  117. package/src/loader-store.ts +18 -7
  118. package/src/prefetch-markers.ts +13 -0
  119. package/src/route-content-wrapper.tsx +18 -4
  120. package/src/route-definition/dsl-helpers.ts +27 -4
  121. package/src/route-definition/helpers-types.ts +27 -1
  122. package/src/router/intercept-resolution.ts +6 -4
  123. package/src/router/is-action.ts +2 -1
  124. package/src/router/loader-resolution.ts +83 -4
  125. package/src/router/match-api.ts +36 -1
  126. package/src/router/match-middleware/background-revalidation.ts +11 -1
  127. package/src/router/match-middleware/cache-lookup.ts +29 -6
  128. package/src/router/match-middleware/cache-store.ts +17 -1
  129. package/src/router/match-result.ts +14 -2
  130. package/src/router/navigation-snapshot.ts +2 -1
  131. package/src/router/segment-resolution/fresh.ts +22 -18
  132. package/src/router/segment-resolution/loader-cache.ts +2 -1
  133. package/src/router/segment-resolution/loader-mask.ts +5 -3
  134. package/src/router/segment-resolution/prefetch-deferral.ts +543 -0
  135. package/src/router/segment-resolution/revalidation.ts +169 -60
  136. package/src/rsc/rsc-rendering.ts +50 -7
  137. package/src/rsc/shell-capture.ts +2 -1
  138. package/src/segment-loader-promise.ts +23 -20
  139. package/src/segment-system.tsx +60 -26
  140. package/src/server/context.ts +39 -5
  141. package/src/server/handle-store.ts +104 -8
  142. package/src/server/request-context.ts +21 -1
  143. package/src/server.ts +4 -0
  144. package/src/testing/action-form.ts +2 -11
  145. package/src/testing/e2e/page-helpers.ts +3 -1
  146. package/src/testing/flight.entry.ts +1 -0
  147. package/src/testing/index.ts +6 -3
  148. package/src/testing/internal/action-id.ts +16 -1
  149. package/src/testing/internal/client-urls-projection.ts +70 -0
  150. package/src/testing/internal/test-action.ts +25 -0
  151. package/src/testing/run-loader.ts +2 -1
  152. package/src/testing/run-revalidate.ts +142 -0
  153. package/src/testing/run-transition-when.ts +7 -8
  154. package/src/testing/serve-shell-request.ts +116 -6
  155. package/src/testing/vitest.ts +67 -1
  156. package/src/types/boundaries.ts +12 -0
  157. package/src/types/handler-context.ts +14 -0
  158. package/src/types/loader-types.ts +30 -0
  159. package/src/types/segments.ts +26 -4
  160. package/src/urls/path-helper-types.ts +27 -5
  161. package/src/urls/type-extraction.ts +30 -10
  162. package/src/use-loader.tsx +18 -13
  163. package/src/vite/discovery/client-urls-projection.ts +20 -21
  164. package/src/vite/discovery/discover-routers.ts +94 -26
  165. package/src/vite/discovery/state.ts +20 -4
  166. package/src/vite/discovery/virtual-module-codegen.ts +22 -17
  167. package/src/vite/plugins/client-urls-revalidate-hoist.ts +564 -0
  168. package/src/vite/plugins/hoist/literal.ts +417 -0
  169. package/src/vite/plugins/hoist/scope.ts +370 -0
  170. package/src/vite/plugins/hoist/source.ts +84 -0
  171. package/src/vite/plugins/transition-when-hoist.ts +113 -779
  172. package/src/vite/rango.ts +2 -0
  173. package/src/vite/router-discovery.ts +93 -32
  174. package/dist/types/client-urls/revalidate-chain.d.ts +0 -33
  175. package/dist/types/client-urls/revalidation-protocol.d.ts +0 -25
  176. package/dist/types/testing/run-client-revalidate.d.ts +0 -43
  177. package/src/client-urls/revalidate-chain.ts +0 -83
  178. package/src/client-urls/revalidation-protocol.ts +0 -56
  179. package/src/testing/run-client-revalidate.ts +0 -92
@@ -172,6 +172,15 @@ export interface ResolvedSegment {
172
172
  */
173
173
  awaitBeforeFlush?: true;
174
174
  parallelLoading?: ReactNode; // For parallel-owned loaders: the parallel's loading fallback
175
+ /**
176
+ * A prefetch skipped this segment's work (`prefetch: false`): a loader
177
+ * carries no `loaderData`, a layout/route/parallel unit carries
178
+ * `component: null` and its fallback. The browser shows the fallback and
179
+ * sends a fill request for it (browser/partial-update.ts); until that
180
+ * lands the segment is a placeholder, never a held copy. Only ever on a
181
+ * prefetch response: see docs/design/prefetch-false.md.
182
+ */
183
+ deferred?: true;
175
184
  // Intercept loader fields (for streaming loader data in parallel segments)
176
185
  loaderDataPromise?: Promise<any[]> | any[]; // Loader data promise or resolved array
177
186
  loaderIds?: string[]; // IDs ($$id) of loaders for this segment
@@ -253,10 +262,12 @@ export interface MatchResult {
253
262
  /**
254
263
  * Every segment id whose handler actually ran on the server this request,
255
264
  * including ones with `component === null` that get filtered out of
256
- * `segments`/`diff` to avoid wasted bytes. Drives the client's handle-
257
- * cleanup pass — a slot that re-resolves and pushes nothing must clear
258
- * its previous handle bucket, but `diff` doesn't carry it because the
259
- * segment payload doesn't either. A superset of `diff`.
265
+ * `segments`/`diff` to avoid wasted bytes, and every loader segment that
266
+ * ran. Drives the client's handle merge (handles/pushed-by.ts) — a slot
267
+ * that re-resolves and pushes nothing must clear its previous handle
268
+ * bucket, but `diff` doesn't carry it because the segment payload doesn't
269
+ * either; a loader that ran replaces its pushes, one that did not keeps
270
+ * them. A superset of `diff`.
260
271
  */
261
272
  resolvedIds: string[];
262
273
  /**
@@ -304,3 +315,14 @@ export interface MatchResult {
304
315
  params: Record<string, string>;
305
316
  }>;
306
317
  }
318
+
319
+ /**
320
+ * A `prefetch: false` placeholder (ResolvedSegment.deferred): a segment whose
321
+ * work a prefetch skipped, waiting for its fill. Never a held copy of the
322
+ * segment it stands for.
323
+ */
324
+ export function isPlaceholder(
325
+ segment: Pick<ResolvedSegment, "deferred">,
326
+ ): boolean {
327
+ return segment.deferred === true;
328
+ }
@@ -30,7 +30,6 @@ import type {
30
30
  InterceptUseItem,
31
31
  LoaderUseItem,
32
32
  TypedCacheItem,
33
- TransitionItem,
34
33
  TypedTransitionItem,
35
34
  TypedRouteItem,
36
35
  TypedIncludeItem,
@@ -362,6 +361,12 @@ export type PathHelpers<TEnv> = {
362
361
  * flag BAKES: the loader executes at shell capture and its settled return
363
362
  * freezes into the shell (nested promises stay live holes) — the pre-flush
364
363
  * promise applied to the prelude.
364
+ *
365
+ * Pass `{ prefetch: false }` to keep the loader out of prefetches: a
366
+ * prefetch skips it and the click that adopts the prefetch fetches it in
367
+ * one follow-up request (see {@link LoaderOptions}). Only on a segment the
368
+ * client does not have yet: a loader the client already holds runs in the
369
+ * prefetch when it revalidates.
365
370
  */
366
371
  loader: <TData>(
367
372
  loaderDef: LoaderDefinition<TData>,
@@ -370,11 +375,25 @@ export type PathHelpers<TEnv> = {
370
375
  ) => LoaderItem;
371
376
 
372
377
  /**
373
- * Attach a loading component to the current route/layout
378
+ * Attach a loading component to the current route/layout.
379
+ *
380
+ * `{ ssr: false }` skips the fallback on document requests. `{ prefetch:
381
+ * false }` keeps what the fallback covers out of prefetches, when this
382
+ * entry's segment is new to the client: the entry's handler, its loaders,
383
+ * its orphan layouts and parallel slots and, for a layout, every deeper
384
+ * entry. The click that adopts the prefetch shows the fallback and fetches
385
+ * them in one follow-up request, which reads cache() and "use cache" as
386
+ * any request does (a prefetch reads neither for skipped work). A segment
387
+ * the client already holds is never deferred: a prefetch renders it as
388
+ * usual when it revalidates, and what is below it follows its own flags.
389
+ * To keep a child out of prefetches while moving inside a section, flag
390
+ * the child's own loading(). An entry whose handler output is stored
391
+ * (cache(), ppr, Prerender/Static) is served as usual and only its loaders
392
+ * are skipped.
374
393
  */
375
394
  loading: (
376
395
  component: ReactNode | (() => ReactNode),
377
- options?: { ssr?: boolean },
396
+ options?: { ssr?: boolean; prefetch?: boolean },
378
397
  ) => LoadingItem;
379
398
 
380
399
  /**
@@ -422,8 +441,11 @@ export type PathHelpers<TEnv> = {
422
441
  * the browser (never on the server); see TransitionWhenFn.
423
442
  */
424
443
  transition: {
425
- (): TransitionItem;
426
- (config: TransitionConfig): TransitionItem;
444
+ // Typed {} phantoms, not bare TransitionItem: a phantom-less item matches
445
+ // `TypedTransitionItem<infer R>` with R inferred as Record<string, any>,
446
+ // which disables route-name checking for the sibling map.
447
+ (): TypedTransitionItem<{}, {}>;
448
+ (config: TransitionConfig): TypedTransitionItem<{}, {}>;
427
449
  <const TChildren extends readonly (AllUseItems | readonly AllUseItems[])[]>(
428
450
  children: () => TChildren,
429
451
  ): TypedTransitionItem<
@@ -12,6 +12,11 @@ import type { LocalOnlyInclude, UnnamedRoute } from "./pattern-types.js";
12
12
  // Route Type Extraction Utilities
13
13
  // ============================================================================
14
14
 
15
+ /** Drops string index signatures, keeping literal and template keys. */
16
+ type StripStringIndex<T> = {
17
+ [K in keyof T as string extends K ? never : K]: T[K];
18
+ };
19
+
15
20
  /**
16
21
  * Prefix route names with a given prefix (e.g., "blog" + "post" = "blog.post")
17
22
  *
@@ -117,15 +122,21 @@ type ExtractRoutesFromItem<T> =
117
122
  : TUrlPrefix extends string
118
123
  ? PrefixPatterns<TRoutes, TUrlPrefix>
119
124
  : TRoutes
120
- : // TypedLayoutItem: extract child routes from phantom type
125
+ : // Phantom-less items (global transition/cache/layout, factories typed
126
+ // as a use item) match the Typed* shapes with the child map inferred
127
+ // as the Record<string, any> constraint; the string index signature
128
+ // turns route-name checking off for the whole map. Strip only the
129
+ // index and keep literal keys, so a loosely typed include() beside
130
+ // named routes still contributes those names.
131
+ // TypedLayoutItem: extract child routes from phantom type
121
132
  T extends TypedLayoutItem<infer TChildRoutes>
122
- ? TChildRoutes
133
+ ? StripStringIndex<TChildRoutes>
123
134
  : // TypedCacheItem: extract child routes from phantom type
124
135
  T extends TypedCacheItem<infer TChildRoutes>
125
- ? TChildRoutes
136
+ ? StripStringIndex<TChildRoutes>
126
137
  : // TypedTransitionItem: extract child routes from phantom type
127
138
  T extends TypedTransitionItem<infer TChildRoutes>
128
- ? TChildRoutes
139
+ ? StripStringIndex<TChildRoutes>
129
140
  : // Fallback (won't extract routes)
130
141
  {};
131
142
 
@@ -206,16 +217,25 @@ type ExtractResponsesFromItem<T> =
206
217
  ? TResponses
207
218
  : {}
208
219
  : T extends TypedLayoutItem<any, infer TChildResponses>
209
- ? TChildResponses extends Record<string, unknown>
210
- ? TChildResponses
220
+ ? StripStringIndex<TChildResponses> extends infer R extends Record<
221
+ string,
222
+ unknown
223
+ >
224
+ ? R
211
225
  : {}
212
226
  : T extends TypedCacheItem<any, infer TChildResponses>
213
- ? TChildResponses extends Record<string, unknown>
214
- ? TChildResponses
227
+ ? StripStringIndex<TChildResponses> extends infer R extends Record<
228
+ string,
229
+ unknown
230
+ >
231
+ ? R
215
232
  : {}
216
233
  : T extends TypedTransitionItem<any, infer TChildResponses>
217
- ? TChildResponses extends Record<string, unknown>
218
- ? TChildResponses
234
+ ? StripStringIndex<TChildResponses> extends infer R extends Record<
235
+ string,
236
+ unknown
237
+ >
238
+ ? R
219
239
  : {}
220
240
  : {};
221
241
 
@@ -20,6 +20,7 @@ import {
20
20
  type LoaderStore,
21
21
  } from "./loader-store.js";
22
22
  import { NavigationStoreContext } from "./browser/react/context.js";
23
+ import { Defer, readDefer } from "./browser/defer.js";
23
24
  import { decodeLoaderEntry } from "./decode-loader-results.js";
24
25
  import { warnAwaitedSsrSuspension } from "./ssr-suspension-warning.js";
25
26
  import { SETTLED_THENABLE } from "./thenable-status.js";
@@ -51,12 +52,14 @@ const NOT_FOUND = Symbol("not-found");
51
52
  * available value from a still-streaming per-loader promise. A pending stream
52
53
  * is `use()`d at the read site (implicit suspension); the promise identity is
53
54
  * stable per commit (it comes from the provider's memoized context value /
54
- * element props), which use() requires across replays.
55
+ * element props), which use() requires across replays. A `prefetch: false`
56
+ * hole is a Defer (browser/defer.ts), read by its rule: every reader of the
57
+ * loader reads the one defer.
55
58
  */
56
59
  type LoaderLookup =
57
60
  | typeof NOT_FOUND
58
61
  | { value: unknown }
59
- | { stream: Promise<unknown> };
62
+ | { stream: Promise<unknown> | Defer<unknown> };
60
63
 
61
64
  function lookupLoaderStreams(
62
65
  loaderStreams: Record<string, unknown> | undefined,
@@ -64,9 +67,9 @@ function lookupLoaderStreams(
64
67
  ): LoaderLookup {
65
68
  if (!loaderStreams || !(loaderId in loaderStreams)) return NOT_FOUND;
66
69
  const entry = loaderStreams[loaderId];
67
- // Within the loaderStreams channel a Promise always means "still streaming";
68
- // settled entries are undecoded result values, never promises.
69
- return entry instanceof Promise
70
+ // Within the loaderStreams channel a Promise or a Defer always means "still
71
+ // streaming"; settled entries are undecoded result values, never promises.
72
+ return entry instanceof Promise || entry instanceof Defer
70
73
  ? { stream: entry }
71
74
  : { value: decodeLoaderEntry(entry) };
72
75
  }
@@ -163,7 +166,7 @@ function useLoaderInternal<T>(
163
166
  const walk = useMemo((): {
164
167
  contextData: T | undefined;
165
168
  hasContextData: boolean;
166
- pendingStream?: Promise<unknown>;
169
+ pendingStream?: Promise<unknown> | Defer<unknown>;
167
170
  /** { ssr: false } loader ids seen on the chain up to (and including) the
168
171
  * level that owned the read — input for the dev SSR suspension warning. */
169
172
  awaitedLoaderIds?: string[];
@@ -217,18 +220,20 @@ function useLoaderInternal<T>(
217
220
  // every COMPLETED render.
218
221
  const { hasContextData } = walk;
219
222
  let contextData = walk.contextData;
223
+ const stream = walk.pendingStream;
220
224
  // Dev only (suspense-audit.ts): the stream this reader is handed. The test
221
225
  // is a build-time constant, so the hook is called on every render or never.
222
226
  if (process.env.NODE_ENV !== "production" && INTERNAL_RANGO_SUSPENSE_AUDIT) {
223
- useReadAudit(loader.$$id, walk.pendingStream);
224
- }
225
- if (walk.pendingStream) {
226
- warnAwaitedSsrSuspension(
227
+ useReadAudit(
227
228
  loader.$$id,
228
- walk.awaitedLoaderIds,
229
- walk.pendingStream,
229
+ stream instanceof Defer ? stream.promise : stream,
230
230
  );
231
- contextData = decodeLoaderEntry(use(walk.pendingStream)) as T;
231
+ }
232
+ if (stream instanceof Defer) {
233
+ contextData = decodeLoaderEntry(readDefer(stream)) as T;
234
+ } else if (stream) {
235
+ warnAwaitedSsrSuspension(loader.$$id, walk.awaitedLoaderIds, stream);
236
+ contextData = decodeLoaderEntry(use(stream)) as T;
232
237
  } else if (hasContextData) {
233
238
  // React logs a render that finishes without use() at a place whose reader
234
239
  // suspended in use() on mount, so every read with route context calls it.
@@ -2,10 +2,9 @@ import { existsSync, readFileSync } from "node:fs";
2
2
  import { isAbsolute, relative, resolve } from "node:path";
3
3
  import { normalizePath, parseAst } from "vite";
4
4
  import {
5
- serializeClientUrlPatterns,
5
+ projectClientUrlsModule,
6
6
  type ClientUrlProjection,
7
7
  } from "../../client-urls/server-projection.js";
8
- import type { ClientUrlPatterns } from "../../client-urls/types.js";
9
8
  import {
10
9
  computeProductionHash,
11
10
  hashRefKey,
@@ -191,22 +190,19 @@ export async function refreshRecordedClientUrlProjections(
191
190
  }
192
191
 
193
192
  for (const [source, referenceIds] of referenceIdsBySource) {
194
- let definition: unknown;
193
+ let module: Record<string, unknown>;
195
194
  try {
196
- definition = (await ssrEnv.runner.import(source)).default;
195
+ module = await ssrEnv.runner.import(source);
197
196
  } catch {
198
197
  continue;
199
198
  }
200
- if (
201
- (typeof definition !== "object" && typeof definition !== "function") ||
202
- definition === null ||
203
- (definition as { __brand?: unknown }).__brand !== "client-urls"
204
- ) {
199
+ let projection: ClientUrlProjection | undefined;
200
+ try {
201
+ projection = projectClientUrlsModule(module, { hoisted: true });
202
+ } catch {
205
203
  continue;
206
204
  }
207
- const projection = serializeClientUrlPatterns(
208
- definition as ClientUrlPatterns,
209
- );
205
+ if (!projection) continue;
210
206
  debug?.(
211
207
  "pre-entry refresh %s -> [%s]",
212
208
  source,
@@ -288,21 +284,24 @@ export async function discoverClientUrlProjections(
288
284
  cause,
289
285
  );
290
286
  }
291
- const definition = module.default;
292
- if (
293
- (typeof definition !== "object" && typeof definition !== "function") ||
294
- definition === null ||
295
- (definition as { __brand?: unknown }).__brand !== "client-urls"
296
- ) {
287
+ let projection: ClientUrlProjection | undefined;
288
+ try {
289
+ projection = projectClientUrlsModule(module, { hoisted: true });
290
+ } catch (cause) {
291
+ throw projectionError(
292
+ referenceIds[0],
293
+ source,
294
+ cause instanceof Error ? cause.message : String(cause),
295
+ cause,
296
+ );
297
+ }
298
+ if (!projection) {
297
299
  throw projectionError(
298
300
  referenceIds[0],
299
301
  source,
300
302
  'the module default export must be created by clientUrls() and have __brand === "client-urls"',
301
303
  );
302
304
  }
303
- const projection = serializeClientUrlPatterns(
304
- definition as ClientUrlPatterns,
305
- );
306
305
  for (const referenceId of referenceIds) {
307
306
  nextProjections.set(referenceId, projection);
308
307
  }
@@ -42,6 +42,30 @@ import {
42
42
 
43
43
  const debug = createRangoDebugger(NS.discovery);
44
44
 
45
+ const SERVER_MODULE_ID = "@rangojs/router/server";
46
+
47
+ /** Passes discoverRouters runs before giving up on a runner that keeps reloading. */
48
+ const MAX_DISCOVERY_PASSES = 3;
49
+
50
+ /** A discovery pass result: the runner reloaded mid-pass, redo it. */
51
+ const RUNNER_RELOADED = Symbol("rango.discovery.runnerReloaded");
52
+
53
+ /**
54
+ * Whether the runner's `@rangojs/router/server` is no longer `serverMod`. A
55
+ * Vite module runner full reload clears its evaluated-module cache, so the
56
+ * next import evaluates a fresh copy with empty module-level registries.
57
+ */
58
+ async function runnerReloadedSince(
59
+ rscEnv: any,
60
+ serverMod: unknown,
61
+ ): Promise<boolean> {
62
+ try {
63
+ return (await rscEnv.runner.import(SERVER_MODULE_ID)) !== serverMod;
64
+ } catch {
65
+ return false;
66
+ }
67
+ }
68
+
45
69
  /**
46
70
  * Import the user's entry via RSC runner, generate manifests for each
47
71
  * discovered router, build route tries, and optionally run prerender
@@ -49,25 +73,70 @@ const debug = createRangoDebugger(NS.discovery);
49
73
  *
50
74
  * Returns the imported `@rangojs/router/server` module so the caller
51
75
  * can access the RouterRegistry and manifest setters.
76
+ *
77
+ * A pass reads the router registry and installs clientUrls projections on ONE
78
+ * `@rangojs/router/server` instance, and checks that the runner still serves
79
+ * it. A runner full reload during the pass (a watched-file change at boot)
80
+ * splits the realm: a router module bound to the old instance registers there
81
+ * while urls modules re-evaluate against a fresh one, so include() looks its
82
+ * projection up in the fresh, empty Map ("include() could not resolve the
83
+ * server projection"). Such a pass is redone, at most
84
+ * MAX_DISCOVERY_PASSES times; the redo evaluates the entry in one realm.
52
85
  */
53
86
  export async function discoverRouters(
54
87
  state: DiscoveryState,
55
88
  rscEnv: any,
56
89
  ssrEnv?: any,
57
90
  ): Promise<any> {
58
- if (!state.resolvedEntryPath) return;
59
-
60
- // Import the router package FIRST (leaf module, no side effects on user
61
- // code) so recorded clientUrls projections can be refreshed BEFORE the entry
62
- // import: routers re-created by that import call `.routes(reference)` at
63
- // module scope and materialize whatever projection is installed at that
64
- // moment. On HMR re-discovery that must be the CURRENT module contents, not
65
- // the previous pass's snapshot (see refreshRecordedClientUrlProjections).
66
- const serverMod = await timed(
67
- debug,
68
- "inner: import @rangojs/router/server",
69
- () => rscEnv.runner.import("@rangojs/router/server"),
70
- );
91
+ const entryPath = state.resolvedEntryPath;
92
+ if (!entryPath) return;
93
+
94
+ for (let pass = 1; ; pass++) {
95
+ // Imported BEFORE the entry so recorded clientUrls projections can be
96
+ // refreshed first: routers re-created by the entry import materialize
97
+ // whatever projection is installed at that moment, which on HMR
98
+ // re-discovery must be the CURRENT module contents, not the previous
99
+ // pass's snapshot (see refreshRecordedClientUrlProjections). It is also
100
+ // the realm baseline of the reload checks; #637 read the registry off a
101
+ // pre-entry copy without one.
102
+ const serverMod = await timed(
103
+ debug,
104
+ "inner: import @rangojs/router/server",
105
+ () => rscEnv.runner.import(SERVER_MODULE_ID),
106
+ );
107
+ let result: unknown;
108
+ try {
109
+ result = await discoverRoutersPass(
110
+ state,
111
+ rscEnv,
112
+ ssrEnv,
113
+ entryPath,
114
+ serverMod,
115
+ );
116
+ } catch (error) {
117
+ if (!(await runnerReloadedSince(rscEnv, serverMod))) throw error;
118
+ result = RUNNER_RELOADED;
119
+ }
120
+ if (result !== RUNNER_RELOADED) return result;
121
+ if (pass === MAX_DISCOVERY_PASSES) {
122
+ throw new Error(
123
+ `[rango] Route discovery did not settle: the RSC module runner reloaded during ${MAX_DISCOVERY_PASSES} consecutive discovery passes.`,
124
+ );
125
+ }
126
+ debug?.(
127
+ "inner: module runner reloaded mid-pass, redoing discovery (pass %d)",
128
+ pass + 1,
129
+ );
130
+ }
131
+ }
132
+
133
+ async function discoverRoutersPass(
134
+ state: DiscoveryState,
135
+ rscEnv: any,
136
+ ssrEnv: any,
137
+ entryPath: string,
138
+ serverMod: any,
139
+ ): Promise<any> {
71
140
  await timed(debug, "inner: refresh recorded client URL projections", () =>
72
141
  refreshRecordedClientUrlProjections(state, ssrEnv, serverMod),
73
142
  );
@@ -76,10 +145,11 @@ export async function discoverRouters(
76
145
  // For node preset: this is the router file (createRouter() registers in RouterRegistry).
77
146
  // For cloudflare preset: this is the worker entry (which imports the router).
78
147
  await timed(debug, "inner: import entry", () =>
79
- rscEnv.runner.import(state.resolvedEntryPath),
148
+ rscEnv.runner.import(entryPath),
80
149
  );
150
+ if (await runnerReloadedSince(rscEnv, serverMod)) return RUNNER_RELOADED;
81
151
 
82
- let registry: Map<string, any> = serverMod.RouterRegistry;
152
+ const registry: Map<string, any> = serverMod.RouterRegistry;
83
153
 
84
154
  if (!registry || registry.size === 0) {
85
155
  // No RSC routers found directly. Check for host routers with lazy handlers
@@ -105,16 +175,10 @@ export async function discoverRouters(
105
175
  debug?.("caught error while resolving %s: %O", context, error);
106
176
  }
107
177
 
108
- // Re-read RouterRegistry - sub-app createRouter() calls should have populated it
109
- const freshServerMod = await rscEnv.runner.import(
110
- "@rangojs/router/server",
111
- );
112
- const freshRegistry: Map<string, any> = freshServerMod.RouterRegistry;
113
-
114
- if (freshRegistry && freshRegistry.size > 0) {
115
- // Update references so the manifest generation below uses the fresh data
116
- Object.assign(serverMod, freshServerMod);
117
- registry = freshRegistry;
178
+ // Sub-app createRouter() calls populate the same live registry, unless
179
+ // a reload moved them to a fresh realm.
180
+ if (await runnerReloadedSince(rscEnv, serverMod)) {
181
+ return RUNNER_RELOADED;
118
182
  }
119
183
  }
120
184
  } catch (error) {
@@ -125,7 +189,7 @@ export async function discoverRouters(
125
189
 
126
190
  // If still no routers after host router resolution, fail
127
191
  if (!registry || registry.size === 0) {
128
- throw new DiscoveryError(state.resolvedEntryPath, discoveryErrors);
192
+ throw new DiscoveryError(entryPath, discoveryErrors);
129
193
  }
130
194
  }
131
195
 
@@ -351,6 +415,10 @@ export async function discoverRouters(
351
415
  }
352
416
  }
353
417
 
418
+ // A reload after the entry import leaves this pass's projections and
419
+ // manifests on a realm the runner no longer serves.
420
+ if (await runnerReloadedSince(rscEnv, serverMod)) return RUNNER_RELOADED;
421
+
354
422
  // Commit all local state to the shared discovery state atomically.
355
423
  // This ensures a failed re-discovery (e.g. from a transient module
356
424
  // evaluation error) preserves the last known-good state.
@@ -196,14 +196,30 @@ export interface DiscoveryState {
196
196
  buildEnvDispose?: (() => Promise<void> | void) | null;
197
197
 
198
198
  /**
199
- * Set when the most recent HMR re-discovery threw. Cleared on the next
200
- * successful discovery. Surfaced via debug logs so we can detect "manifest
201
- * frozen at last-good after error → user fix in non-route file → no
202
- * rediscovery trigger" scenarios.
199
+ * Set when the most recent discovery threw: an HMR re-discovery, or the
200
+ * cold dev discovery on a main RSC runner. Cleared on the next successful
201
+ * discovery. While set, the route-file watcher treats any in-scan source
202
+ * change as a rediscovery trigger (recovery mode).
203
203
  */
204
204
  lastDiscoveryError?: { message: string; at: number } | null;
205
205
  }
206
206
 
207
+ /**
208
+ * Whether the route-file watcher can re-run runtime discovery. A committed
209
+ * manifest is the usual precondition. A failed cold discovery commits none,
210
+ * so on a main RSC runner its recorded failure qualifies too; without that
211
+ * the dev server stayed broken until a restart.
212
+ */
213
+ export function canRefreshRuntimeDiscovery(
214
+ state: Pick<DiscoveryState, "perRouterManifests" | "lastDiscoveryError">,
215
+ hasMainRunner: boolean,
216
+ ): boolean {
217
+ return (
218
+ state.perRouterManifests.length > 0 ||
219
+ (hasMainRunner && !!state.lastDiscoveryError)
220
+ );
221
+ }
222
+
207
223
  export function createDiscoveryState(
208
224
  entryPath: string | undefined,
209
225
  opts: PluginOptions | undefined,
@@ -46,6 +46,22 @@ function devDiscoveryBootstrap(state: DiscoveryState): string[] {
46
46
  return [`globalThis.__RANGO_DEV_DISCOVERY_EPOCH = ${epoch};`];
47
47
  }
48
48
 
49
+ /** Clear, then install every projection in state.clientUrlProjectionMap. */
50
+ function clientUrlProjectionReplay(state: DiscoveryState): string[] {
51
+ // Sorted: the map fills in transform order, which varies between runs,
52
+ // and this module's bytes feed cache versions (build-versions.ts).
53
+ const projections = [...(state.clientUrlProjectionMap ?? [])].sort(
54
+ ([a], [b]) => compareStrings(a, b),
55
+ );
56
+ return [
57
+ `clearClientUrlProjections();`,
58
+ ...projections.map(
59
+ ([referenceId, projection]) =>
60
+ `setClientUrlProjection(${JSON.stringify(referenceId)}, ${jsonParseExpression(projection)});`,
61
+ ),
62
+ ];
63
+ }
64
+
49
65
  /**
50
66
  * Generate the code for the main virtual:rsc-router/routes-manifest module.
51
67
  */
@@ -110,19 +126,7 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
110
126
  TRANSITION_WHEN_VALIDATION,
111
127
  ];
112
128
 
113
- if (hasClientUrlModules) {
114
- lines.push(`clearClientUrlProjections();`);
115
- // Sorted: the map fills in transform order, which varies between runs,
116
- // and this module's bytes feed cache versions (build-versions.ts).
117
- const projections = [...(state.clientUrlProjectionMap ?? [])].sort(
118
- ([a], [b]) => compareStrings(a, b),
119
- );
120
- for (const [referenceId, projection] of projections) {
121
- lines.push(
122
- `setClientUrlProjection(${JSON.stringify(referenceId)}, ${jsonParseExpression(projection)});`,
123
- );
124
- }
125
- }
129
+ if (hasClientUrlModules) lines.push(...clientUrlProjectionReplay(state));
126
130
 
127
131
  if (varIdx > 0) {
128
132
  lines.push(
@@ -197,13 +201,14 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
197
201
  return lines.join("\n");
198
202
  }
199
203
 
204
+ // No manifest: discovery has not committed one (a failed cold pass commits
205
+ // nothing). The projections the failed pass computed still replay, so the
206
+ // realm serving requests can resolve include()s of clientUrls() modules.
200
207
  const lines: string[] = [
201
- `import { ${hasClientUrlModules ? "clearClientUrlProjections, " : ""}enableTransitionWhenValidation } from "@rangojs/router/server";`,
208
+ `import { ${hasClientUrlModules ? "clearClientUrlProjections, setClientUrlProjection, " : ""}enableTransitionWhenValidation } from "@rangojs/router/server";`,
202
209
  TRANSITION_WHEN_VALIDATION,
203
210
  ];
204
- if (hasClientUrlModules) {
205
- lines.push(`clearClientUrlProjections();`);
206
- }
211
+ if (hasClientUrlModules) lines.push(...clientUrlProjectionReplay(state));
207
212
  if (!state.isBuildMode) {
208
213
  const origin =
209
214
  state.devServerOrigin ||