@rangojs/router 0.32.0 → 0.33.1

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 (181) 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 -10173
  11. package/dist/bin/runtime-EPUDEHO4.js +596 -0
  12. package/dist/bin/runtime-discovery-3HH6BP3K.js +352 -0
  13. package/dist/bin/state-ACD2MGKJ.js +16 -0
  14. package/dist/bin/temp-rsc-server-JALTIWLT.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/invalidate-client-cache.d.ts +8 -6
  19. package/dist/types/browser/navigation-bridge.d.ts +36 -1
  20. package/dist/types/browser/navigation-client.d.ts +19 -0
  21. package/dist/types/browser/navigation-store-handle.d.ts +37 -4
  22. package/dist/types/browser/navigation-store.d.ts +2 -2
  23. package/dist/types/browser/navigation-transaction.d.ts +0 -1
  24. package/dist/types/browser/partial-update.d.ts +13 -0
  25. package/dist/types/browser/prefetch/cache.d.ts +96 -8
  26. package/dist/types/browser/prefetch/fetch.d.ts +26 -2
  27. package/dist/types/browser/prefetch/queue.d.ts +3 -2
  28. package/dist/types/browser/rango-state.d.ts +34 -5
  29. package/dist/types/browser/react/Link.d.ts +15 -6
  30. package/dist/types/browser/types.d.ts +25 -0
  31. package/dist/types/build/generate-route-types.d.ts +1 -1
  32. package/dist/types/build/route-types/include-resolution.d.ts +9 -1
  33. package/dist/types/build/route-types/router-processing.d.ts +3 -1
  34. package/dist/types/cache/cache-key-utils.d.ts +12 -8
  35. package/dist/types/cache/cache-scope.d.ts +4 -2
  36. package/dist/types/cache/cf/cf-cache-types.d.ts +9 -5
  37. package/dist/types/cache/document-cache-policy.d.ts +9 -1
  38. package/dist/types/cache/memory-segment-store.d.ts +3 -2
  39. package/dist/types/cache/segment-codec.d.ts +2 -2
  40. package/dist/types/cache/shell-snapshot.d.ts +1 -1
  41. package/dist/types/cache/types.d.ts +12 -7
  42. package/dist/types/cache/vercel/vercel-cache-store.d.ts +2 -1
  43. package/dist/types/client-urls/types.d.ts +7 -5
  44. package/dist/types/prefetch-markers.d.ts +9 -0
  45. package/dist/types/prefetch-source-scope.d.ts +66 -0
  46. package/dist/types/route-definition/helpers-types.d.ts +8 -5
  47. package/dist/types/router/intercept-resolution.d.ts +25 -11
  48. package/dist/types/router/match-context.d.ts +1 -0
  49. package/dist/types/router/match-handlers.d.ts +1 -1
  50. package/dist/types/router/prefetch-cache-ttl.d.ts +11 -3
  51. package/dist/types/router/router-context.d.ts +1 -1
  52. package/dist/types/router/router-options.d.ts +6 -3
  53. package/dist/types/router/segment-resolution/loader-cache.d.ts +8 -0
  54. package/dist/types/router/segment-resolution/prefetch-deferral.d.ts +23 -7
  55. package/dist/types/router/segment-resolution/revalidation.d.ts +19 -0
  56. package/dist/types/router/source-reads.d.ts +53 -0
  57. package/dist/types/router/types.d.ts +1 -1
  58. package/dist/types/rsc/helpers.d.ts +5 -2
  59. package/dist/types/rsc/prefetch-render-error.d.ts +7 -2
  60. package/dist/types/rsc/request-stages.d.ts +2 -0
  61. package/dist/types/rsc/shell-serve.d.ts +6 -1
  62. package/dist/types/server/context.d.ts +23 -6
  63. package/dist/types/server/cookie-store.d.ts +15 -1
  64. package/dist/types/server/document-location-state.d.ts +5 -2
  65. package/dist/types/server/handle-store.d.ts +2 -0
  66. package/dist/types/server/request-context.d.ts +43 -7
  67. package/dist/types/testing/e2e/server.d.ts +16 -1
  68. package/dist/types/testing/render-route.d.ts +66 -2
  69. package/dist/types/testing/serve-shell-request.d.ts +6 -3
  70. package/dist/types/types/handler-context.d.ts +7 -5
  71. package/dist/types/types/loader-types.d.ts +7 -4
  72. package/dist/types/types/segments.d.ts +15 -4
  73. package/dist/types/urls/path-helper-types.d.ts +7 -5
  74. package/dist/types/vite/temp-rsc-server.d.ts +2 -2
  75. package/dist/vite/index.js +449 -189
  76. package/package.json +2 -2
  77. package/skills/app-structure/SKILL.md +81 -0
  78. package/skills/app-structure/e-commerce.md +10 -9
  79. package/skills/app-structure/large-app.md +18 -2
  80. package/skills/caching/SKILL.md +10 -5
  81. package/skills/client-urls/SKILL.md +29 -11
  82. package/skills/comparison/references/framework-comparison.md +13 -8
  83. package/skills/document-cache/SKILL.md +8 -0
  84. package/skills/hooks/navigation.md +8 -8
  85. package/skills/hooks/outlets.md +4 -0
  86. package/skills/hooks/state.md +24 -8
  87. package/skills/intercept/SKILL.md +33 -12
  88. package/skills/layout/SKILL.md +60 -0
  89. package/skills/links/SKILL.md +101 -4
  90. package/skills/loader/SKILL.md +49 -22
  91. package/skills/migrate-nextjs/SKILL.md +8 -2
  92. package/skills/migrate-react-router/data-and-actions.md +5 -0
  93. package/skills/ppr/SKILL.md +26 -5
  94. package/skills/prerender/SKILL.md +4 -1
  95. package/skills/route/SKILL.md +6 -6
  96. package/skills/router-setup/SKILL.md +2 -1
  97. package/skills/server-actions/SKILL.md +41 -8
  98. package/skills/testing/client-components.md +29 -13
  99. package/skills/testing/serve-shell-request.md +7 -7
  100. package/skills/testing/server-actions.md +8 -0
  101. package/skills/ux-architecture/SKILL.md +70 -19
  102. package/src/bin/rango.ts +39 -13
  103. package/src/browser/action-fence.ts +72 -0
  104. package/src/browser/cookie-name.ts +82 -14
  105. package/src/browser/invalidate-client-cache.ts +9 -7
  106. package/src/browser/navigation-bridge.ts +164 -32
  107. package/src/browser/navigation-client.ts +95 -20
  108. package/src/browser/navigation-store-handle.ts +67 -5
  109. package/src/browser/navigation-store.ts +86 -17
  110. package/src/browser/navigation-transaction.ts +0 -6
  111. package/src/browser/partial-update.ts +31 -12
  112. package/src/browser/prefetch/cache.ts +188 -11
  113. package/src/browser/prefetch/fetch.ts +211 -51
  114. package/src/browser/prefetch/queue.ts +3 -2
  115. package/src/browser/rango-state.ts +50 -8
  116. package/src/browser/react/Link.tsx +20 -9
  117. package/src/browser/react/use-navigation.ts +1 -1
  118. package/src/browser/rsc-router.tsx +8 -1
  119. package/src/browser/server-action-bridge.ts +16 -11
  120. package/src/browser/types.ts +25 -0
  121. package/src/build/generate-route-types.ts +1 -0
  122. package/src/build/route-types/include-resolution.ts +40 -3
  123. package/src/build/route-types/router-processing.ts +170 -25
  124. package/src/build/route-types/scan-filter.ts +1 -0
  125. package/src/cache/cache-key-utils.ts +12 -8
  126. package/src/cache/cache-runtime.ts +5 -1
  127. package/src/cache/cache-scope.ts +28 -7
  128. package/src/cache/cf/cf-cache-types.ts +9 -5
  129. package/src/cache/document-cache-policy.ts +27 -0
  130. package/src/cache/document-cache.ts +5 -0
  131. package/src/cache/memory-segment-store.ts +3 -2
  132. package/src/cache/segment-codec.ts +2 -2
  133. package/src/cache/shell-snapshot.ts +1 -1
  134. package/src/cache/types.ts +12 -7
  135. package/src/cache/vercel/vercel-cache-store.ts +2 -1
  136. package/src/client-urls/types.ts +7 -5
  137. package/src/prefetch-markers.ts +10 -0
  138. package/src/prefetch-source-scope.ts +137 -0
  139. package/src/route-definition/helpers-types.ts +8 -5
  140. package/src/router/intercept-resolution.ts +94 -50
  141. package/src/router/loader-resolution.ts +8 -6
  142. package/src/router/match-api.ts +33 -29
  143. package/src/router/match-context.ts +5 -0
  144. package/src/router/match-handlers.ts +2 -1
  145. package/src/router/match-middleware/cache-lookup.ts +21 -13
  146. package/src/router/match-result.ts +39 -87
  147. package/src/router/navigation-snapshot.ts +18 -3
  148. package/src/router/prefetch-cache-ttl.ts +15 -3
  149. package/src/router/revalidation.ts +53 -23
  150. package/src/router/router-context.ts +2 -1
  151. package/src/router/router-options.ts +6 -3
  152. package/src/router/segment-resolution/loader-cache.ts +21 -3
  153. package/src/router/segment-resolution/prefetch-deferral.ts +38 -31
  154. package/src/router/segment-resolution/revalidation.ts +40 -12
  155. package/src/router/source-reads.ts +122 -0
  156. package/src/router/types.ts +2 -1
  157. package/src/router.ts +11 -12
  158. package/src/rsc/helpers.ts +8 -3
  159. package/src/rsc/prefetch-render-error.ts +7 -2
  160. package/src/rsc/progressive-enhancement.ts +3 -2
  161. package/src/rsc/request-stages.ts +59 -0
  162. package/src/rsc/rsc-rendering.ts +49 -33
  163. package/src/rsc/server-action.ts +2 -1
  164. package/src/rsc/shell-capture.ts +4 -5
  165. package/src/rsc/shell-serve.ts +12 -1
  166. package/src/segment-system.tsx +1 -1
  167. package/src/server/context.ts +37 -6
  168. package/src/server/cookie-store.ts +22 -1
  169. package/src/server/document-location-state.ts +23 -2
  170. package/src/server/handle-store.ts +2 -0
  171. package/src/server/request-context.ts +113 -28
  172. package/src/testing/dispatch.ts +3 -0
  173. package/src/testing/e2e/fixture.ts +4 -12
  174. package/src/testing/e2e/server.ts +91 -4
  175. package/src/testing/render-route.tsx +269 -4
  176. package/src/testing/serve-shell-request.ts +6 -3
  177. package/src/types/handler-context.ts +7 -5
  178. package/src/types/loader-types.ts +7 -4
  179. package/src/types/segments.ts +15 -4
  180. package/src/urls/path-helper-types.ts +7 -5
  181. package/src/vite/temp-rsc-server.ts +2 -2
@@ -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.
@@ -885,14 +885,19 @@ export function createServerActionBridge(
885
885
  throw new Error("No matched segments in response");
886
886
  }
887
887
 
888
- // Reconcile server segments with cached segments (single source of truth)
889
- const reconciled = reconcileSegments({
890
- actor: "action",
891
- matched,
892
- diff: diff || [],
893
- serverSegments: segments || [],
894
- cachedSegments,
895
- });
888
+ // Reconcile server segments with cached segments (single source of
889
+ // truth). A page that left commits nothing (navigated-away), and the
890
+ // cache on screen is another page's: reconciling would report missing
891
+ // segments.
892
+ const reconciled = navigatedSince()
893
+ ? { segments: [], mainSegments: [], interceptSegments: [] }
894
+ : reconcileSegments({
895
+ actor: "action",
896
+ matched,
897
+ diff: diff || [],
898
+ serverSegments: segments || [],
899
+ cachedSegments,
900
+ });
896
901
  const fullSegments = reconciled.segments;
897
902
 
898
903
  const returnData = returnValue?.data;
@@ -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
 
@@ -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") {
@@ -235,6 +235,22 @@ function positionalParts(parts: readonly (string | null)[]): string[] {
235
235
  : parts.map((part) => part ?? "");
236
236
  }
237
237
 
238
+ /**
239
+ * A route record key's own parts (CacheScope.resolveKeyFrom): `own`, the
240
+ * scope's default key or its store's keyGenerator result (undefined under a
241
+ * `key()` of its own), then the request `kind` unless `own` starts with
242
+ * `{kind}:`. A default key does, and so does a keyGenerator result built on
243
+ * `defaultKey` (`${defaultKey}|x`), which stays byte for byte. One that
244
+ * ignores `defaultKey` (a constant, the pathname) gave a document, a
245
+ * navigation and each intercept navigation one record, so a document GET
246
+ * rendered an intercept's. The kind is a part, never a `{kind}:` prefix: a
247
+ * prefix would give the results `x` and `doc:x` one record.
248
+ */
249
+ function ownParts(own: string | undefined, kind: string | undefined): string[] {
250
+ if (own === undefined) return kind === undefined ? [] : [kind];
251
+ return kind === undefined || own.startsWith(`${kind}:`) ? [own] : [own, kind];
252
+ }
253
+
238
254
  /**
239
255
  * One key resolution per request, resolver (`by`: a `key()` or a store's
240
256
  * keyGenerator) and `slot`, held on the request context (`_resolvedCacheKeys`,
@@ -530,8 +546,10 @@ export class CacheScope {
530
546
  * replaces the URL part, and its store's keyGenerator does not run. It
531
547
  * appends `kind` instead (routeRecordKind): the `key()` decides which
532
548
  * requests of one kind share a record, never that a document, a
533
- * navigation and an intercept navigation do. With no partition part it is
534
- * resolveDefaultKey alone. With a `key()` on the chain but no request
549
+ * navigation and an intercept navigation do. A keyGenerator result that
550
+ * does not start with its kind gets `kind` after it too (ownParts). With
551
+ * no partition part it is resolveDefaultKey alone, or that result and
552
+ * `kind` composed. With a `key()` on the chain but no request
535
553
  * context it rejects (keyWithoutContextError). `partitionedPrefix`
536
554
  * prefixes a key built from partition parts only (the response family's
537
555
  * `response:`, which passes no `kind`: a response route answers every
@@ -556,7 +574,9 @@ export class CacheScope {
556
574
  if (!requestCtx || !this.mayPartition) {
557
575
  return this.keyFns.length > 0
558
576
  ? Promise.reject(keyWithoutContextError())
559
- : this.resolveDefaultKey(requestCtx, defaultKey);
577
+ : this.resolveDefaultKey(requestCtx, defaultKey).then((own) =>
578
+ composeCacheKeys([], ownParts(own, kind)),
579
+ );
560
580
  }
561
581
  return Promise.all([
562
582
  this.resolvePartitionParts(requestCtx, defaultKey),
@@ -565,10 +585,11 @@ export class CacheScope {
565
585
  : this.resolveDefaultKey(requestCtx, defaultKey),
566
586
  ]).then(([[keys, storeParts], ownDefault]) => {
567
587
  const generated = positionalParts(storeParts);
568
- if (keys.length === 0 && generated.length === 0) return ownDefault!;
569
- const last = ownDefault ?? kind;
570
- if (last !== undefined) generated.push(last);
571
- return partitionedPrefix + composeCacheKeys(keys, generated);
588
+ const own = ownParts(ownDefault, kind);
589
+ if (keys.length === 0 && generated.length === 0) {
590
+ return composeCacheKeys([], own);
591
+ }
592
+ return partitionedPrefix + composeCacheKeys(keys, [...generated, ...own]);
572
593
  });
573
594
  }
574
595
 
@@ -419,19 +419,23 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
419
419
  /**
420
420
  * Custom key generator applied to all cache operations.
421
421
  * Receives the full RequestContext (including env) and the default-generated key.
422
- * Return value becomes the final cache key (unless route overrides with `key` option).
422
+ * The return value is the cache key (unless route overrides with `key`
423
+ * option), except that a route record keeps its request kind: a result that
424
+ * does not start with `defaultKey`'s prefix (`doc:`, `partial:`,
425
+ * `intercept:<intercept>:`) gets the kind appended (see
426
+ * SegmentCacheStore.keyGenerator).
423
427
  *
424
428
  * Reserved prefixes: tag-invalidation markers live in the SAME KV namespace as
425
429
  * data, keyed `__tag__/<tag>` (and `__tagmarker__/<tag>` for the L1 cache). A
426
430
  * returned key must NOT begin with `__tag__/` or `__tagmarker__/`, or it can
427
431
  * collide with a tag marker and corrupt invalidation. The documented
428
- * prepend-style generators below are safe.
432
+ * generators below start with `defaultKey` and are safe.
429
433
  *
430
434
  * @example Using headers for user segmentation
431
435
  * ```typescript
432
436
  * keyGenerator: (ctx, defaultKey) => {
433
437
  * const segment = ctx.request.headers.get('x-user-segment') || 'default';
434
- * return `${segment}:${defaultKey}`;
438
+ * return `${defaultKey}|${segment}`;
435
439
  * }
436
440
  * ```
437
441
  *
@@ -439,7 +443,7 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
439
443
  * ```typescript
440
444
  * keyGenerator: (ctx, defaultKey) => {
441
445
  * const region = ctx.env.REGION || 'us';
442
- * return `${region}:${defaultKey}`;
446
+ * return `${defaultKey}|${region}`;
443
447
  * }
444
448
  * ```
445
449
  *
@@ -447,7 +451,7 @@ export interface CFCacheStoreOptions<TEnv = unknown> {
447
451
  * ```typescript
448
452
  * keyGenerator: (ctx, defaultKey) => {
449
453
  * const locale = cookies().get('locale')?.value || 'en';
450
- * return `${locale}:${defaultKey}`;
454
+ * return `${defaultKey}|${locale}`;
451
455
  * }
452
456
  * ```
453
457
  */