@rangojs/router 0.0.0-experimental.111 → 0.0.0-experimental.112

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 (49) hide show
  1. package/dist/bin/rango.js +41 -37
  2. package/dist/vite/index.js +144 -191
  3. package/package.json +3 -1
  4. package/src/browser/action-coordinator.ts +53 -36
  5. package/src/browser/event-controller.ts +42 -66
  6. package/src/browser/navigation-bridge.ts +4 -0
  7. package/src/browser/navigation-client.ts +12 -15
  8. package/src/browser/navigation-store.ts +7 -8
  9. package/src/browser/navigation-transaction.ts +7 -21
  10. package/src/browser/partial-update.ts +8 -16
  11. package/src/browser/react/NavigationProvider.tsx +29 -40
  12. package/src/browser/react/use-params.ts +3 -4
  13. package/src/browser/response-adapter.ts +25 -0
  14. package/src/browser/rsc-router.tsx +16 -2
  15. package/src/browser/server-action-bridge.ts +23 -30
  16. package/src/browser/types.ts +2 -0
  17. package/src/build/generate-manifest.ts +29 -31
  18. package/src/build/generate-route-types.ts +2 -0
  19. package/src/build/route-types/router-processing.ts +37 -9
  20. package/src/build/runtime-discovery.ts +9 -20
  21. package/src/decode-loader-results.ts +36 -0
  22. package/src/errors.ts +11 -0
  23. package/src/response-utils.ts +9 -0
  24. package/src/route-content-wrapper.tsx +6 -28
  25. package/src/router/content-negotiation.ts +15 -2
  26. package/src/router/intercept-resolution.ts +4 -18
  27. package/src/router/match-result.ts +32 -30
  28. package/src/router/middleware.ts +46 -78
  29. package/src/router/preview-match.ts +3 -1
  30. package/src/router/request-classification.ts +4 -28
  31. package/src/rsc/handler.ts +20 -65
  32. package/src/rsc/helpers.ts +3 -2
  33. package/src/rsc/origin-guard.ts +28 -10
  34. package/src/rsc/response-route-handler.ts +32 -52
  35. package/src/rsc/rsc-rendering.ts +27 -53
  36. package/src/rsc/runtime-warnings.ts +9 -10
  37. package/src/rsc/server-action.ts +13 -37
  38. package/src/rsc/ssr-setup.ts +16 -0
  39. package/src/segment-system.tsx +5 -39
  40. package/src/vite/discovery/discover-routers.ts +10 -22
  41. package/src/vite/discovery/route-types-writer.ts +38 -82
  42. package/src/vite/plugins/cjs-to-esm.ts +3 -7
  43. package/src/vite/plugins/expose-ids/handler-transform.ts +8 -61
  44. package/src/vite/plugins/expose-ids/loader-transform.ts +3 -5
  45. package/src/vite/plugins/expose-internal-ids.ts +34 -62
  46. package/src/vite/plugins/version-injector.ts +2 -12
  47. package/src/vite/router-discovery.ts +71 -26
  48. package/src/vite/utils/shared-utils.ts +13 -1
  49. package/src/browser/action-response-classifier.ts +0 -99
@@ -8,6 +8,7 @@ import {
8
8
  createResponseWithMergedHeaders,
9
9
  carryOverRedirectHeaders,
10
10
  } from "./helpers.js";
11
+ import { isRedirectResponse } from "../response-utils.js";
11
12
 
12
13
  // W3 -----------------------------------------------------------------------
13
14
 
@@ -18,16 +19,14 @@ import {
18
19
  */
19
20
  export function extractRedirectResponse(value: unknown): Response | null {
20
21
  if (!(value instanceof Response)) return null;
21
- const location = value.headers.get("Location");
22
- if (value.status >= 300 && value.status < 400 && location) {
23
- const redirect = createResponseWithMergedHeaders(null, {
24
- status: value.status,
25
- headers: { Location: location },
26
- });
27
- carryOverRedirectHeaders(value, redirect);
28
- return redirect;
29
- }
30
- return null;
22
+ if (!isRedirectResponse(value)) return null;
23
+ const location = value.headers.get("Location")!;
24
+ const redirect = createResponseWithMergedHeaders(null, {
25
+ status: value.status,
26
+ headers: { Location: location },
27
+ });
28
+ carryOverRedirectHeaders(value, redirect);
29
+ return redirect;
31
30
  }
32
31
 
33
32
  /**
@@ -27,7 +27,7 @@ import {
27
27
  hasBodyContent,
28
28
  createResponseWithMergedHeaders,
29
29
  createSimpleRedirectResponse,
30
- carryOverRedirectHeaders,
30
+ interceptRedirectForPartial,
31
31
  } from "./helpers.js";
32
32
  import type { HandlerContext } from "./handler-context.js";
33
33
 
@@ -111,49 +111,25 @@ export async function executeServerAction<TEnv>(
111
111
  loadedAction = await ctx.loadServerAction(actionId);
112
112
  const data = await loadedAction!.apply(null, args);
113
113
 
114
- // Intercept redirect responses from actions. Without this, the redirect
115
- // Response would be serialized as the action returnValue (which fails)
116
- // and the revalidation step would run unnecessarily.
114
+ // Intercept redirect Responses: serializing one as the action returnValue
115
+ // would fail, and revalidation would run needlessly.
117
116
  if (data instanceof Response) {
118
- const redirectUrl = data.headers.get("Location");
119
- const isRedirect = data.status >= 300 && data.status < 400 && redirectUrl;
120
- if (isRedirect) {
121
- const locationState = getLocationState();
122
- let redirect: Response;
123
- if (locationState) {
124
- redirect = ctx.createRedirectFlightResponse(
125
- redirectUrl,
126
- resolveLocationStateEntries(locationState),
127
- );
128
- } else {
129
- redirect = createSimpleRedirectResponse(redirectUrl);
130
- }
131
- carryOverRedirectHeaders(data, redirect);
132
- return redirect;
133
- }
117
+ const intercepted = interceptRedirectForPartial(
118
+ data,
119
+ ctx.createRedirectFlightResponse,
120
+ );
121
+ if (intercepted) return intercepted;
134
122
  }
135
123
 
136
124
  returnValue = { ok: true, data };
137
125
  } catch (error) {
138
126
  // Handle thrown redirect (e.g., throw redirect('/path'))
139
127
  if (error instanceof Response) {
140
- const redirectUrl = error.headers.get("Location");
141
- const isRedirect =
142
- error.status >= 300 && error.status < 400 && redirectUrl;
143
- if (isRedirect) {
144
- const locationState = getLocationState();
145
- let redirect: Response;
146
- if (locationState) {
147
- redirect = ctx.createRedirectFlightResponse(
148
- redirectUrl,
149
- resolveLocationStateEntries(locationState),
150
- );
151
- } else {
152
- redirect = createSimpleRedirectResponse(redirectUrl);
153
- }
154
- carryOverRedirectHeaders(error, redirect);
155
- return redirect;
156
- }
128
+ const intercepted = interceptRedirectForPartial(
129
+ error,
130
+ ctx.createRedirectFlightResponse,
131
+ );
132
+ if (intercepted) return intercepted;
157
133
 
158
134
  // Non-redirect Response thrown from action — this will be treated
159
135
  // as a regular error and routed to the error boundary. Warn in dev
@@ -126,3 +126,19 @@ export function mayNeedSSR(request: Request, url: URL): boolean {
126
126
 
127
127
  return true;
128
128
  }
129
+
130
+ // Final render-time decision: is the response an RSC stream (vs HTML)? Distinct
131
+ // from mayNeedSSR, which is a conservative pre-classifier (it treats a missing
132
+ // Accept header as needing SSR; this treats it as RSC).
133
+ export function isRscRequest(
134
+ request: Request,
135
+ url: URL,
136
+ isPartial: boolean,
137
+ ): boolean {
138
+ return (
139
+ isPartial ||
140
+ (!request.headers.get("accept")?.includes("text/html") &&
141
+ !url.searchParams.has("__html")) ||
142
+ url.searchParams.has("__rsc")
143
+ );
144
+ }
@@ -3,7 +3,7 @@ import { createElement, type ReactNode, type ComponentType } from "react";
3
3
  import { OutletProvider } from "./client.js";
4
4
  import { MountContextProvider } from "./browser/react/mount-context.js";
5
5
  import type { ResolvedSegment, RootLayoutProps } from "./types.js";
6
- import { isLoaderDataResult } from "./types.js";
6
+ import { decodeLoaderResults } from "./decode-loader-results.js";
7
7
  import { invariant } from "./errors.js";
8
8
  import {
9
9
  RouteContentWrapper,
@@ -59,42 +59,6 @@ function restoreParallelLoaderMarkers(
59
59
  return nextSegments ?? segments;
60
60
  }
61
61
 
62
- /**
63
- * Resolve loader data from raw results, unwrapping LoaderDataResult wrappers
64
- */
65
- function resolveLoaderData(
66
- resolvedData: any[],
67
- loaderIds: string[],
68
- ): { loaderData: Record<string, any>; errorFallback: ReactNode } {
69
- const loaderData: Record<string, any> = {};
70
- let errorFallback: ReactNode = null;
71
-
72
- for (let i = 0; i < loaderIds.length; i++) {
73
- const id = loaderIds[i];
74
- const result = resolvedData[i];
75
-
76
- if (!isLoaderDataResult(result)) {
77
- // Legacy format - direct data
78
- loaderData[id] = result;
79
- continue;
80
- }
81
-
82
- if (result.ok) {
83
- loaderData[id] = result.data;
84
- continue;
85
- }
86
-
87
- // Error case
88
- if (result.fallback) {
89
- errorFallback = result.fallback;
90
- } else {
91
- throw new Error(result.error.message);
92
- }
93
- }
94
-
95
- return { loaderData, errorFallback };
96
- }
97
-
98
62
  /**
99
63
  * Options for renderSegments
100
64
  */
@@ -337,13 +301,15 @@ export async function renderSegments(
337
301
 
338
302
  // Prepare loader data if there are loaders
339
303
  const loaderIds = loaderEntries.map((loader) => loader.loaderId!);
340
- const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
341
304
 
342
305
  // Use LoaderBoundary when loading is defined to maintain consistent tree structure
343
306
  // This ensures cached segments (which may not have loader segments) have the same
344
307
  // tree structure as fresh segments, preventing React remounts
345
308
  // If forceAwait or isAction is set, pre-resolve promises so LoaderBoundary won't suspend
346
309
  if (loading !== undefined && loading !== null) {
310
+ // Aggregate built here only — the loaderless and no-loading branches don't
311
+ // read it (the latter builds its own per-parallel promises).
312
+ const loaderDataPromise = getMemoizedLoaderPromise(loaderEntries);
347
313
  content = createElement(LoaderBoundary, {
348
314
  key: `loader-boundary-${key}`,
349
315
  loaderDataPromise:
@@ -387,7 +353,7 @@ export async function renderSegments(
387
353
  )
388
354
  : Promise.resolve([]);
389
355
  const resolvedData = await layoutLoaderDataPromise;
390
- const { loaderData, errorFallback } = resolveLoaderData(
356
+ const { loaderData, errorFallback } = decodeLoaderResults(
391
357
  resolvedData,
392
358
  layoutLoaderIds,
393
359
  );
@@ -292,18 +292,16 @@ export async function discoverRouters(
292
292
  }
293
293
  }
294
294
 
295
+ // buildRouteTrie reads these via ?.has / ?.[] — empty is observationally
296
+ // identical to undefined, so no empty->undefined coercion is needed.
295
297
  newMergedRouteTrie = buildRouteTrie(
296
298
  newMergedRouteManifest,
297
299
  mergedRouteAncestry,
298
300
  routeToStaticPrefix,
299
- Object.keys(mergedRouteTrailingSlash).length > 0
300
- ? mergedRouteTrailingSlash
301
- : undefined,
302
- prerenderRouteNames.size > 0 ? prerenderRouteNames : undefined,
303
- passthroughRouteNames.size > 0 ? passthroughRouteNames : undefined,
304
- Object.keys(mergedResponseTypeRoutes).length > 0
305
- ? mergedResponseTypeRoutes
306
- : undefined,
301
+ mergedRouteTrailingSlash,
302
+ prerenderRouteNames,
303
+ passthroughRouteNames,
304
+ mergedResponseTypeRoutes,
307
305
  );
308
306
 
309
307
  // Build per-router tries for multi-router isolation.
@@ -330,20 +328,10 @@ export async function discoverRouters(
330
328
  manifest.routeManifest,
331
329
  manifest._routeAncestry,
332
330
  perRouterStaticPrefix,
333
- manifest.routeTrailingSlash &&
334
- Object.keys(manifest.routeTrailingSlash).length > 0
335
- ? manifest.routeTrailingSlash
336
- : undefined,
337
- perRouterPrerenderNames && perRouterPrerenderNames.size > 0
338
- ? perRouterPrerenderNames
339
- : undefined,
340
- perRouterPassthroughNames && perRouterPassthroughNames.size > 0
341
- ? perRouterPassthroughNames
342
- : undefined,
343
- manifest.responseTypeRoutes &&
344
- Object.keys(manifest.responseTypeRoutes).length > 0
345
- ? manifest.responseTypeRoutes
346
- : undefined,
331
+ manifest.routeTrailingSlash,
332
+ perRouterPrerenderNames,
333
+ perRouterPassthroughNames,
334
+ manifest.responseTypeRoutes,
347
335
  );
348
336
  newPerRouterTrieMap.set(id, perRouterTrie);
349
337
  }
@@ -5,13 +5,15 @@
5
5
  * from discovered router manifests and static source parsing.
6
6
  */
7
7
 
8
- import { dirname, basename, join, resolve } from "node:path";
8
+ import { dirname, join, resolve } from "node:path";
9
9
  import { readFileSync, writeFileSync, existsSync, unlinkSync } from "node:fs";
10
10
  import {
11
11
  generateRouteTypesSource,
12
12
  writeCombinedRouteTypes,
13
13
  findRouterFiles,
14
14
  buildCombinedRouteMapForRouterFile,
15
+ genFileTsPath,
16
+ resolveSearchSchemas,
15
17
  } from "../../build/generate-route-types.js";
16
18
  import type { DiscoveryState } from "./state.js";
17
19
  import { markSelfGenWrite } from "./self-gen-tracking.js";
@@ -35,6 +37,22 @@ function filterUserNamedRoutes(
35
37
  return filtered;
36
38
  }
37
39
 
40
+ // Write a gen file only when content changed, marking the write as
41
+ // self-generated BEFORE writeFileSync so the watcher distinguishes it from a
42
+ // manual edit (the HMR self-gen-loop guard).
43
+ function writeGenFileIfChanged(
44
+ state: DiscoveryState,
45
+ outPath: string,
46
+ source: string,
47
+ opts?: { log?: boolean },
48
+ ): void {
49
+ const existing = existsSync(outPath) ? readFileSync(outPath, "utf-8") : null;
50
+ if (existing === source) return;
51
+ markSelfGenWrite(state, outPath, source);
52
+ writeFileSync(outPath, source);
53
+ if (opts?.log) console.log(`[rango] Generated route types -> ${outPath}`);
54
+ }
55
+
38
56
  /**
39
57
  * Write combined route types for all router files.
40
58
  * Only writes when content has changed to avoid triggering HMR loops.
@@ -48,45 +66,16 @@ export function writeCombinedRouteTypesWithTracking(
48
66
  findRouterFiles(state.projectRoot, state.scanFilter);
49
67
  state.cachedRouterFiles = routerFiles;
50
68
 
51
- // Snapshot pre-write content to detect which files actually change.
52
- const preContent = new Map<string, string>();
53
- for (const routerFilePath of routerFiles) {
54
- const routerDir = dirname(routerFilePath);
55
- const routerBasename = basename(routerFilePath).replace(
56
- /\.(tsx?|jsx?)$/,
57
- "",
58
- );
59
- const outPath = join(routerDir, `${routerBasename}.named-routes.gen.ts`);
60
- try {
61
- preContent.set(outPath, readFileSync(outPath, "utf-8"));
62
- } catch {
63
- // File doesn't exist yet — any write is a real change.
64
- }
65
- }
66
-
67
- writeCombinedRouteTypes(state.projectRoot, routerFiles, opts);
68
-
69
- // Mark only files that were actually written so the watcher can
70
- // distinguish self-triggered change events from manual edits.
71
- // Marking unchanged files creates stale entries that interfere with
72
- // multi-server setups (e.g. shared webServer + isolated HMR server).
73
- for (const routerFilePath of routerFiles) {
74
- const routerDir = dirname(routerFilePath);
75
- const routerBasename = basename(routerFilePath).replace(
76
- /\.(tsx?|jsx?)$/,
77
- "",
78
- );
79
- const outPath = join(routerDir, `${routerBasename}.named-routes.gen.ts`);
80
- if (!existsSync(outPath)) continue;
81
- try {
82
- const content = readFileSync(outPath, "utf-8");
83
- if (content !== preContent.get(outPath)) {
84
- markSelfGenWrite(state, outPath, content);
85
- }
86
- } catch {
87
- // Ignore transient fs errors while files are being rewritten.
88
- }
89
- }
69
+ // Mark each gen file as self-generated BEFORE it is written, via the onWrite
70
+ // callback fired at every writeFileSync site, so the watcher distinguishes
71
+ // self-triggered change events from manual edits. The callback fires only
72
+ // for files actually written, so unchanged files are never marked (stale
73
+ // entries interfere with multi-server setups such as a shared webServer plus
74
+ // an isolated HMR server).
75
+ writeCombinedRouteTypes(state.projectRoot, routerFiles, {
76
+ ...opts,
77
+ onWrite: (outPath, content) => markSelfGenWrite(state, outPath, content),
78
+ });
90
79
  }
91
80
 
92
81
  /**
@@ -128,34 +117,16 @@ export function writeRouteTypesFiles(state: DiscoveryState): void {
128
117
  );
129
118
  }
130
119
 
131
- const routerDir = dirname(sourceFile);
132
- const routerBasename = basename(sourceFile).replace(/\.(tsx?|jsx?)$/, "");
133
- const outPath = join(routerDir, `${routerBasename}.named-routes.gen.ts`);
120
+ const outPath = genFileTsPath(sourceFile);
134
121
 
135
122
  // Filter out auto-generated route names (e.g. "$path____debug_reverse-test")
136
123
  // to match the static parser's output and prevent HMR oscillation.
137
124
  const userRoutes = filterUserNamedRoutes(routeManifest);
138
- let effectiveSearchSchemas = routeSearchSchemas;
139
-
140
- // Runtime manifest may omit search schema metadata in some module-runner
141
- // flows. Fall back to static source parsing from the router file.
142
- if (
143
- (!effectiveSearchSchemas ||
144
- Object.keys(effectiveSearchSchemas).length === 0) &&
145
- sourceFile
146
- ) {
147
- const staticParsed = buildCombinedRouteMapForRouterFile(sourceFile);
148
- if (Object.keys(staticParsed.searchSchemas).length > 0) {
149
- const filtered: Record<string, Record<string, string>> = {};
150
- for (const name of Object.keys(userRoutes)) {
151
- const schema = staticParsed.searchSchemas[name];
152
- if (schema) filtered[name] = schema;
153
- }
154
- if (Object.keys(filtered).length > 0) {
155
- effectiveSearchSchemas = filtered;
156
- }
157
- }
158
- }
125
+ const effectiveSearchSchemas = resolveSearchSchemas(
126
+ Object.keys(userRoutes),
127
+ routeSearchSchemas,
128
+ sourceFile,
129
+ );
159
130
 
160
131
  const source = generateRouteTypesSource(
161
132
  userRoutes,
@@ -163,14 +134,7 @@ export function writeRouteTypesFiles(state: DiscoveryState): void {
163
134
  ? effectiveSearchSchemas
164
135
  : undefined,
165
136
  );
166
- const existing = existsSync(outPath)
167
- ? readFileSync(outPath, "utf-8")
168
- : null;
169
- if (existing !== source) {
170
- markSelfGenWrite(state, outPath, source);
171
- writeFileSync(outPath, source);
172
- console.log(`[rango] Generated route types -> ${outPath}`);
173
- }
137
+ writeGenFileIfChanged(state, outPath, source, { log: true });
174
138
  }
175
139
  }
176
140
 
@@ -236,22 +200,14 @@ export function supplementGenFilesWithRuntimeRoutes(
236
200
  }
237
201
  }
238
202
 
239
- const routerDir = dirname(sourceFile);
240
- const routerBasename = basename(sourceFile).replace(/\.(tsx?|jsx?)$/, "");
241
- const outPath = join(routerDir, `${routerBasename}.named-routes.gen.ts`);
203
+ const outPath = genFileTsPath(sourceFile);
242
204
  const source = generateRouteTypesSource(
243
205
  mergedRoutes,
244
206
  Object.keys(mergedSearchSchemas).length > 0
245
207
  ? mergedSearchSchemas
246
208
  : undefined,
247
209
  );
248
- const existing = existsSync(outPath)
249
- ? readFileSync(outPath, "utf-8")
250
- : null;
251
- if (existing !== source) {
252
- markSelfGenWrite(state, outPath, source);
253
- writeFileSync(outPath, source);
254
- }
210
+ writeGenFileIfChanged(state, outPath, source);
255
211
  }
256
212
  // No manual manifest update needed: the virtual module imports the gen
257
213
  // file, so Vite's HMR automatically re-evaluates it with fresh data.
@@ -12,13 +12,10 @@ export function createCjsToEsmPlugin(): Plugin {
12
12
  name: "@rangojs/router:cjs-to-esm",
13
13
  enforce: "pre",
14
14
  transform(code, id) {
15
- const cleanId = id.split("?")[0];
15
+ const cleanId = id.split("?")[0].replaceAll("\\", "/");
16
16
 
17
17
  // Transform the client.browser.js entry point to re-export from CJS
18
- if (
19
- cleanId.includes("vendor/react-server-dom/client.browser.js") ||
20
- cleanId.includes("vendor\\react-server-dom\\client.browser.js")
21
- ) {
18
+ if (cleanId.includes("vendor/react-server-dom/client.browser.js")) {
22
19
  const isProd = process.env.NODE_ENV === "production";
23
20
  const cjsFile = isProd
24
21
  ? "./cjs/react-server-dom-webpack-client.browser.production.js"
@@ -33,8 +30,7 @@ export function createCjsToEsmPlugin(): Plugin {
33
30
 
34
31
  // Transform the actual CJS files to ESM
35
32
  if (
36
- (cleanId.includes("vendor/react-server-dom/cjs/") ||
37
- cleanId.includes("vendor\\react-server-dom\\cjs\\")) &&
33
+ cleanId.includes("vendor/react-server-dom/cjs/") &&
38
34
  cleanId.includes("client.browser")
39
35
  ) {
40
36
  let transformed = code;
@@ -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")