@rangojs/router 0.0.0-experimental.d7eeaa75 → 0.0.0-experimental.dc2bd2b4

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 (253) hide show
  1. package/README.md +120 -25
  2. package/dist/bin/rango.js +147 -57
  3. package/dist/testing/vitest.js +48 -0
  4. package/dist/vite/index.js +2151 -846
  5. package/dist/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  6. package/package.json +57 -11
  7. package/skills/breadcrumbs/SKILL.md +3 -1
  8. package/skills/bundle-analysis/SKILL.md +159 -0
  9. package/skills/cache-guide/SKILL.md +220 -30
  10. package/skills/caching/SKILL.md +116 -8
  11. package/skills/composability/SKILL.md +27 -2
  12. package/skills/document-cache/SKILL.md +78 -55
  13. package/skills/handler-use/SKILL.md +364 -0
  14. package/skills/hooks/SKILL.md +229 -20
  15. package/skills/host-router/SKILL.md +45 -20
  16. package/skills/i18n/SKILL.md +276 -0
  17. package/skills/intercept/SKILL.md +46 -4
  18. package/skills/layout/SKILL.md +28 -7
  19. package/skills/links/SKILL.md +247 -17
  20. package/skills/loader/SKILL.md +219 -9
  21. package/skills/middleware/SKILL.md +47 -12
  22. package/skills/migrate-nextjs/SKILL.md +562 -0
  23. package/skills/migrate-react-router/SKILL.md +769 -0
  24. package/skills/mime-routes/SKILL.md +27 -0
  25. package/skills/observability/SKILL.md +137 -0
  26. package/skills/parallel/SKILL.md +71 -6
  27. package/skills/prerender/SKILL.md +14 -33
  28. package/skills/rango/SKILL.md +242 -22
  29. package/skills/react-compiler/SKILL.md +168 -0
  30. package/skills/response-routes/SKILL.md +66 -9
  31. package/skills/route/SKILL.md +57 -4
  32. package/skills/router-setup/SKILL.md +3 -3
  33. package/skills/server-actions/SKILL.md +751 -0
  34. package/skills/streams-and-websockets/SKILL.md +283 -0
  35. package/skills/testing/SKILL.md +647 -0
  36. package/skills/typesafety/SKILL.md +319 -27
  37. package/skills/use-cache/SKILL.md +34 -5
  38. package/skills/view-transitions/SKILL.md +294 -0
  39. package/src/__augment-tests__/augment.ts +81 -0
  40. package/src/__augment-tests__/augmented.check.ts +117 -0
  41. package/src/browser/action-coordinator.ts +53 -36
  42. package/src/browser/app-shell.ts +52 -0
  43. package/src/browser/event-controller.ts +86 -70
  44. package/src/browser/history-state.ts +21 -0
  45. package/src/browser/index.ts +3 -3
  46. package/src/browser/navigation-bridge.ts +84 -11
  47. package/src/browser/navigation-client.ts +76 -28
  48. package/src/browser/navigation-store.ts +32 -9
  49. package/src/browser/navigation-transaction.ts +10 -28
  50. package/src/browser/partial-update.ts +64 -26
  51. package/src/browser/prefetch/cache.ts +129 -21
  52. package/src/browser/prefetch/fetch.ts +148 -16
  53. package/src/browser/prefetch/queue.ts +36 -5
  54. package/src/browser/rango-state.ts +53 -13
  55. package/src/browser/react/Link.tsx +30 -2
  56. package/src/browser/react/NavigationProvider.tsx +72 -31
  57. package/src/browser/react/filter-segment-order.ts +51 -7
  58. package/src/browser/react/index.ts +3 -0
  59. package/src/browser/react/location-state-shared.ts +175 -4
  60. package/src/browser/react/location-state.ts +39 -13
  61. package/src/browser/react/use-handle.ts +17 -9
  62. package/src/browser/react/use-navigation.ts +22 -2
  63. package/src/browser/react/use-params.ts +20 -8
  64. package/src/browser/react/use-reverse.ts +106 -0
  65. package/src/browser/react/use-router.ts +22 -2
  66. package/src/browser/react/use-segments.ts +11 -8
  67. package/src/browser/response-adapter.ts +25 -0
  68. package/src/browser/rsc-router.tsx +64 -22
  69. package/src/browser/scroll-restoration.ts +22 -14
  70. package/src/browser/segment-reconciler.ts +36 -14
  71. package/src/browser/segment-structure-assert.ts +2 -2
  72. package/src/browser/server-action-bridge.ts +23 -30
  73. package/src/browser/types.ts +21 -0
  74. package/src/build/collect-fallback-refs.ts +107 -0
  75. package/src/build/generate-manifest.ts +60 -35
  76. package/src/build/generate-route-types.ts +2 -0
  77. package/src/build/index.ts +2 -0
  78. package/src/build/route-trie.ts +52 -25
  79. package/src/build/route-types/codegen.ts +4 -4
  80. package/src/build/route-types/include-resolution.ts +1 -1
  81. package/src/build/route-types/per-module-writer.ts +7 -4
  82. package/src/build/route-types/router-processing.ts +55 -14
  83. package/src/build/route-types/scan-filter.ts +1 -1
  84. package/src/build/route-types/source-scan.ts +118 -0
  85. package/src/build/runtime-discovery.ts +9 -20
  86. package/src/cache/cache-scope.ts +28 -42
  87. package/src/cache/cf/cf-cache-store.ts +54 -13
  88. package/src/client.rsc.tsx +3 -0
  89. package/src/client.tsx +92 -182
  90. package/src/context-var.ts +5 -5
  91. package/src/decode-loader-results.ts +36 -0
  92. package/src/errors.ts +30 -1
  93. package/src/handle.ts +26 -13
  94. package/src/host/index.ts +2 -2
  95. package/src/host/router.ts +129 -57
  96. package/src/host/types.ts +31 -2
  97. package/src/host/utils.ts +1 -1
  98. package/src/href-client.ts +140 -20
  99. package/src/index.rsc.ts +9 -4
  100. package/src/index.ts +53 -15
  101. package/src/loader-store.ts +500 -0
  102. package/src/loader.rsc.ts +2 -5
  103. package/src/loader.ts +3 -10
  104. package/src/missing-id-error.ts +68 -0
  105. package/src/outlet-context.ts +1 -1
  106. package/src/prerender.ts +4 -4
  107. package/src/response-utils.ts +37 -0
  108. package/src/reverse.ts +65 -36
  109. package/src/route-content-wrapper.tsx +6 -28
  110. package/src/route-definition/dsl-helpers.ts +384 -257
  111. package/src/route-definition/helper-factories.ts +29 -139
  112. package/src/route-definition/helpers-types.ts +100 -28
  113. package/src/route-definition/resolve-handler-use.ts +6 -0
  114. package/src/route-definition/use-item-types.ts +32 -0
  115. package/src/route-types.ts +26 -41
  116. package/src/router/basename.ts +14 -0
  117. package/src/router/content-negotiation.ts +15 -2
  118. package/src/router/error-handling.ts +1 -1
  119. package/src/router/handler-context.ts +21 -38
  120. package/src/router/intercept-resolution.ts +4 -18
  121. package/src/router/lazy-includes.ts +8 -8
  122. package/src/router/loader-resolution.ts +19 -2
  123. package/src/router/manifest.ts +22 -13
  124. package/src/router/match-api.ts +4 -3
  125. package/src/router/match-handlers.ts +63 -20
  126. package/src/router/match-middleware/cache-lookup.ts +44 -91
  127. package/src/router/match-middleware/cache-store.ts +3 -2
  128. package/src/router/match-result.ts +53 -32
  129. package/src/router/metrics.ts +1 -1
  130. package/src/router/middleware-types.ts +15 -26
  131. package/src/router/middleware.ts +99 -84
  132. package/src/router/pattern-matching.ts +101 -17
  133. package/src/router/prerender-match.ts +1 -1
  134. package/src/router/preview-match.ts +3 -1
  135. package/src/router/request-classification.ts +4 -28
  136. package/src/router/revalidation.ts +58 -2
  137. package/src/router/router-interfaces.ts +45 -28
  138. package/src/router/router-options.ts +40 -1
  139. package/src/router/router-registry.ts +2 -5
  140. package/src/router/segment-resolution/fresh.ts +27 -6
  141. package/src/router/segment-resolution/revalidation.ts +147 -106
  142. package/src/router/segment-resolution/view-transition-default.ts +36 -0
  143. package/src/router/substitute-pattern-params.ts +56 -0
  144. package/src/router/telemetry.ts +99 -0
  145. package/src/router/trie-matching.ts +18 -13
  146. package/src/router/types.ts +8 -0
  147. package/src/router/url-params.ts +49 -0
  148. package/src/router.ts +38 -23
  149. package/src/rsc/handler-context.ts +2 -2
  150. package/src/rsc/handler.ts +28 -69
  151. package/src/rsc/helpers.ts +91 -43
  152. package/src/rsc/index.ts +1 -1
  153. package/src/rsc/origin-guard.ts +28 -10
  154. package/src/rsc/progressive-enhancement.ts +4 -0
  155. package/src/rsc/response-route-handler.ts +46 -53
  156. package/src/rsc/rsc-rendering.ts +35 -51
  157. package/src/rsc/runtime-warnings.ts +9 -10
  158. package/src/rsc/server-action.ts +17 -37
  159. package/src/rsc/ssr-setup.ts +16 -0
  160. package/src/rsc/types.ts +8 -2
  161. package/src/search-params.ts +4 -4
  162. package/src/segment-content-promise.ts +67 -0
  163. package/src/segment-loader-promise.ts +122 -0
  164. package/src/segment-system.tsx +132 -116
  165. package/src/serialize.ts +243 -0
  166. package/src/server/context.ts +143 -53
  167. package/src/server/cookie-store.ts +28 -4
  168. package/src/server/request-context.ts +20 -42
  169. package/src/ssr/index.tsx +5 -1
  170. package/src/static-handler.ts +1 -1
  171. package/src/testing/cache-status.ts +166 -0
  172. package/src/testing/collect-handle.ts +63 -0
  173. package/src/testing/dispatch.ts +440 -0
  174. package/src/testing/dom.entry.ts +22 -0
  175. package/src/testing/e2e/fixture.ts +154 -0
  176. package/src/testing/e2e/index.ts +149 -0
  177. package/src/testing/e2e/matchers.ts +51 -0
  178. package/src/testing/e2e/page-helpers.ts +272 -0
  179. package/src/testing/e2e/parity.ts +306 -0
  180. package/src/testing/e2e/server.ts +183 -0
  181. package/src/testing/flight-matchers.ts +104 -0
  182. package/src/testing/flight-runtime.d.ts +21 -0
  183. package/src/testing/flight.entry.ts +22 -0
  184. package/src/testing/flight.ts +182 -0
  185. package/src/testing/generated-routes.ts +223 -0
  186. package/src/testing/index.ts +105 -0
  187. package/src/testing/internal/context.ts +193 -0
  188. package/src/testing/render-route.tsx +536 -0
  189. package/src/testing/run-loader.ts +296 -0
  190. package/src/testing/run-middleware.ts +170 -0
  191. package/src/testing/vitest-stubs/cloudflare-email.ts +9 -0
  192. package/src/testing/vitest-stubs/cloudflare-workers.ts +21 -0
  193. package/src/testing/vitest-stubs/plugin-rsc.ts +16 -0
  194. package/src/testing/vitest-stubs/version.ts +5 -0
  195. package/src/testing/vitest.ts +183 -0
  196. package/src/types/global-namespace.ts +39 -26
  197. package/src/types/handler-context.ts +68 -50
  198. package/src/types/index.ts +1 -0
  199. package/src/types/loader-types.ts +5 -6
  200. package/src/types/request-scope.ts +126 -0
  201. package/src/types/route-entry.ts +11 -0
  202. package/src/types/segments.ts +35 -2
  203. package/src/urls/include-helper.ts +34 -67
  204. package/src/urls/index.ts +0 -3
  205. package/src/urls/path-helper-types.ts +41 -7
  206. package/src/urls/path-helper.ts +17 -52
  207. package/src/urls/pattern-types.ts +36 -19
  208. package/src/urls/response-types.ts +22 -29
  209. package/src/urls/type-extraction.ts +26 -116
  210. package/src/urls/urls-function.ts +1 -5
  211. package/src/use-loader.tsx +413 -42
  212. package/src/vite/debug.ts +185 -0
  213. package/src/vite/discovery/bundle-postprocess.ts +6 -6
  214. package/src/vite/discovery/discover-routers.ts +101 -51
  215. package/src/vite/discovery/discovery-errors.ts +194 -0
  216. package/src/vite/discovery/gate-state.ts +171 -0
  217. package/src/vite/discovery/prerender-collection.ts +67 -26
  218. package/src/vite/discovery/route-types-writer.ts +40 -84
  219. package/src/vite/discovery/self-gen-tracking.ts +27 -1
  220. package/src/vite/discovery/state.ts +33 -0
  221. package/src/vite/discovery/virtual-module-codegen.ts +13 -23
  222. package/src/vite/index.ts +2 -0
  223. package/src/vite/plugin-types.ts +67 -0
  224. package/src/vite/plugins/cjs-to-esm.ts +8 -7
  225. package/src/vite/plugins/client-ref-dedup.ts +16 -0
  226. package/src/vite/plugins/client-ref-hashing.ts +28 -5
  227. package/src/vite/plugins/cloudflare-protocol-loader-hook.d.mts +23 -0
  228. package/src/vite/plugins/cloudflare-protocol-loader-hook.mjs +76 -0
  229. package/src/vite/plugins/cloudflare-protocol-stub.ts +214 -0
  230. package/src/vite/plugins/expose-action-id.ts +54 -30
  231. package/src/vite/plugins/expose-id-utils.ts +12 -8
  232. package/src/vite/plugins/expose-ids/export-analysis.ts +100 -20
  233. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  234. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  235. package/src/vite/plugins/expose-ids/router-transform.ts +20 -3
  236. package/src/vite/plugins/expose-internal-ids.ts +496 -486
  237. package/src/vite/plugins/performance-tracks.ts +29 -25
  238. package/src/vite/plugins/use-cache-transform.ts +65 -50
  239. package/src/vite/plugins/version-injector.ts +39 -23
  240. package/src/vite/plugins/version-plugin.ts +59 -2
  241. package/src/vite/plugins/virtual-entries.ts +2 -2
  242. package/src/vite/rango.ts +116 -29
  243. package/src/vite/router-discovery.ts +750 -100
  244. package/src/vite/utils/ast-handler-extract.ts +15 -15
  245. package/src/vite/utils/banner.ts +1 -1
  246. package/src/vite/utils/bundle-analysis.ts +4 -2
  247. package/src/vite/utils/client-chunks.ts +190 -0
  248. package/src/vite/utils/forward-user-plugins.ts +193 -0
  249. package/src/vite/utils/manifest-utils.ts +21 -5
  250. package/src/vite/utils/package-resolution.ts +41 -1
  251. package/src/vite/utils/prerender-utils.ts +21 -6
  252. package/src/vite/utils/shared-utils.ts +107 -26
  253. package/src/browser/action-response-classifier.ts +0 -99
@@ -3,6 +3,9 @@ import MagicString from "magic-string";
3
3
  import path from "node:path";
4
4
  import fs from "node:fs";
5
5
  import { normalizePath } from "./expose-id-utils.js";
6
+ import { createRangoDebugger, createCounter, NS } from "../debug.js";
7
+
8
+ const debug = createRangoDebugger(NS.transform);
6
9
 
7
10
  /**
8
11
  * Type for the RSC plugin's manager API
@@ -39,7 +42,7 @@ function getRscPluginApi(config: ResolvedConfig): RscPluginApi | undefined {
39
42
  );
40
43
  if (plugin) {
41
44
  console.warn(
42
- `[rsc-router:expose-action-id] RSC plugin found by API structure (name: "${plugin.name}"). ` +
45
+ `[rango:expose-action-id] RSC plugin found by API structure (name: "${plugin.name}"). ` +
43
46
  `Consider updating the name lookup if the plugin was renamed.`,
44
47
  );
45
48
  }
@@ -254,6 +257,8 @@ export function exposeActionId(): Plugin {
254
257
  let isBuild = false;
255
258
  let hashToFileMap: Map<string, string> | undefined;
256
259
  let rscPluginApi: RscPluginApi | undefined;
260
+ const counterTransform = createCounter(debug, "expose-action-id transform");
261
+ const counterRender = createCounter(debug, "expose-action-id renderChunk");
257
262
 
258
263
  return {
259
264
  name: "@rangojs/router:expose-action-id",
@@ -268,6 +273,11 @@ export function exposeActionId(): Plugin {
268
273
  rscPluginApi = getRscPluginApi(config);
269
274
  },
270
275
 
276
+ buildEnd() {
277
+ counterTransform?.flush();
278
+ counterRender?.flush();
279
+ },
280
+
271
281
  buildStart() {
272
282
  // Verify RSC plugin is present at build start (after all config hooks have run)
273
283
  // This allows rsc-router:rsc-integration to dynamically add the RSC plugin
@@ -277,7 +287,7 @@ export function exposeActionId(): Plugin {
277
287
 
278
288
  if (!rscPluginApi) {
279
289
  throw new Error(
280
- "[rsc-router] Could not find @vitejs/plugin-rsc. " +
290
+ "[rango] Could not find @vitejs/plugin-rsc. " +
281
291
  "@rangojs/router requires the Vite RSC plugin, which is included automatically by rango().",
282
292
  );
283
293
  }
@@ -324,40 +334,54 @@ export function exposeActionId(): Plugin {
324
334
  return;
325
335
  }
326
336
 
327
- // Dev mode: no hash-to-file mapping needed (IDs are already file paths)
328
- return transformServerReferences(code, id);
337
+ const start = counterTransform ? performance.now() : 0;
338
+ try {
339
+ // Dev mode: no hash-to-file mapping needed (IDs are already file paths)
340
+ return transformServerReferences(code, id);
341
+ } finally {
342
+ counterTransform?.record(id, performance.now() - start);
343
+ }
329
344
  },
330
345
 
331
346
  // Build mode: renderChunk runs after all transforms and bundling complete
332
347
  renderChunk(code, chunk) {
333
- // Only RSC bundle should get file paths for revalidation matching
334
- // SSR bundle must NOT use file paths because client components run there
335
- // and need to match the client bundle during hydration (otherwise: error #418)
336
- const isRscEnv = this.environment?.name === "rsc";
337
-
338
- // Only use file path mapping for RSC environment
339
- const effectiveMap = isRscEnv ? hashToFileMap : undefined;
340
-
341
- // For RSC bundles, both createServerReference and registerServerReference
342
- // may need transforming. Use a single MagicString for correct sourcemaps.
343
- if (isRscEnv && hashToFileMap) {
344
- const s = new MagicString(code);
345
- const changed1 = applyServerReferenceWrapping(code, s, effectiveMap);
346
- const changed2 = applyRegisterReferenceWrapping(code, s, hashToFileMap);
347
- if (changed1 || changed2) {
348
- return {
349
- code: s.toString(),
350
- map: s.generateMap({
351
- source: chunk.fileName,
352
- includeContent: true,
353
- }),
354
- };
348
+ const start = counterRender ? performance.now() : 0;
349
+ try {
350
+ // Only RSC bundle should get file paths for revalidation matching
351
+ // SSR bundle must NOT use file paths because client components run there
352
+ // and need to match the client bundle during hydration (otherwise: error #418)
353
+ const isRscEnv = this.environment?.name === "rsc";
354
+
355
+ // Only use file path mapping for RSC environment
356
+ const effectiveMap = isRscEnv ? hashToFileMap : undefined;
357
+
358
+ // For RSC bundles, both createServerReference and registerServerReference
359
+ // may need transforming. Use a single MagicString for correct sourcemaps.
360
+ if (isRscEnv && hashToFileMap) {
361
+ const s = new MagicString(code);
362
+ const changed1 = applyServerReferenceWrapping(code, s, effectiveMap);
363
+ const changed2 = applyRegisterReferenceWrapping(
364
+ code,
365
+ s,
366
+ hashToFileMap,
367
+ );
368
+ if (changed1 || changed2) {
369
+ return {
370
+ code: s.toString(),
371
+ map: s.generateMap({
372
+ source: chunk.fileName,
373
+ includeContent: true,
374
+ }),
375
+ };
376
+ }
377
+ return null;
355
378
  }
356
- return null;
357
- }
358
379
 
359
- // Non-RSC environments: only transform createServerReference calls
360
- return transformServerReferences(code, chunk.fileName, effectiveMap);
380
+ // Non-RSC environments: only transform createServerReference calls
381
+ return transformServerReferences(code, chunk.fileName, effectiveMap);
382
+ } finally {
383
+ counterRender?.record(chunk.fileName, performance.now() - start);
384
+ }
361
385
  },
362
386
  };
363
387
  }
@@ -32,18 +32,22 @@ export function makeStubId(
32
32
  }
33
33
 
34
34
  /**
35
- * Generate an 8-char hex hash for an inline static handler call site.
36
- * Uses file path and line number (plus optional index for same-line collisions).
35
+ * Generate an 8-char hex hash for an inline handler call site.
36
+ *
37
+ * Keyed on the source-order INDEX of the call (the Nth inline `fnName(...)` in
38
+ * the file), NOT its line number. Line numbers shift between the prerender
39
+ * build context and the production build context (preceding transforms differ,
40
+ * e.g. plugin-react boilerplate), which would desync the prerender manifest key
41
+ * from the runtime handler id and break prerender/static freezing. The
42
+ * source-order index is invariant to line shifts; `fnName` keeps Static and
43
+ * Prerender inline ids from colliding at the same index.
37
44
  */
38
45
  export function hashInlineId(
39
46
  filePath: string,
40
- lineNumber: number,
41
- index?: number,
47
+ fnName: string,
48
+ index: number,
42
49
  ): string {
43
- const input =
44
- index !== undefined && index > 0
45
- ? `${filePath}:${lineNumber}:${index}`
46
- : `${filePath}:${lineNumber}`;
50
+ const input = `${filePath}:${fnName}:${index}`;
47
51
  return crypto.createHash("sha256").update(input).digest("hex").slice(0, 8);
48
52
  }
49
53
 
@@ -6,6 +6,7 @@ import {
6
6
  buildExportMap,
7
7
  escapeRegExp,
8
8
  } from "../expose-id-utils.js";
9
+ import { codeMatchIndices } from "../../../build/route-types/source-scan.js";
9
10
  import type { CreateExportBinding } from "./types.js";
10
11
 
11
12
  /**
@@ -59,19 +60,57 @@ export function isExportOnlyFile(
59
60
  return true;
60
61
  }
61
62
 
62
- // NOTE: This regex may over-count when the fn name appears inside strings or
63
- // comments, but it's only used for the warning heuristic (totalCalls >
64
- // supportedBindings) and the inline-extraction pre-check, so over-counting
65
- // triggers a harmless extra AST parse rather than affecting correctness.
63
+ function createCallPattern(fnNames: string[]): RegExp {
64
+ return new RegExp(
65
+ `\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
66
+ "g",
67
+ );
68
+ }
69
+
70
+ // Counts real create*() call sites, ignoring occurrences inside comments and
71
+ // string literals. Used by the unsupported-shape warning heuristic and the
72
+ // inline-extraction pre-check.
66
73
  export function countCreateCallsForNames(
67
74
  code: string,
68
75
  fnNames: string[],
69
76
  ): number {
70
- const pattern = new RegExp(
71
- `\\b(?:${fnNames.map(escapeRegExp).join("|")})\\s*(?:<[^>]*>\\s*)?\\(`,
72
- "g",
73
- );
74
- return (code.match(pattern) || []).length;
77
+ return codeMatchIndices(code, createCallPattern(fnNames)).length;
78
+ }
79
+
80
+ /** Convert a 0-based byte offset to a 1-based { line, column }. */
81
+ export function offsetToLineColumn(
82
+ code: string,
83
+ index: number,
84
+ ): { line: number; column: number } {
85
+ let line = 1;
86
+ let lineStart = 0;
87
+ const end = Math.min(index, code.length);
88
+ for (let i = 0; i < end; i++) {
89
+ if (code[i] === "\n") {
90
+ line++;
91
+ lineStart = i + 1;
92
+ }
93
+ }
94
+ return { line, column: index - lineStart + 1 };
95
+ }
96
+
97
+ /**
98
+ * Locate every real create*() call site (comment/string-free) that is NOT one
99
+ * of the supported, id-injectable export bindings, returning each as a 1-based
100
+ * { line, column }. The empty result means every call is in a supported shape.
101
+ * Both binding-collection paths anchor `callExprStart` at the start of the
102
+ * create* identifier — exactly where this pattern matches — so the set
103
+ * difference is exact.
104
+ */
105
+ export function findUnsupportedCreateCallSites(
106
+ code: string,
107
+ fnNames: string[],
108
+ supportedBindings: CreateExportBinding[],
109
+ ): Array<{ line: number; column: number }> {
110
+ const supported = new Set(supportedBindings.map((b) => b.callExprStart));
111
+ return codeMatchIndices(code, createCallPattern(fnNames))
112
+ .filter((index) => !supported.has(index))
113
+ .map((index) => offsetToLineColumn(code, index));
75
114
  }
76
115
 
77
116
  export function getImportedFnNames(
@@ -119,6 +158,28 @@ export function getCalledIdentifierFromCall(callExpr: any): string | null {
119
158
  return null;
120
159
  }
121
160
 
161
+ /**
162
+ * plugin-react's dev Fast Refresh wraps exports whose function body uses
163
+ * hook-like calls in a signature-registration call. A loader/handle that calls
164
+ * `ctx.use(...)` trips this heuristic, so `export const X = createLoader(...)`
165
+ * becomes `export const X = _s(createLoader(...), "<sig>", true)` — the create*
166
+ * call is the first argument of an unrelated wrapper call. Unwrap a single such
167
+ * layer so ID injection still targets the inner create* call. The `$$id`
168
+ * assignment is appended after the whole statement (against the export local),
169
+ * which is unaffected by the wrapper since `_s(x)` returns `x`.
170
+ */
171
+ function unwrapSignatureWrappedCall(init: any, fnNameSet: Set<string>): any {
172
+ if (init?.type !== "CallExpression") return init;
173
+ const directId = getCalledIdentifierFromCall(init);
174
+ if (directId && fnNameSet.has(directId)) return init;
175
+ const firstArg = init.arguments?.[0];
176
+ if (firstArg?.type === "CallExpression") {
177
+ const innerId = getCalledIdentifierFromCall(firstArg);
178
+ if (innerId && fnNameSet.has(innerId)) return firstArg;
179
+ }
180
+ return init;
181
+ }
182
+
122
183
  export function collectCreateExportBindingsFallback(
123
184
  code: string,
124
185
  fnNames: string[],
@@ -196,7 +257,7 @@ export function collectCreateExportBindings(
196
257
  ): CreateExportBinding[] {
197
258
  if (!program) {
198
259
  try {
199
- program = parseAst(code, { jsx: true });
260
+ program = parseAst(code, { lang: "tsx" });
200
261
  } catch {
201
262
  return collectCreateExportBindingsFallback(code, fnNames);
202
263
  }
@@ -212,10 +273,13 @@ export function collectCreateExportBindings(
212
273
  }
213
274
 
214
275
  for (const decl of varDecl.declarations ?? []) {
215
- const calledIdentifier = getCalledIdentifierFromCall(decl?.init);
276
+ // Unwrap a Fast Refresh signature wrapper (`_s(createLoader(...), ...)`)
277
+ // so injection targets the inner create* call. Falls back to decl.init.
278
+ const callExpr = unwrapSignatureWrappedCall(decl?.init, fnNameSet);
279
+ const calledIdentifier = getCalledIdentifierFromCall(callExpr);
216
280
  if (
217
281
  decl?.id?.type !== "Identifier" ||
218
- decl?.init?.type !== "CallExpression" ||
282
+ callExpr?.type !== "CallExpression" ||
219
283
  !calledIdentifier ||
220
284
  !fnNameSet.has(calledIdentifier)
221
285
  ) {
@@ -226,9 +290,8 @@ export function collectCreateExportBindings(
226
290
  const exportNames = exportMap.get(localName) ?? [];
227
291
  if (exportNames.length === 0) continue;
228
292
 
229
- const callStart = decl.init.start as number;
230
- const callEnd = decl.init.end as number;
231
- const calleeEnd = decl.init.callee.end as number;
293
+ const callEnd = callExpr.end as number;
294
+ const calleeEnd = callExpr.callee.end as number;
232
295
 
233
296
  let openParenPos = -1;
234
297
  for (let i = calleeEnd; i < callEnd; i++) {
@@ -245,10 +308,10 @@ export function collectCreateExportBindings(
245
308
  bindings.push({
246
309
  localName,
247
310
  exportNames,
248
- callExprStart: decl.init.start as number,
311
+ callExprStart: callExpr.start as number,
249
312
  callOpenParenPos: openParenPos,
250
313
  callCloseParenPos: closeParenPos,
251
- argCount: decl.init.arguments?.length ?? 0,
314
+ argCount: callExpr.arguments?.length ?? 0,
252
315
  statementEnd,
253
316
  });
254
317
  }
@@ -282,9 +345,25 @@ export function collectCreateExportBindings(
282
345
  export function buildUnsupportedShapeWarning(
283
346
  filePath: string,
284
347
  fnName: string,
348
+ sites: Array<{ line: number; column: number }> = [],
285
349
  ): string {
286
- return [
287
- `[rsc-router] Unsupported ${fnName} shape in "${filePath}".`,
350
+ const lines = [`[rango] Unsupported ${fnName} shape in "${filePath}".`];
351
+
352
+ // Point at the exact call(s) so the location is clickable in the terminal/IDE
353
+ // (file:line:column) instead of leaving the user to scan the whole file.
354
+ if (sites.length === 1) {
355
+ const s = sites[0];
356
+ lines.push(
357
+ `The ${fnName}(...) call at ${filePath}:${s.line}:${s.column} has no stable $$id injected — it is not in a supported shape.`,
358
+ );
359
+ } else if (sites.length > 1) {
360
+ lines.push(
361
+ `These ${fnName}(...) calls have no stable $$id injected — they are not in a supported shape:`,
362
+ );
363
+ for (const s of sites) lines.push(` - ${filePath}:${s.line}:${s.column}`);
364
+ }
365
+
366
+ lines.push(
288
367
  `Supported shapes are:`,
289
368
  ` - export const X = ${fnName}(...)`,
290
369
  ` - const X = ${fnName}(...); export { X }`,
@@ -292,5 +371,6 @@ export function buildUnsupportedShapeWarning(
292
371
  `Potentially unsupported forms include:`,
293
372
  ` - export let/var X = ${fnName}(...)`,
294
373
  ` - inline ${fnName}(...) calls`,
295
- ].join("\n");
374
+ );
375
+ return lines.join("\n");
296
376
  }
@@ -1,5 +1,5 @@
1
1
  import MagicString from "magic-string";
2
- import { hashId } from "../expose-id-utils.js";
2
+ import { makeStubId } from "../expose-id-utils.js";
3
3
  import type { HandlerTransformConfig, CreateExportBinding } from "./types.js";
4
4
  import { isExportOnlyFile } from "./export-analysis.js";
5
5
 
@@ -28,9 +28,7 @@ export function transformHandles(
28
28
  binding.callCloseParenPos,
29
29
  );
30
30
 
31
- const handleId = isBuild
32
- ? hashId(filePath, exportName)
33
- : `${filePath}#${exportName}`;
31
+ const handleId = makeStubId(filePath, exportName, isBuild);
34
32
 
35
33
  let paramInjection: string;
36
34
  if (!args.hasArgs) {
@@ -58,9 +56,7 @@ export function transformLocationState(
58
56
  for (const binding of bindings) {
59
57
  const exportName = binding.exportNames[0];
60
58
 
61
- const stateKey = isBuild
62
- ? hashId(filePath, exportName)
63
- : `${filePath}#${exportName}`;
59
+ const stateKey = makeStubId(filePath, exportName, isBuild);
64
60
 
65
61
  // Key is injected as a property assignment (not as a function argument).
66
62
  // This allows createLocationState to accept options like { flash: true }
@@ -88,7 +84,7 @@ export function generateWholeFileStubs(
88
84
 
89
85
  const exportNames = bindings.flatMap((b) => b.exportNames);
90
86
  const stubs = exportNames.map((name) => {
91
- const handlerId = isBuild ? hashId(filePath, name) : `${filePath}#${name}`;
87
+ const handlerId = makeStubId(filePath, name, isBuild);
92
88
  return `export const ${name} = { __brand: "${cfg.brand}", $$id: "${handlerId}" };`;
93
89
  });
94
90
 
@@ -96,53 +92,8 @@ export function generateWholeFileStubs(
96
92
  }
97
93
 
98
94
  /**
99
- * Replace handler call expressions with lightweight stub objects in non-RSC
100
- * environments. Other exports, imports, and module-level code remain untouched.
101
- */
102
- export function generateExprStubs(
103
- cfg: HandlerTransformConfig,
104
- bindings: CreateExportBinding[],
105
- code: string,
106
- filePath: string,
107
- sourceId: string,
108
- isBuild: boolean,
109
- ): { code: string; map: ReturnType<MagicString["generateMap"]> } | null {
110
- if (bindings.length === 0) return null;
111
-
112
- const s = new MagicString(code);
113
- let hasChanges = false;
114
-
115
- for (const binding of bindings) {
116
- const exportName = binding.exportNames[0];
117
- const handlerId = isBuild
118
- ? hashId(filePath, exportName)
119
- : `${filePath}#${exportName}`;
120
-
121
- s.overwrite(
122
- binding.callExprStart,
123
- binding.callCloseParenPos + 1,
124
- `{ __brand: "${cfg.brand}", $$id: "${handlerId}" }`,
125
- );
126
- hasChanges = true;
127
- }
128
-
129
- if (!hasChanges) return null;
130
-
131
- return {
132
- code: s.toString(),
133
- map: s.generateMap({
134
- source: sourceId,
135
- includeContent: true,
136
- hires: "boundary",
137
- }),
138
- };
139
- }
140
-
141
- /**
142
- * Replace handler call expressions with lightweight stub objects on an
143
- * existing MagicString. Unlike generateExprStubs (which creates its own
144
- * MagicString and returns the full result), this integrates into the
145
- * unified transform pipeline so all transforms share one sourcemap.
95
+ * Replace handler call expressions with lightweight stub objects on the shared
96
+ * unified-pipeline MagicString so all transforms share one sourcemap.
146
97
  */
147
98
  export function stubHandlerExprs(
148
99
  cfg: HandlerTransformConfig,
@@ -154,9 +105,7 @@ export function stubHandlerExprs(
154
105
  let hasChanges = false;
155
106
  for (const binding of bindings) {
156
107
  const exportName = binding.exportNames[0];
157
- const handlerId = isBuild
158
- ? hashId(filePath, exportName)
159
- : `${filePath}#${exportName}`;
108
+ const handlerId = makeStubId(filePath, exportName, isBuild);
160
109
 
161
110
  s.overwrite(
162
111
  binding.callExprStart,
@@ -182,9 +131,7 @@ export function transformHandlerIds(
182
131
  for (const binding of bindings) {
183
132
  const exportName = binding.exportNames[0];
184
133
 
185
- const handlerId = isBuild
186
- ? hashId(filePath, exportName)
187
- : `${filePath}#${exportName}`;
134
+ const handlerId = makeStubId(filePath, exportName, isBuild);
188
135
 
189
136
  // Injection strategy matches the runtime overload signatures:
190
137
  // 0 args -> inject undefined, "id"
@@ -1,5 +1,5 @@
1
1
  import type MagicString from "magic-string";
2
- import { hashId } from "../expose-id-utils.js";
2
+ import { makeStubId } from "../expose-id-utils.js";
3
3
  import type { CreateExportBinding } from "./types.js";
4
4
  import { isExportOnlyFile } from "./export-analysis.js";
5
5
 
@@ -33,7 +33,7 @@ export function generateClientLoaderStubs(
33
33
 
34
34
  for (const binding of bindings) {
35
35
  for (const name of binding.exportNames) {
36
- const loaderId = isBuild ? hashId(filePath, name) : `${filePath}#${name}`;
36
+ const loaderId = makeStubId(filePath, name, isBuild);
37
37
  lines.push(
38
38
  `export const ${name} = { __brand: "loader", $$id: "${loaderId}" };`,
39
39
  );
@@ -54,9 +54,7 @@ export function transformLoaders(
54
54
  for (const binding of bindings) {
55
55
  const exportName = binding.exportNames[0];
56
56
 
57
- const loaderId = isBuild
58
- ? hashId(filePath, exportName)
59
- : `${filePath}#${exportName}`;
57
+ const loaderId = makeStubId(filePath, exportName, isBuild);
60
58
 
61
59
  // Inject $$id as hidden third parameter.
62
60
  // createLoader(fn) -> createLoader(fn, undefined, "id")
@@ -4,6 +4,9 @@ import path from "node:path";
4
4
  import { createHash } from "node:crypto";
5
5
  import { normalizePath, findMatchingParen } from "../expose-id-utils.js";
6
6
  import { getImportedFnNames } from "./export-analysis.js";
7
+ import { createRangoDebugger, createCounter, NS } from "../../debug.js";
8
+
9
+ const debug = createRangoDebugger(NS.transform);
7
10
 
8
11
  export function transformRouter(
9
12
  code: string,
@@ -82,11 +85,15 @@ export function transformRouter(
82
85
  */
83
86
  export function exposeRouterId(): Plugin {
84
87
  let projectRoot = "";
88
+ const counter = createCounter(debug, "expose-router-id");
85
89
  return {
86
90
  name: "@rangojs/router:expose-router-id",
87
91
  configResolved(config) {
88
92
  projectRoot = config.root;
89
93
  },
94
+ buildEnd() {
95
+ counter?.flush();
96
+ },
90
97
  transform(code, id) {
91
98
  if (!code.includes("createRouter")) return null;
92
99
  // Accepts both @rangojs/router and @rangojs/router/server subpath.
@@ -102,9 +109,19 @@ export function exposeRouterId(): Plugin {
102
109
  }
103
110
  if (id.includes("node_modules")) return null;
104
111
 
105
- const filePath = normalizePath(path.relative(projectRoot, id));
106
- const routerFnNames = getImportedFnNames(code, "createRouter");
107
- return transformRouter(code, filePath, routerFnNames, normalizePath(id));
112
+ const start = counter ? performance.now() : 0;
113
+ try {
114
+ const filePath = normalizePath(path.relative(projectRoot, id));
115
+ const routerFnNames = getImportedFnNames(code, "createRouter");
116
+ return transformRouter(
117
+ code,
118
+ filePath,
119
+ routerFnNames,
120
+ normalizePath(id),
121
+ );
122
+ } finally {
123
+ counter?.record(id, performance.now() - start);
124
+ }
108
125
  },
109
126
  };
110
127
  }