@rangojs/router 0.31.0 → 0.33.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 (192) hide show
  1. package/README.md +3 -2
  2. package/dist/bin/browser-7KIJ5HSV.js +15 -0
  3. package/dist/bin/chunk-3B7CKBRO.js +463 -0
  4. package/dist/bin/chunk-524VACCG.js +71 -0
  5. package/dist/bin/chunk-LD7UBHT4.js +196 -0
  6. package/dist/bin/chunk-TO6CRWLN.js +1429 -0
  7. package/dist/bin/chunk-TTXC2U3G.js +2477 -0
  8. package/dist/bin/client-urls-projection-DQLWPDAT.js +28 -0
  9. package/dist/bin/forward-user-plugins-URBZKJP4.js +62 -0
  10. package/dist/bin/rango.js +42 -10143
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-CZ2QHT4Q.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-QRR6HD6T.js +3190 -0
  15. package/dist/types/bin/rango.d.ts +1 -0
  16. package/dist/types/browser/action-fence.d.ts +20 -0
  17. package/dist/types/browser/cookie-name.d.ts +51 -10
  18. package/dist/types/browser/history-state.d.ts +13 -2
  19. package/dist/types/browser/invalidate-client-cache.d.ts +8 -6
  20. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  21. package/dist/types/browser/navigation-client.d.ts +19 -0
  22. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  23. package/dist/types/browser/navigation-store.d.ts +2 -2
  24. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  25. package/dist/types/browser/partial-update.d.ts +13 -0
  26. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  27. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  28. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  29. package/dist/types/browser/rango-state.d.ts +34 -5
  30. package/dist/types/browser/react/Link.d.ts +19 -13
  31. package/dist/types/browser/react/location-state-shared.d.ts +2 -1
  32. package/dist/types/browser/types.d.ts +25 -0
  33. package/dist/types/build/generate-route-types.d.ts +1 -1
  34. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  35. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  36. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  37. package/dist/types/cache/cache-scope.d.ts +4 -2
  38. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  39. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  40. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  41. package/dist/types/cache/segment-codec.d.ts +2 -2
  42. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  43. package/dist/types/cache/types.d.ts +12 -7
  44. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  45. package/dist/types/client-urls/types.d.ts +7 -5
  46. package/dist/types/prefetch-markers.d.ts +9 -0
  47. package/dist/types/prefetch-source-scope.d.ts +66 -0
  48. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  49. package/dist/types/route-definition/redirect.d.ts +5 -0
  50. package/dist/types/router/intercept-resolution.d.ts +25 -11
  51. package/dist/types/router/match-context.d.ts +1 -0
  52. package/dist/types/router/match-handlers.d.ts +1 -1
  53. package/dist/types/router/middleware-types.d.ts +6 -0
  54. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  55. package/dist/types/router/router-context.d.ts +1 -1
  56. package/dist/types/router/router-options.d.ts +6 -3
  57. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  58. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  59. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  60. package/dist/types/router/source-reads.d.ts +53 -0
  61. package/dist/types/router/types.d.ts +1 -1
  62. package/dist/types/rsc/helpers.d.ts +8 -3
  63. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  64. package/dist/types/rsc/request-stages.d.ts +4 -0
  65. package/dist/types/rsc/shell-serve.d.ts +6 -1
  66. package/dist/types/server/context.d.ts +23 -6
  67. package/dist/types/server/cookie-store.d.ts +15 -1
  68. package/dist/types/server/document-location-state.d.ts +59 -0
  69. package/dist/types/server/handle-store.d.ts +2 -0
  70. package/dist/types/server/request-context.d.ts +65 -12
  71. package/dist/types/testing/e2e/server.d.ts +16 -1
  72. package/dist/types/testing/render-route.d.ts +83 -2
  73. package/dist/types/testing/serve-shell-request.d.ts +15 -3
  74. package/dist/types/types/handler-context.d.ts +21 -4
  75. package/dist/types/types/loader-types.d.ts +7 -4
  76. package/dist/types/types/segments.d.ts +18 -1
  77. package/dist/types/urls/path-helper-types.d.ts +7 -5
  78. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  79. package/dist/types/vite/utils/forward-user-plugins.d.ts +32 -2
  80. package/dist/vite/index.js +451 -191
  81. package/package.json +2 -2
  82. package/skills/app-structure/SKILL.md +74 -0
  83. package/skills/app-structure/e-commerce.md +10 -9
  84. package/skills/app-structure/large-app.md +15 -2
  85. package/skills/caching/SKILL.md +10 -5
  86. package/skills/client-urls/SKILL.md +18 -11
  87. package/skills/comparison/references/framework-comparison.md +16 -10
  88. package/skills/document-cache/SKILL.md +13 -0
  89. package/skills/hooks/navigation.md +8 -8
  90. package/skills/hooks/state.md +132 -12
  91. package/skills/intercept/SKILL.md +33 -11
  92. package/skills/layout/SKILL.md +11 -0
  93. package/skills/links/SKILL.md +101 -4
  94. package/skills/loader/SKILL.md +49 -22
  95. package/skills/migrate-nextjs/SKILL.md +8 -2
  96. package/skills/migrate-react-router/data-and-actions.md +5 -0
  97. package/skills/ppr/SKILL.md +38 -5
  98. package/skills/prerender/SKILL.md +4 -1
  99. package/skills/route/SKILL.md +6 -6
  100. package/skills/router-setup/SKILL.md +2 -1
  101. package/skills/server-actions/SKILL.md +49 -8
  102. package/skills/testing/client-components.md +73 -32
  103. package/skills/testing/serve-shell-request.md +45 -20
  104. package/skills/testing/server-actions.md +8 -0
  105. package/skills/ux-architecture/SKILL.md +67 -15
  106. package/src/bin/rango.ts +39 -13
  107. package/src/browser/action-fence.ts +72 -0
  108. package/src/browser/cookie-name.ts +82 -14
  109. package/src/browser/history-state.ts +24 -2
  110. package/src/browser/invalidate-client-cache.ts +9 -7
  111. package/src/browser/navigation-bridge.ts +164 -32
  112. package/src/browser/navigation-client.ts +95 -20
  113. package/src/browser/navigation-store-handle.ts +67 -5
  114. package/src/browser/navigation-store.ts +86 -17
  115. package/src/browser/navigation-transaction.ts +0 -6
  116. package/src/browser/partial-update.ts +31 -12
  117. package/src/browser/prefetch/cache.ts +188 -11
  118. package/src/browser/prefetch/fetch.ts +211 -51
  119. package/src/browser/prefetch/queue.ts +3 -2
  120. package/src/browser/rango-state.ts +50 -8
  121. package/src/browser/react/Link.tsx +24 -16
  122. package/src/browser/react/location-state-shared.ts +8 -1
  123. package/src/browser/react/use-navigation.ts +1 -1
  124. package/src/browser/rsc-router.tsx +13 -3
  125. package/src/browser/server-action-bridge.ts +3 -3
  126. package/src/browser/types.ts +25 -0
  127. package/src/build/generate-route-types.ts +1 -0
  128. package/src/build/route-types/include-resolution.ts +40 -3
  129. package/src/build/route-types/router-processing.ts +170 -25
  130. package/src/build/route-types/scan-filter.ts +1 -0
  131. package/src/build/runtime-discovery.ts +46 -8
  132. package/src/cache/cache-key-utils.ts +12 -8
  133. package/src/cache/cache-runtime.ts +5 -1
  134. package/src/cache/cache-scope.ts +28 -7
  135. package/src/cache/cf/cf-cache-types.ts +9 -5
  136. package/src/cache/document-cache-policy.ts +37 -1
  137. package/src/cache/document-cache.ts +12 -0
  138. package/src/cache/memory-segment-store.ts +3 -2
  139. package/src/cache/segment-codec.ts +2 -2
  140. package/src/cache/shell-snapshot.ts +1 -1
  141. package/src/cache/types.ts +12 -7
  142. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  143. package/src/client-urls/types.ts +7 -5
  144. package/src/prefetch-markers.ts +10 -0
  145. package/src/prefetch-source-scope.ts +137 -0
  146. package/src/route-definition/helpers-types.ts +8 -5
  147. package/src/route-definition/redirect.ts +5 -0
  148. package/src/router/intercept-resolution.ts +94 -50
  149. package/src/router/loader-resolution.ts +8 -6
  150. package/src/router/match-api.ts +33 -29
  151. package/src/router/match-context.ts +5 -0
  152. package/src/router/match-handlers.ts +2 -1
  153. package/src/router/match-middleware/cache-lookup.ts +39 -14
  154. package/src/router/match-result.ts +45 -85
  155. package/src/router/middleware-types.ts +6 -0
  156. package/src/router/navigation-snapshot.ts +18 -3
  157. package/src/router/prefetch-cache-ttl.ts +15 -3
  158. package/src/router/revalidation.ts +63 -13
  159. package/src/router/router-context.ts +2 -1
  160. package/src/router/router-options.ts +6 -3
  161. package/src/router/segment-resolution/loader-cache.ts +21 -3
  162. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  163. package/src/router/segment-resolution/revalidation.ts +40 -12
  164. package/src/router/source-reads.ts +122 -0
  165. package/src/router/types.ts +2 -1
  166. package/src/router.ts +11 -12
  167. package/src/rsc/helpers.ts +20 -6
  168. package/src/rsc/prefetch-render-error.ts +7 -2
  169. package/src/rsc/progressive-enhancement.ts +8 -2
  170. package/src/rsc/request-stages.ts +87 -1
  171. package/src/rsc/rsc-rendering.ts +78 -32
  172. package/src/rsc/server-action.ts +2 -1
  173. package/src/rsc/shell-capture.ts +4 -5
  174. package/src/rsc/shell-serve.ts +12 -1
  175. package/src/segment-system.tsx +1 -1
  176. package/src/server/context.ts +37 -6
  177. package/src/server/cookie-store.ts +22 -1
  178. package/src/server/document-location-state.ts +334 -0
  179. package/src/server/handle-store.ts +2 -0
  180. package/src/server/request-context.ts +137 -33
  181. package/src/testing/dispatch.ts +6 -0
  182. package/src/testing/e2e/fixture.ts +4 -12
  183. package/src/testing/e2e/server.ts +91 -4
  184. package/src/testing/render-route.tsx +309 -10
  185. package/src/testing/run-revalidate.ts +11 -1
  186. package/src/testing/serve-shell-request.ts +22 -3
  187. package/src/types/handler-context.ts +21 -4
  188. package/src/types/loader-types.ts +7 -4
  189. package/src/types/segments.ts +18 -1
  190. package/src/urls/path-helper-types.ts +7 -5
  191. package/src/vite/temp-rsc-server.ts +2 -2
  192. package/src/vite/utils/forward-user-plugins.ts +13 -5
@@ -27,7 +27,7 @@ interface Snapshot<T> {
27
27
  /**
28
28
  * A notification while React holds a page: work that starts shows at once
29
29
  * (loading, streaming, a newer click's URL), what settles waits for the
30
- * page's commit (rango-d152), and location moves only with the notification
30
+ * page's commit, and location moves only with the notification
31
31
  * that moves it (the page on screen until the next one shows).
32
32
  */
33
33
  function hold(
@@ -27,7 +27,11 @@ import type { EventController } from "./event-controller.js";
27
27
  import type { ResolvedThemeConfig, Theme } from "../theme/types.js";
28
28
  import { expandPayloadFragments } from "../segment-fragments.js";
29
29
  import { initRangoState } from "./rango-state.js";
30
- import { registerNavigationStore } from "./navigation-store-handle.js";
30
+ import {
31
+ committedPageOf,
32
+ registerCommittedPageReader,
33
+ registerNavigationStore,
34
+ } from "./navigation-store-handle.js";
31
35
  import { initPrefetchCache } from "./prefetch/cache.js";
32
36
  import {
33
37
  auditTreeCause,
@@ -47,6 +51,7 @@ import {
47
51
  import { createAppShellRef } from "./app-shell.js";
48
52
  import { bootLog, IS_BROWSER_DEBUG } from "./logging.js";
49
53
  import {
54
+ applyDocumentLocationState,
50
55
  clearLocationStateOnDocumentLoad,
51
56
  stripShellMissMarker,
52
57
  } from "./history-state.js";
@@ -274,8 +279,10 @@ export async function initBrowserApp(
274
279
  setSuspenseAuditStreamProbe(() => eventController.getState().isStreaming);
275
280
  }
276
281
 
277
- // The document's entry, after the clearOnReload pass and under the version
278
- // set above: what readers get once they have hydrated.
282
+ // The state the document response carried, after the clearOnReload pass
283
+ // and under the version set above.
284
+ applyDocumentLocationState(initialPayload.metadata?.locationState);
285
+ // The document's entry: what readers get once they have hydrated.
279
286
  eventController.commitLocationState(window.history.state);
280
287
 
281
288
  // Initialize event controller with segment order (even without handles)
@@ -283,6 +290,9 @@ export async function initBrowserApp(
283
290
 
284
291
  // Initialize route params
285
292
  eventController.setParams(initialPayload.metadata?.params ?? {});
293
+ // The page a prefetch is sent from, read by the prefetch cache at request
294
+ // time (browser/prefetch/cache.ts paramsCandidateKeys).
295
+ registerCommittedPageReader(committedPageOf(eventController, store));
286
296
 
287
297
  // Initialize handle data from initial payload BEFORE hydration
288
298
  // This ensures useHandle returns correct data during hydration to avoid mismatch
@@ -271,7 +271,7 @@ export function createServerActionBridge(
271
271
  * - Never over an intercept: a request holding no segments drops the page
272
272
  * under it (match-result.ts buildMatchResult). Checked when it would run,
273
273
  * against the page then on screen; the debt is dropped there.
274
- * - Never supersedes a navigation the user started (dc-t114): it waits for
274
+ * - Never supersedes a navigation the user started: it waits for
275
275
  * NavigationEntry.settled, then looks again. A navigation that failed
276
276
  * keeps its error on screen, and the debt is dropped.
277
277
  * - Runs again when it is left before its commit (a navigation, or an action
@@ -322,7 +322,7 @@ export function createServerActionBridge(
322
322
  * R6: an action that starts while the follow-up has not committed leaves
323
323
  * it, and the batch's end owes it again. Its request may predate that
324
324
  * action's write, and its commit could land after the action's own,
325
- * moving data on screen backwards (dc-t110). Through the controller, so
325
+ * moving data on screen backwards. Through the controller, so
326
326
  * the navigation it started stops being current.
327
327
  */
328
328
  function leaveFollowUp(): void {
@@ -339,7 +339,7 @@ export function createServerActionBridge(
339
339
  * repairs. Resolves with whether it started and the error of a loader
340
340
  * that threw on it. Never starts while a navigation the user started may
341
341
  * be superseded (unsettledUserNavigation): its startNavigation would
342
- * abort it (dc-t114). A repair that cannot start is owed at the batch's
342
+ * abort it. A repair that cannot start is owed at the batch's
343
343
  * end instead (finalizeAction). `run` (the follow-up's) is handed the
344
344
  * refetch's navigation, and told once whether it ended left before its
345
345
  * commit.
@@ -703,7 +703,15 @@ export interface NavigationStore {
703
703
  */
704
704
  getPageHistoryKeys(historyKey: string): string[];
705
705
  markHistoryCacheStale(): void;
706
+ /**
707
+ * Mark history entries and prefetches stale without a broadcast: a cookie
708
+ * read saw another write (rango-state.ts setRangoStateWriteObserver).
709
+ */
710
+ markCachesStaleAfterWrite(): void;
711
+ /** A server action without a directive: mark stale, soft broadcast. */
706
712
  markCacheAsStaleAndBroadcast(): void;
713
+ /** The client seat of invalidateClientCache(): flush, rotate, broadcast. */
714
+ invalidateCacheAndBroadcast(): void;
707
715
  clearHistoryCache(): void;
708
716
 
709
717
  // Cross-tab refresh callback (set by navigation bridge)
@@ -753,6 +761,16 @@ export interface FetchPartialOptions {
753
761
  * to a prefetch cache or the HTTP cache, and it cancels no prefetch.
754
762
  */
755
763
  fill?: boolean;
764
+ /**
765
+ * Whether a prefetch a server action marked stale (prefetch/cache.ts
766
+ * isPrefetchStale) may answer this navigation. Only a click sets it: it
767
+ * revalidates its page once it settled (navigation-bridge.ts navigate,
768
+ * partial-update.ts onStalePrefetch). For any other caller (Back/Forward on
769
+ * a history-cache miss, leaving an intercept) a stale stored entry is a
770
+ * miss, and a stale in-flight one is dropped and fetched again, so no path
771
+ * that never revalidates commits pre-write data.
772
+ */
773
+ acceptStalePrefetch?: boolean;
756
774
  }
757
775
 
758
776
  /**
@@ -771,6 +789,13 @@ export interface FetchPartialResult {
771
789
  * fallbacks stream as usual.
772
790
  */
773
791
  fullyPrefetched?: boolean;
792
+ /**
793
+ * True when the payload is a prefetch a server action marked stale after it
794
+ * was fetched (prefetch/cache.ts isPrefetchStale). The click commits it as
795
+ * any prefetch; the navigation then revalidates its page once it settled,
796
+ * as Back onto a stale history entry does (navigation-bridge.ts navigate).
797
+ */
798
+ stalePrefetch?: boolean;
774
799
  }
775
800
 
776
801
  /**
@@ -34,6 +34,7 @@ export {
34
34
  formatNestedRouterConflictError,
35
35
  findRouterFiles,
36
36
  findHostRouterFiles,
37
+ isRoutableSourceFile,
37
38
  writeCombinedRouteTypes,
38
39
  genFileTsPath,
39
40
  resolveSearchSchemas,
@@ -13,7 +13,8 @@ export type UnresolvableReason =
13
13
  | "dynamic-expression"
14
14
  | "unresolvable-import"
15
15
  | "file-not-found"
16
- | "unresolvable-basename";
16
+ | "unresolvable-basename"
17
+ | "unresolvable-routes";
17
18
 
18
19
  export interface UnresolvableInclude {
19
20
  pathPrefix: string;
@@ -78,14 +79,50 @@ function readSourceMemoized(
78
79
  return source;
79
80
  }
80
81
 
81
- function isUrlsDefinitionCall(node: ts.CallExpression): boolean {
82
+ export function isUrlsDefinitionCall(
83
+ node: ts.CallExpression,
84
+ aliases?: ReadonlySet<string>,
85
+ ): boolean {
82
86
  const callee = node.expression;
83
87
  return (
84
88
  ts.isIdentifier(callee) &&
85
- (callee.text === "urls" || callee.text === "clientUrls")
89
+ (callee.text === "urls" ||
90
+ callee.text === "clientUrls" ||
91
+ aliases?.has(callee.text) === true)
86
92
  );
87
93
  }
88
94
 
95
+ /**
96
+ * Local names that `import { urls as u, clientUrls as cu } from
97
+ * "@rangojs/router"` (or `/client`) binds to urls()/clientUrls(), so a renamed
98
+ * import is read like the plain name. Other modules' exports are not guessed
99
+ * at: a `urls` from elsewhere is read only under its own name.
100
+ */
101
+ export function collectUrlsImportAliases(
102
+ sourceFile: ts.SourceFile,
103
+ ): ReadonlySet<string> {
104
+ const aliases = new Set<string>();
105
+ for (const stmt of sourceFile.statements) {
106
+ if (
107
+ !ts.isImportDeclaration(stmt) ||
108
+ !ts.isStringLiteral(stmt.moduleSpecifier) ||
109
+ (stmt.moduleSpecifier.text !== "@rangojs/router" &&
110
+ stmt.moduleSpecifier.text !== "@rangojs/router/client")
111
+ ) {
112
+ continue;
113
+ }
114
+ const bindings = stmt.importClause?.namedBindings;
115
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
116
+ for (const el of bindings.elements) {
117
+ const imported = (el.propertyName ?? el.name).text;
118
+ if (imported === "urls" || imported === "clientUrls") {
119
+ aliases.add(el.name.text);
120
+ }
121
+ }
122
+ }
123
+ return aliases;
124
+ }
125
+
89
126
  // ---------------------------------------------------------------------------
90
127
  // AST-based include() parsing
91
128
  // ---------------------------------------------------------------------------
@@ -21,6 +21,8 @@ import {
21
21
  resolveImportPath,
22
22
  buildCombinedRouteMapWithSearch,
23
23
  createScanMemo,
24
+ isUrlsDefinitionCall,
25
+ collectUrlsImportAliases,
24
26
  type ScanMemo,
25
27
  type UnresolvableInclude,
26
28
  } from "./include-resolution.js";
@@ -46,7 +48,9 @@ const ROUTER_CALL_PATTERN = /\bcreateRouter\s*[<(]/;
46
48
  // Global variant for the code-region scan (firstCodeMatchIndex sets lastIndex).
47
49
  const ROUTER_CALL_PATTERN_G = /\bcreateRouter\s*[<(]/g;
48
50
 
49
- function isRoutableSourceFile(name: string): boolean {
51
+ // Test files build their own routers (a Flight test's `*.rsc-test.tsx` usually
52
+ // mounts `createRouter({}).routes(urls(...))`); none is an app's router root.
53
+ export function isRoutableSourceFile(name: string): boolean {
50
54
  return (
51
55
  (name.endsWith(".ts") ||
52
56
  name.endsWith(".tsx") ||
@@ -54,6 +58,7 @@ function isRoutableSourceFile(name: string): boolean {
54
58
  name.endsWith(".jsx")) &&
55
59
  !name.includes(".gen.") &&
56
60
  !name.includes(".test.") &&
61
+ !name.includes(".rsc-test.") &&
57
62
  !name.includes(".spec.")
58
63
  );
59
64
  }
@@ -205,13 +210,19 @@ function isCreateRouterCall(node: ts.Node): boolean {
205
210
  }
206
211
 
207
212
  function extractUrlsArgument(
208
- node: ts.Expression,
213
+ arg: ts.Expression,
209
214
  sourceFile: ts.SourceFile,
215
+ urlsAliases: ReadonlySet<string>,
210
216
  ): UrlsExtractionResult | null {
217
+ const node = unwrapTypeOperators(arg);
211
218
  if (ts.isIdentifier(node)) {
212
219
  return { kind: "variable", name: node.text };
213
220
  }
214
- if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) {
221
+ if (
222
+ ts.isArrowFunction(node) ||
223
+ ts.isFunctionExpression(node) ||
224
+ (ts.isCallExpression(node) && isUrlsDefinitionCall(node, urlsAliases))
225
+ ) {
215
226
  return { kind: "inline", block: node.getText(sourceFile) };
216
227
  }
217
228
  return null;
@@ -234,24 +245,135 @@ function collectCreateRouterCallChain(
234
245
  }
235
246
  }
236
247
 
237
- // The first createRouter chain (in source order) that mounts urls, with its
238
- // extractions in runtime order. Route types and basename both come from it.
248
+ // Local names a file exports other than by an `export` modifier on their own
249
+ // declaration: `export { a, b as c }`, `export default a`, `export = a` and
250
+ // `export const x = a` (a one-hop alias).
251
+ function collectExportedLocalNames(sourceFile: ts.SourceFile): Set<string> {
252
+ const names = new Set<string>();
253
+ const addIdentifier = (expr: ts.Expression | undefined) => {
254
+ const node = expr && unwrapTypeOperators(expr);
255
+ if (node && ts.isIdentifier(node)) names.add(node.text);
256
+ };
257
+ for (const statement of sourceFile.statements) {
258
+ if (ts.isExportAssignment(statement)) {
259
+ addIdentifier(statement.expression);
260
+ } else if (
261
+ ts.isExportDeclaration(statement) &&
262
+ !statement.moduleSpecifier &&
263
+ !statement.isTypeOnly &&
264
+ statement.exportClause &&
265
+ ts.isNamedExports(statement.exportClause)
266
+ ) {
267
+ for (const element of statement.exportClause.elements) {
268
+ if (!element.isTypeOnly) {
269
+ names.add((element.propertyName ?? element.name).text);
270
+ }
271
+ }
272
+ } else if (
273
+ ts.isVariableStatement(statement) &&
274
+ hasModifier(statement, ts.SyntaxKind.ExportKeyword)
275
+ ) {
276
+ for (const decl of statement.declarationList.declarations) {
277
+ addIdentifier(decl.initializer);
278
+ }
279
+ }
280
+ }
281
+ return names;
282
+ }
283
+
284
+ // Whether a createRouter chain is the value a module exports: the initializer
285
+ // of an exported top-level variable (or of one an export list or default
286
+ // export names), or the `export default` / `export =` expression itself,
287
+ // through `as`, `satisfies` and parentheses. A chain inside a function or class
288
+ // body, an argument of another call or a property of an object literal is not
289
+ // exported even when the enclosing declaration is.
290
+ function isExportedChain(
291
+ chainEnd: ts.CallExpression,
292
+ exportedNames: ReadonlySet<string>,
293
+ ): boolean {
294
+ let current: ts.Node = chainEnd;
295
+ while (
296
+ current.parent &&
297
+ (ts.isAsExpression(current.parent) ||
298
+ ts.isSatisfiesExpression(current.parent) ||
299
+ ts.isParenthesizedExpression(current.parent))
300
+ ) {
301
+ current = current.parent;
302
+ }
303
+ const parent = current.parent;
304
+ if (!parent) return false;
305
+ if (ts.isExportAssignment(parent))
306
+ return parent.parent === current.getSourceFile();
307
+ if (
308
+ !ts.isVariableDeclaration(parent) ||
309
+ parent.initializer !== current ||
310
+ !ts.isIdentifier(parent.name)
311
+ ) {
312
+ return false;
313
+ }
314
+ const statement = parent.parent.parent;
315
+ if (
316
+ !ts.isVariableStatement(statement) ||
317
+ !ts.isSourceFile(statement.parent)
318
+ ) {
319
+ return false;
320
+ }
321
+ return (
322
+ hasModifier(statement, ts.SyntaxKind.ExportKeyword) ||
323
+ exportedNames.has(parent.name.text)
324
+ );
325
+ }
326
+
327
+ // The createRouter chain whose urls the route types and basename come from,
328
+ // with its extractions in runtime order. A file may hold several chains (a
329
+ // helper router beside the app's): an exported chain always wins over one that
330
+ // is not exported, so a helper never supplies the app's routes or basename. Only when no chain is exported (a lone router in a test file)
331
+ // does a non-exported one count. Among the candidates, the first (in source
332
+ // order) that mounts a readable urls wins.
333
+ // `unreadable` holds the source of each mount the static parser cannot follow
334
+ // (`.routes(buildUrls())`, `urls: mod.tree`): its routes are missing from the
335
+ // extractions, so detectUnresolvableIncludes reports it rather than letting
336
+ // the gen file silently lose them. A chain with only unreadable
337
+ // mounts is returned only when no candidate has a readable one. An exported
338
+ // chain that mounts nothing still hides the non-exported ones.
239
339
  function findRouterUrls(sourceFile: ts.SourceFile): {
240
340
  createRouterCall: ts.CallExpression;
241
341
  extractions: UrlsExtractionResult[];
342
+ unreadable: string[];
242
343
  } | null {
243
- let result: {
344
+ type RouterUrls = {
244
345
  createRouterCall: ts.CallExpression;
245
346
  extractions: UrlsExtractionResult[];
246
- } | null = null;
347
+ unreadable: string[];
348
+ };
349
+ const exported: RouterUrls[] = [];
350
+ const local: RouterUrls[] = [];
351
+ let anyExported = false;
352
+ const seen = new Set<ts.CallExpression>();
353
+ const urlsAliases = collectUrlsImportAliases(sourceFile);
354
+ const exportedNames = collectExportedLocalNames(sourceFile);
247
355
 
248
356
  function visit(node: ts.Node) {
249
- if (result) return;
250
357
  if (ts.isCallExpression(node)) {
251
358
  const chain = collectCreateRouterCallChain(node);
252
- if (chain) {
359
+ if (chain && !seen.has(chain[0])) {
253
360
  const extracted: UrlsExtractionResult[] = [];
361
+ const unreadable: string[] = [];
254
362
  const createRouterCall = chain[0];
363
+ seen.add(createRouterCall);
364
+ // `mount` wraps the argument's source for the diagnostic; the text is
365
+ // only built for an argument the parser cannot read.
366
+ const addMount = (
367
+ arg: ts.Expression,
368
+ mount: (text: string) => string,
369
+ ) => {
370
+ const urls = extractUrlsArgument(arg, sourceFile, urlsAliases);
371
+ if (urls) extracted.push(urls);
372
+ else
373
+ unreadable.push(
374
+ mount(arg.getText(sourceFile).replace(/\s+/g, " ")),
375
+ );
376
+ };
255
377
 
256
378
  for (const callArg of createRouterCall.arguments) {
257
379
  if (!ts.isObjectLiteralExpression(callArg)) continue;
@@ -263,8 +385,7 @@ function findRouterUrls(sourceFile: ts.SourceFile): {
263
385
  ) {
264
386
  continue;
265
387
  }
266
- const urls = extractUrlsArgument(prop.initializer, sourceFile);
267
- if (urls) extracted.push(urls);
388
+ addMount(prop.initializer, (text) => `urls: ${text}`);
268
389
  break;
269
390
  }
270
391
  }
@@ -277,13 +398,17 @@ function findRouterUrls(sourceFile: ts.SourceFile): {
277
398
  ) {
278
399
  continue;
279
400
  }
280
- const urls = extractUrlsArgument(call.arguments[0], sourceFile);
281
- if (urls) extracted.push(urls);
401
+ addMount(call.arguments[0], (text) => `.routes(${text})`);
282
402
  }
283
403
 
284
- if (extracted.length > 0) {
285
- result = { createRouterCall, extractions: extracted };
286
- return;
404
+ const isExported = isExportedChain(node, exportedNames);
405
+ if (isExported) anyExported = true;
406
+ if (extracted.length > 0 || unreadable.length > 0) {
407
+ (isExported ? exported : local).push({
408
+ createRouterCall,
409
+ extractions: extracted,
410
+ unreadable,
411
+ });
287
412
  }
288
413
  }
289
414
  }
@@ -291,27 +416,34 @@ function findRouterUrls(sourceFile: ts.SourceFile): {
291
416
  }
292
417
 
293
418
  visit(sourceFile);
294
- return result;
419
+ const candidates = anyExported ? exported : local;
420
+ return (
421
+ candidates.find((c) => c.extractions.length > 0) ?? candidates[0] ?? null
422
+ );
295
423
  }
296
424
 
297
- function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
298
- const sourceFile = ts.createSourceFile(
425
+ function parseRouterSource(code: string): ts.SourceFile {
426
+ return ts.createSourceFile(
299
427
  "router.tsx",
300
428
  code,
301
429
  ts.ScriptTarget.Latest,
302
430
  true,
303
431
  ts.ScriptKind.TSX,
304
432
  );
305
- return findRouterUrls(sourceFile)?.extractions ?? [];
433
+ }
434
+
435
+ function extractAllUrlsFromRouter(code: string): UrlsExtractionResult[] {
436
+ return findRouterUrls(parseRouterSource(code))?.extractions ?? [];
306
437
  }
307
438
 
308
439
  /**
309
440
  * Extract the url patterns from a router file using AST.
310
- * Detects four patterns:
441
+ * Detects these patterns, in either mount (`.routes(x)` or `urls: x`):
311
442
  * 1. createRouter(...).routes(variableName)
312
443
  * 2. createRouter({ urls: variableName, ... })
313
444
  * 3. createRouter(...).routes(({ path, ... }) => [...])
314
445
  * 4. createRouter({ urls: ({ path, ... }) => [...], ... })
446
+ * 5. createRouter(...).routes(urls(({ path, ... }) => [...])), and clientUrls()
315
447
  * Returns either a variable name or an inline code block.
316
448
  */
317
449
  export function extractUrlsFromRouter(
@@ -322,7 +454,11 @@ export function extractUrlsFromRouter(
322
454
  }
323
455
 
324
456
  function unwrapTypeOperators(expr: ts.Expression): ts.Expression {
325
- while (ts.isAsExpression(expr) || ts.isSatisfiesExpression(expr)) {
457
+ while (
458
+ ts.isAsExpression(expr) ||
459
+ ts.isSatisfiesExpression(expr) ||
460
+ ts.isParenthesizedExpression(expr)
461
+ ) {
326
462
  expr = expr.expression;
327
463
  }
328
464
  return expr;
@@ -699,12 +835,21 @@ export function detectUnresolvableIncludes(
699
835
  return [];
700
836
  }
701
837
 
702
- const extractions = extractAllUrlsFromRouter(source);
703
- if (extractions.length === 0) return [];
838
+ const routerUrls = findRouterUrls(parseRouterSource(source));
839
+ if (!routerUrls) return [];
704
840
 
705
841
  const diagnostics: UnresolvableInclude[] = [];
842
+ for (const mount of routerUrls.unreadable) {
843
+ diagnostics.push({
844
+ pathPrefix: "/",
845
+ namePrefix: null,
846
+ reason: "unresolvable-routes",
847
+ sourceFile: realPath,
848
+ detail: `\`${mount}\` is not a urls() call (as imported from @rangojs/router), a builder function or a variable; its routes would be missing`,
849
+ });
850
+ }
706
851
  const memo = createScanMemo();
707
- for (const extraction of extractions) {
852
+ for (const extraction of routerUrls.extractions) {
708
853
  resolveRouterUrlsExtraction(
709
854
  realPath,
710
855
  source,
@@ -10,6 +10,7 @@ export const DEFAULT_EXCLUDE_PATTERNS: string[] = [
10
10
  "**/dist/**",
11
11
  "**/coverage/**",
12
12
  "**/*.test.{ts,tsx,js,jsx}",
13
+ "**/*.rsc-test.{ts,tsx,js,jsx}",
13
14
  "**/*.spec.{ts,tsx,js,jsx}",
14
15
  ];
15
16
 
@@ -37,10 +37,12 @@ export async function discoverAndWriteRouteTypes(
37
37
  outputFiles: string[];
38
38
  }> {
39
39
  let loadConfigFromFile: typeof import("vite").loadConfigFromFile;
40
+ let resolveConfig: typeof import("vite").resolveConfig;
40
41
 
41
42
  try {
42
43
  const vite = await import("vite");
43
44
  loadConfigFromFile = vite.loadConfigFromFile;
45
+ resolveConfig = vite.resolveConfig;
44
46
  } catch {
45
47
  throw new Error(
46
48
  "Runtime discovery requires 'vite'. Install it with: pnpm add -D vite",
@@ -59,14 +61,19 @@ export async function discoverAndWriteRouteTypes(
59
61
  const { createDiscoveryState } = await import("../vite/discovery/state.js");
60
62
  const { discoverClientUrlProjections } =
61
63
  await import("../vite/discovery/client-urls-projection.js");
62
- const { pickForwardedRunnerConfig, selectForwardableResolvePlugins } =
63
- await import("../vite/utils/forward-user-plugins.js");
64
+ const {
65
+ isFrameworkPlugin,
66
+ pickForwardedRunnerConfig,
67
+ selectForwardableResolvePlugins,
68
+ } = await import("../vite/utils/forward-user-plugins.js");
64
69
 
65
70
  // The discovery temp server of `vite dev/build` (expose-internal-ids, the
66
71
  // cloudflare:* stubs, the user's resolver plugins and resolve/define/oxc).
67
- // The config stays unresolved: resolving it would run rango()'s
68
- // configResolved, which writes the static gen files. Forwarded resolvers
69
- // therefore run without their own config/configResolved hooks.
72
+ // Resolver plugins are forwarded from a resolved config, as `vite dev` does:
73
+ // vite-tsconfig-paths and the like build their matchers in config /
74
+ // configResolved, and resolving drops `apply: "build"` plugins. The
75
+ // framework plugins are left out of that resolve: rango()'s configResolved
76
+ // writes the static gen files.
70
77
  const entryPath = resolve(opts.entry);
71
78
  const state = createDiscoveryState(entryPath, undefined);
72
79
  state.projectRoot = opts.root;
@@ -80,9 +87,40 @@ export async function discoverAndWriteRouteTypes(
80
87
  );
81
88
  if (loaded) {
82
89
  state.userRunnerConfig = pickForwardedRunnerConfig(loaded.config);
83
- state.userResolvePlugins = selectForwardableResolvePlugins(
84
- await flattenPluginOptions(loaded.config.plugins),
85
- );
90
+ const userPlugins = (
91
+ await flattenPluginOptions(loaded.config.plugins)
92
+ ).filter((p) => !isFrameworkPlugin(p.name ?? ""));
93
+ try {
94
+ // The temp server's root, so a resolver's root-relative state
95
+ // matches the server it runs in.
96
+ const resolved = await resolveConfig(
97
+ {
98
+ ...loaded.config,
99
+ root: opts.root,
100
+ configFile: false,
101
+ plugins: userPlugins,
102
+ },
103
+ "serve",
104
+ "development",
105
+ );
106
+ state.userResolvePlugins = selectForwardableResolvePlugins(
107
+ resolved.plugins,
108
+ );
109
+ } catch (err: any) {
110
+ console.warn(
111
+ `[rango] Could not resolve the Vite config (${err?.message ?? err}); forwarding its resolver plugins without their config hooks. rango() and rsc() are left out of that resolve, so a plugin that needs the rsc environment fails there`,
112
+ );
113
+ // The resolve would have applied `apply`; do the same for the serve
114
+ // pass, including the function form.
115
+ const serveEnv = { command: "serve", mode: "development" } as const;
116
+ state.userResolvePlugins = selectForwardableResolvePlugins(
117
+ userPlugins.filter((p) =>
118
+ typeof p.apply === "function"
119
+ ? p.apply(loaded.config, serveEnv)
120
+ : p.apply !== "build",
121
+ ),
122
+ );
123
+ }
86
124
  }
87
125
  } catch (err: any) {
88
126
  // Discovery without the app's resolvers fails later on an unrelated
@@ -187,9 +187,10 @@ export const KEY_PART_PREFIX = "key:";
187
187
  * (CacheScope.resolveKeyFrom, CacheScope.resolvePartition): the `key()`
188
188
  * results of the scope's cache() chain, outermost first, then its default
189
189
  * parts: the keyGenerator results of enclosing scopes on another store
190
- * (#974) and, for a route record key, the scope's own part: without a
190
+ * (#974) and, for a route record key, the scope's own parts: without a
191
191
  * `key()` of its own, its default key (or its store's keyGenerator result,
192
- * #970); with one, the default key's request kind (`doc`, `partial` or
192
+ * #970, then the request kind when that result does not start with it,
193
+ * #1298); with one, the default key's request kind (`doc`, `partial` or
193
194
  * `intercept:<intercept>`, routeRecordKind in cache-scope.ts).
194
195
  *
195
196
  * Key scheme invariant (#975). A `key()` result is often request input,
@@ -209,7 +210,9 @@ export const KEY_PART_PREFIX = "key:";
209
210
  * - one `key()` part is the key; two or more parts are joined by `|`,
210
211
  * `key()` parts first;
211
212
  * - a lone default part stays raw: with no `key()` on the chain the record
212
- * key is the default key or the store's keyGenerator result, unchanged.
213
+ * key is the default key or the store's keyGenerator result, unchanged
214
+ * when that result starts with its request kind, else that result and
215
+ * the kind composed (`items|doc`).
213
216
  *
214
217
  * Why nothing collides: the router's default keys (`doc:`, `partial:`,
215
218
  * `intercept:`, `response:<type>:`) never start with `key:` and hold a `:`.
@@ -218,11 +221,12 @@ export const KEY_PART_PREFIX = "key:";
218
221
  * Among composed keys, splitting on `|` recovers each part, a part's kind
219
222
  * (a `key:` prefix, or no `:` at all) and its value. A request kind part is
220
223
  * a default key's prefix without the URL after it, so it never equals a
221
- * default key part; a keyGenerator part can equal it (`doc`) only when the
222
- * keyGenerator returns a constant, which already gives every URL one record.
223
- * Pinned by the collision probe in
224
- * src/cache/__tests__/cache-scope-chain.test.ts. A keyGenerator result is
225
- * the store's own namespace, as before.
224
+ * default key part. A raw keyGenerator record key starts with its kind and
225
+ * holds a `:`, so it never equals a composed one, and two kinds' prefixes
226
+ * differ; a result without its kind is composed with it, never prefixed,
227
+ * since `doc:` + `x` would be the raw result `doc:x`. Pinned by the
228
+ * collision probes in src/cache/__tests__/cache-scope-chain.test.ts. A
229
+ * keyGenerator result is the store's own namespace, as before.
226
230
  *
227
231
  * Shared with the testing helper `shellCacheKey`, so a test builds the
228
232
  * production partition.
@@ -25,6 +25,7 @@ import {
25
25
  runAsBackgroundWork,
26
26
  type RequestContext,
27
27
  } from "../server/request-context.js";
28
+ import { segmentPinOf } from "../server/context.js";
28
29
  import { isUnderTestRunner } from "../runtime-env.js";
29
30
  import {
30
31
  isTainted,
@@ -505,8 +506,11 @@ export function registerCachedFunction<T extends (...args: any[]) => any>(
505
506
  if (ctx.body !== undefined || ctx.formData !== undefined) {
506
507
  hasBodyCtx = true;
507
508
  }
509
+ // Only an argument with a pin (a handler or loader ctx, or its copy)
510
+ // is the caller: a request context before it has no claim.
508
511
  if (callerSegmentId === undefined) {
509
- callerSegmentId = ctx._currentSegmentId;
512
+ callerSegmentId =
513
+ ctx._currentSegmentId === undefined ? undefined : segmentPinOf(ctx);
510
514
  claimLoaderPushes = ctx._claimLoaderPushes;
511
515
  }
512
516
  if (ctx.params && typeof ctx.params === "object") {