@rangojs/router 0.0.0-experimental.19 → 0.0.0-experimental.1b930379

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 (84) hide show
  1. package/README.md +46 -12
  2. package/dist/bin/rango.js +109 -15
  3. package/dist/vite/index.js +323 -121
  4. package/package.json +15 -16
  5. package/skills/breadcrumbs/SKILL.md +250 -0
  6. package/skills/caching/SKILL.md +4 -4
  7. package/skills/document-cache/SKILL.md +2 -2
  8. package/skills/hooks/SKILL.md +33 -31
  9. package/skills/host-router/SKILL.md +218 -0
  10. package/skills/loader/SKILL.md +55 -15
  11. package/skills/prerender/SKILL.md +2 -2
  12. package/skills/rango/SKILL.md +0 -1
  13. package/skills/route/SKILL.md +3 -4
  14. package/skills/router-setup/SKILL.md +8 -3
  15. package/skills/typesafety/SKILL.md +25 -23
  16. package/src/__internal.ts +92 -0
  17. package/src/bin/rango.ts +18 -0
  18. package/src/browser/link-interceptor.ts +4 -0
  19. package/src/browser/navigation-bridge.ts +95 -5
  20. package/src/browser/navigation-client.ts +97 -72
  21. package/src/browser/prefetch/cache.ts +112 -25
  22. package/src/browser/prefetch/fetch.ts +28 -30
  23. package/src/browser/prefetch/policy.ts +6 -0
  24. package/src/browser/react/Link.tsx +19 -7
  25. package/src/browser/rsc-router.tsx +11 -2
  26. package/src/browser/server-action-bridge.ts +448 -432
  27. package/src/browser/types.ts +24 -0
  28. package/src/build/generate-route-types.ts +2 -0
  29. package/src/build/route-trie.ts +19 -3
  30. package/src/build/route-types/router-processing.ts +125 -15
  31. package/src/client.rsc.tsx +2 -1
  32. package/src/client.tsx +1 -46
  33. package/src/handles/breadcrumbs.ts +66 -0
  34. package/src/handles/index.ts +1 -0
  35. package/src/host/index.ts +0 -3
  36. package/src/index.rsc.ts +5 -36
  37. package/src/index.ts +32 -66
  38. package/src/prerender/store.ts +56 -15
  39. package/src/route-definition/index.ts +0 -3
  40. package/src/router/handler-context.ts +30 -3
  41. package/src/router/loader-resolution.ts +1 -1
  42. package/src/router/match-api.ts +1 -1
  43. package/src/router/match-result.ts +0 -9
  44. package/src/router/metrics.ts +233 -13
  45. package/src/router/middleware-types.ts +53 -10
  46. package/src/router/middleware.ts +170 -81
  47. package/src/router/pattern-matching.ts +20 -5
  48. package/src/router/prerender-match.ts +4 -0
  49. package/src/router/revalidation.ts +27 -7
  50. package/src/router/router-interfaces.ts +14 -1
  51. package/src/router/router-options.ts +13 -8
  52. package/src/router/segment-resolution/fresh.ts +18 -0
  53. package/src/router/segment-resolution/helpers.ts +1 -1
  54. package/src/router/segment-resolution/revalidation.ts +22 -9
  55. package/src/router/trie-matching.ts +20 -2
  56. package/src/router.ts +29 -9
  57. package/src/rsc/handler.ts +106 -11
  58. package/src/rsc/index.ts +0 -20
  59. package/src/rsc/progressive-enhancement.ts +21 -8
  60. package/src/rsc/rsc-rendering.ts +30 -43
  61. package/src/rsc/server-action.ts +14 -10
  62. package/src/rsc/ssr-setup.ts +128 -0
  63. package/src/rsc/types.ts +2 -0
  64. package/src/search-params.ts +16 -13
  65. package/src/server/context.ts +8 -2
  66. package/src/server/request-context.ts +38 -16
  67. package/src/server.ts +6 -0
  68. package/src/theme/index.ts +4 -13
  69. package/src/types/handler-context.ts +12 -16
  70. package/src/types/route-config.ts +17 -8
  71. package/src/types/segments.ts +0 -5
  72. package/src/vite/discovery/bundle-postprocess.ts +31 -56
  73. package/src/vite/discovery/discover-routers.ts +18 -4
  74. package/src/vite/discovery/prerender-collection.ts +34 -14
  75. package/src/vite/discovery/state.ts +4 -7
  76. package/src/vite/index.ts +4 -3
  77. package/src/vite/plugins/client-ref-dedup.ts +115 -0
  78. package/src/vite/plugins/refresh-cmd.ts +65 -0
  79. package/src/vite/rango.ts +11 -0
  80. package/src/vite/router-discovery.ts +16 -0
  81. package/src/vite/utils/prerender-utils.ts +60 -0
  82. package/skills/testing/SKILL.md +0 -226
  83. package/src/route-definition/route-function.ts +0 -119
  84. /package/{CLAUDE.md → AGENTS.md} +0 -0
@@ -228,9 +228,17 @@ export type HandlerContext<
228
228
  */
229
229
  pathname: string;
230
230
  /**
231
- * The full URL object (with system params filtered).
231
+ * The full URL object (with internal `_rsc*` params stripped).
232
+ * Use this for application logic — routing, link generation, display.
232
233
  */
233
234
  url: URL;
235
+ /**
236
+ * The original request URL with all parameters intact, including
237
+ * internal `_rsc*` transport params. Use `ctx.url` for application
238
+ * logic — this is only needed for advanced cases like debugging
239
+ * or custom cache keying.
240
+ */
241
+ originalUrl: URL;
234
242
  /**
235
243
  * Platform bindings (DB, KV, secrets, etc.).
236
244
  * Access resources like `ctx.env.DB`, `ctx.env.KV`.
@@ -267,21 +275,7 @@ export type HandlerContext<
267
275
  <T>(contextVar: ContextVar<T>, value: T): void;
268
276
  } & (<K extends keyof DefaultVars>(key: K, value: DefaultVars[K]) => void);
269
277
  /**
270
- * Stub response for setting headers/cookies.
271
- * Headers set here are merged into the final response.
272
- *
273
- * @example
274
- * ```typescript
275
- * route("product", (ctx) => {
276
- * ctx.res.headers.set("Cache-Control", "s-maxage=60");
277
- * return <ProductPage />;
278
- * });
279
- * ```
280
- */
281
- res: Response;
282
- /**
283
- * Shorthand for ctx.res.headers - response headers.
284
- * Headers set here are merged into the final response.
278
+ * Response headers. Headers set here are merged into the final response.
285
279
  *
286
280
  * @example
287
281
  * ```typescript
@@ -436,6 +430,8 @@ export type InternalHandlerContext<
436
430
  TEnv = DefaultEnv,
437
431
  TSearch extends SearchSchema = {},
438
432
  > = HandlerContext<TParams, TEnv, TSearch> & {
433
+ /** @internal Stub response for collecting headers/cookies. */
434
+ res: Response;
439
435
  /** Prerender-only control flow helper, attached when the runtime context supports it. */
440
436
  passthrough?: () => unknown;
441
437
  /** Current segment ID for handle data attribution. */
@@ -24,17 +24,26 @@ type ParseConstraint<T extends string> =
24
24
  * - :param(a|b)? -> { name: "param", optional: true, type: "a" | "b" }
25
25
  */
26
26
  type ExtractParamInfo<T extends string> =
27
- // Optional + constrained: :param(a|b)?
28
- T extends `${infer Name}(${infer Constraint})?`
27
+ // Optional + constrained (with optional suffix): :param(a|b)?suffix
28
+ T extends `${infer Name}(${infer Constraint})?${string}`
29
29
  ? { name: Name; optional: true; type: ParseConstraint<Constraint> }
30
- : // Constrained only: :param(a|b)
31
- T extends `${infer Name}(${infer Constraint})`
30
+ : // Constrained (with optional suffix): :param(a|b)suffix
31
+ T extends `${infer Name}(${infer Constraint})${string}`
32
32
  ? { name: Name; optional: false; type: ParseConstraint<Constraint> }
33
- : // Optional only: :param?
34
- T extends `${infer Name}?`
33
+ : // Optional (with optional suffix): :param?suffix
34
+ T extends `${infer Name}?${string}`
35
35
  ? { name: Name; optional: true; type: string }
36
- : // Required: :param
37
- { name: T; optional: false; type: string };
36
+ : // Param with dot-suffix: :param.html
37
+ T extends `${infer Name}.${string}`
38
+ ? { name: Name; optional: false; type: string }
39
+ : // Param with dash-suffix: :param-slug
40
+ T extends `${infer Name}-${string}`
41
+ ? { name: Name; optional: false; type: string }
42
+ : // Param with tilde-suffix: :param~v2
43
+ T extends `${infer Name}~${string}`
44
+ ? { name: Name; optional: false; type: string }
45
+ : // Required: :param (no suffix)
46
+ { name: T; optional: false; type: string };
38
47
 
39
48
  /**
40
49
  * Build param object from info
@@ -124,11 +124,6 @@ export interface MatchResult {
124
124
  * Used by ctx.reverse() for local name resolution.
125
125
  */
126
126
  routeName?: string;
127
- /**
128
- * Server-Timing header value (only present when debugPerformance is enabled)
129
- * Can be added to response headers for DevTools integration
130
- */
131
- serverTiming?: string;
132
127
  /**
133
128
  * State of named slots for this route match
134
129
  * Key is slot name (e.g., "@modal"), value is slot state
@@ -6,9 +6,9 @@
6
6
  */
7
7
 
8
8
  import { resolve } from "node:path";
9
- import { createHash } from "node:crypto";
10
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
9
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
11
10
  import { evictHandlerCode } from "../utils/bundle-analysis.js";
11
+ import { copyStagedBuildAssets } from "../utils/prerender-utils.js";
12
12
  import type { DiscoveryState } from "./state.js";
13
13
 
14
14
  /**
@@ -17,11 +17,11 @@ import type { DiscoveryState } from "./state.js";
17
17
  */
18
18
  export function postprocessBundle(state: DiscoveryState): void {
19
19
  const hasPrerenderData =
20
- state.prerenderCollectedData &&
21
- Object.keys(state.prerenderCollectedData).length > 0;
20
+ state.prerenderManifestEntries &&
21
+ Object.keys(state.prerenderManifestEntries).length > 0;
22
22
  const hasStaticData =
23
- state.staticCollectedData &&
24
- Object.keys(state.staticCollectedData).length > 0;
23
+ state.staticManifestEntries &&
24
+ Object.keys(state.staticManifestEntries).length > 0;
25
25
  if (!hasPrerenderData && !hasStaticData) return;
26
26
 
27
27
  // Find RSC entry (recorded in generateBundle, fallback to dist/rsc/index.js)
@@ -88,39 +88,30 @@ export function postprocessBundle(state: DiscoveryState): void {
88
88
  state.staticHandlerChunkInfo = null;
89
89
 
90
90
  // 2. Write prerender data as separate importable asset modules
91
- // and inject a manifest import into the RSC entry.
91
+ // and inject a lazy manifest loader into the RSC entry.
92
92
  if (hasPrerenderData && existsSync(rscEntryPath)) {
93
93
  const rscCode = readFileSync(rscEntryPath, "utf-8");
94
- // Check for the specific injection marker, not just the variable name.
95
- // The runtime code (prerender store) also references __PRERENDER_MANIFEST,
96
- // so a broad string check would false-positive and skip injection.
94
+ // Check for the specific injection marker to avoid double-injection.
97
95
  if (!rscCode.includes("__prerender-manifest.js")) {
98
96
  try {
99
- const assetsDir = resolve(state.projectRoot, "dist/rsc/assets");
100
- mkdirSync(assetsDir, { recursive: true });
101
-
102
- const manifestEntries: string[] = [];
103
- let totalBytes = 0;
97
+ let totalBytes = copyStagedBuildAssets(
98
+ state.projectRoot,
99
+ Object.values(state.prerenderManifestEntries!),
100
+ );
104
101
 
105
- for (const [key, entry] of Object.entries(
106
- state.prerenderCollectedData!,
102
+ const manifestMap: Record<string, string> = {};
103
+ for (const [key, assetFileName] of Object.entries(
104
+ state.prerenderManifestEntries!,
107
105
  )) {
108
- const entryJson = JSON.stringify(entry);
109
- const contentHash = createHash("sha256")
110
- .update(entryJson)
111
- .digest("hex")
112
- .slice(0, 8);
113
- const assetFileName = `__pr-${contentHash}.js`;
114
- const assetPath = resolve(assetsDir, assetFileName);
115
- const assetCode = `export default ${entryJson};\n`;
116
- writeFileSync(assetPath, assetCode);
117
- totalBytes += Buffer.byteLength(assetCode);
118
- manifestEntries.push(
119
- `${JSON.stringify(key)}:()=>import("./assets/${assetFileName}")`,
120
- );
106
+ manifestMap[key] = `./assets/${assetFileName}`;
121
107
  }
122
108
 
123
- const manifestCode = `const m={${manifestEntries.join(",")}};export default m;\n`;
109
+ const manifestCode = [
110
+ `const m=JSON.parse('${JSON.stringify(manifestMap).replace(/'/g, "\\'")}');`,
111
+ `export function loadPrerenderAsset(s){return import(s)}`,
112
+ `export default m;`,
113
+ "",
114
+ ].join("\n");
124
115
  const manifestPath = resolve(
125
116
  state.projectRoot,
126
117
  "dist/rsc/__prerender-manifest.js",
@@ -128,12 +119,12 @@ export function postprocessBundle(state: DiscoveryState): void {
128
119
  writeFileSync(manifestPath, manifestCode);
129
120
  totalBytes += Buffer.byteLength(manifestCode);
130
121
 
131
- const injection = `import __pm from "./__prerender-manifest.js";\nglobalThis.__PRERENDER_MANIFEST = __pm;\n`;
122
+ const injection = `globalThis.__loadPrerenderManifestModule = () => import("./__prerender-manifest.js");\n`;
132
123
  writeFileSync(rscEntryPath, injection + rscCode);
133
124
 
134
125
  const totalKB = (totalBytes / 1024).toFixed(1);
135
126
  console.log(
136
- `[rsc-router] Wrote prerender assets (${totalKB} KB total, ${Object.keys(state.prerenderCollectedData!).length} entries)`,
127
+ `[rsc-router] Wrote prerender assets (${totalKB} KB total, ${Object.keys(state.prerenderManifestEntries!).length} entries)`,
137
128
  );
138
129
  } catch (err: any) {
139
130
  throw new Error(
@@ -149,31 +140,15 @@ export function postprocessBundle(state: DiscoveryState): void {
149
140
  const rscCode = readFileSync(rscEntryPath, "utf-8");
150
141
  if (!rscCode.includes("__STATIC_MANIFEST")) {
151
142
  try {
152
- const assetsDir = resolve(state.projectRoot, "dist/rsc/assets");
153
- mkdirSync(assetsDir, { recursive: true });
154
-
155
143
  const manifestEntries: string[] = [];
156
- let totalBytes = 0;
144
+ let totalBytes = copyStagedBuildAssets(
145
+ state.projectRoot,
146
+ Object.values(state.staticManifestEntries!),
147
+ );
157
148
 
158
- for (const [handlerId, { encoded, handles }] of Object.entries(
159
- state.staticCollectedData!,
149
+ for (const [handlerId, assetFileName] of Object.entries(
150
+ state.staticManifestEntries!,
160
151
  )) {
161
- // Store both the Flight payload and handle data
162
- const hasHandles = Object.keys(handles).length > 0;
163
- const exportValue = hasHandles
164
- ? JSON.stringify({ encoded, handles })
165
- : JSON.stringify(encoded);
166
- // Hash the full payload that is written so distinct handle
167
- // snapshots produce distinct asset filenames.
168
- const contentHash = createHash("sha256")
169
- .update(exportValue)
170
- .digest("hex")
171
- .slice(0, 8);
172
- const assetFileName = `__st-${contentHash}.js`;
173
- const assetPath = resolve(assetsDir, assetFileName);
174
- const assetCode = `export default ${exportValue};\n`;
175
- writeFileSync(assetPath, assetCode);
176
- totalBytes += Buffer.byteLength(assetCode);
177
152
  manifestEntries.push(
178
153
  `${JSON.stringify(handlerId)}:()=>import("./assets/${assetFileName}")`,
179
154
  );
@@ -197,7 +172,7 @@ export function postprocessBundle(state: DiscoveryState): void {
197
172
 
198
173
  const totalKB = (totalBytes / 1024).toFixed(1);
199
174
  console.log(
200
- `[rsc-router] Wrote static assets (${totalKB} KB total, ${Object.keys(state.staticCollectedData!).length} entries)`,
175
+ `[rsc-router] Wrote static assets (${totalKB} KB total, ${Object.keys(state.staticManifestEntries!).length} entries)`,
201
176
  );
202
177
  } catch (err: any) {
203
178
  throw new Error(
@@ -6,7 +6,11 @@
6
6
  * router, and builds route tries for O(path_length) matching.
7
7
  */
8
8
 
9
- import { buildCombinedRouteMapForRouterFile } from "../../build/generate-route-types.js";
9
+ import {
10
+ buildCombinedRouteMapForRouterFile,
11
+ formatNestedRouterConflictError,
12
+ findNestedRouterConflict,
13
+ } from "../../build/generate-route-types.js";
10
14
  import {
11
15
  flattenLeafEntries,
12
16
  buildRouteToStaticPrefix,
@@ -44,9 +48,8 @@ export async function discoverRouters(
44
48
  // No RSC routers found directly. Check for host routers with lazy handlers
45
49
  // that need to be resolved to trigger sub-app createRouter() calls.
46
50
  try {
47
- const hostMod = await rscEnv.runner.import("@rangojs/router/host");
48
51
  const hostRegistry: Map<string, any> | undefined =
49
- hostMod.HostRouterRegistry;
52
+ serverMod.HostRouterRegistry;
50
53
 
51
54
  if (hostRegistry && hostRegistry.size > 0) {
52
55
  console.log(
@@ -85,7 +88,7 @@ export async function discoverRouters(
85
88
  }
86
89
  }
87
90
  } catch {
88
- // @rangojs/router/host not available or import failed, skip
91
+ // Host-router discovery is best-effort; skip if unavailable
89
92
  }
90
93
 
91
94
  // If still no routers after host router resolution, fail
@@ -100,6 +103,17 @@ export async function discoverRouters(
100
103
  const buildMod = await rscEnv.runner.import("@rangojs/router/build");
101
104
  const generateManifestFull = buildMod.generateManifestFull;
102
105
 
106
+ const nestedRouterConflict = findNestedRouterConflict(
107
+ [...registry.values()]
108
+ .map((router) => router.__sourceFile)
109
+ .filter(
110
+ (sourceFile): sourceFile is string => typeof sourceFile === "string",
111
+ ),
112
+ );
113
+ if (nestedRouterConflict) {
114
+ throw new Error(formatNestedRouterConflictError(nestedRouterConflict));
115
+ }
116
+
103
117
  // Build into local variables first. Only commit to state after the
104
118
  // full pass succeeds, so a failed re-discovery preserves the last
105
119
  // known-good state instead of leaving it partially wiped.
@@ -13,12 +13,14 @@ import {
13
13
  runWithConcurrency,
14
14
  groupByConcurrency,
15
15
  notifyOnError,
16
+ stageBuildAssetModule,
16
17
  } from "../utils/prerender-utils.js";
17
18
  import type { DiscoveryState } from "./state.js";
18
19
 
19
20
  /**
20
21
  * Expand prerender routes into concrete URLs and render them via the
21
- * RSC runner. Stores collected data in state.prerenderCollectedData.
22
+ * RSC runner. Stages asset modules and stores key-to-file entries in
23
+ * state.prerenderManifestEntries.
22
24
  */
23
25
  export async function expandPrerenderRoutes(
24
26
  state: DiscoveryState,
@@ -150,7 +152,7 @@ export async function expandPrerenderRoutes(
150
152
 
151
153
  const { hashParams } = await rscEnv.runner.import("@rangojs/router/build");
152
154
 
153
- const collectedData: Record<string, any> = {};
155
+ const manifestEntries: Record<string, string> = {};
154
156
  let doneCount = 0;
155
157
  let skipCount = 0;
156
158
  const startTotal = performance.now();
@@ -187,18 +189,30 @@ export async function expandPrerenderRoutes(
187
189
  }
188
190
 
189
191
  const paramHash = hashParams(result.params || {});
190
- collectedData[`${result.routeName}/${paramHash}`] = {
192
+ const mainKey = `${result.routeName}/${paramHash}`;
193
+ const mainValue = JSON.stringify({
191
194
  segments: result.segments,
192
195
  handles: result.handles,
193
- };
196
+ });
197
+ manifestEntries[mainKey] = stageBuildAssetModule(
198
+ state.projectRoot,
199
+ "__pr",
200
+ mainValue,
201
+ );
194
202
  if (result.interceptSegments?.length) {
195
- collectedData[`${result.routeName}/${paramHash}/i`] = {
203
+ const interceptKey = `${result.routeName}/${paramHash}/i`;
204
+ const interceptValue = JSON.stringify({
196
205
  segments: [...result.segments, ...result.interceptSegments],
197
206
  handles: {
198
207
  ...result.handles,
199
208
  ...(result.interceptHandles || {}),
200
209
  },
201
- };
210
+ });
211
+ manifestEntries[interceptKey] = stageBuildAssetModule(
212
+ state.projectRoot,
213
+ "__pr",
214
+ interceptValue,
215
+ );
202
216
  }
203
217
  const elapsed = (performance.now() - startUrl).toFixed(0);
204
218
  console.log(
@@ -244,7 +258,7 @@ export async function expandPrerenderRoutes(
244
258
 
245
259
  const totalElapsed = (performance.now() - startTotal).toFixed(0);
246
260
  if (doneCount > 0) {
247
- state.prerenderCollectedData = collectedData;
261
+ state.prerenderManifestEntries = manifestEntries;
248
262
  }
249
263
  const parts = [`${doneCount} done`];
250
264
  if (skipCount > 0) parts.push(`${skipCount} skipped`);
@@ -256,7 +270,8 @@ export async function expandPrerenderRoutes(
256
270
  /**
257
271
  * Render Static handlers at build time. Each Static handler is called
258
272
  * with a synthetic BuildContext and its output is RSC-serialized.
259
- * Stores collected data in state.staticCollectedData.
273
+ * Stages asset modules and stores handlerId-to-file entries in
274
+ * state.staticManifestEntries.
260
275
  */
261
276
  export async function renderStaticHandlers(
262
277
  state: DiscoveryState,
@@ -270,10 +285,7 @@ export async function renderStaticHandlers(
270
285
  )
271
286
  return;
272
287
 
273
- const collected: Record<
274
- string,
275
- { encoded: string; handles: Record<string, unknown[]> }
276
- > = {};
288
+ const manifestEntries: Record<string, string> = {};
277
289
  let staticDone = 0;
278
290
  let staticSkip = 0;
279
291
  let totalStaticCount = 0;
@@ -316,7 +328,15 @@ export async function renderStaticHandlers(
316
328
  (def as any).$$routePrefix,
317
329
  );
318
330
  if (result) {
319
- collected[def.$$id] = result;
331
+ const hasHandles = Object.keys(result.handles).length > 0;
332
+ const exportValue = hasHandles
333
+ ? JSON.stringify(result)
334
+ : JSON.stringify(result.encoded);
335
+ manifestEntries[def.$$id] = stageBuildAssetModule(
336
+ state.projectRoot,
337
+ "__st",
338
+ exportValue,
339
+ );
320
340
  const elapsed = (performance.now() - startHandler).toFixed(0);
321
341
  console.log(
322
342
  `[rsc-router] OK ${name.padEnd(40)} (${elapsed}ms)`,
@@ -355,7 +375,7 @@ export async function renderStaticHandlers(
355
375
 
356
376
  const totalStaticElapsed = (performance.now() - startStatic).toFixed(0);
357
377
  if (staticDone > 0) {
358
- state.staticCollectedData = collected;
378
+ state.staticManifestEntries = manifestEntries;
359
379
  }
360
380
  const staticParts = [`${staticDone} done`];
361
381
  if (staticSkip > 0) staticParts.push(`${staticSkip} skipped`);
@@ -56,11 +56,8 @@ export interface DiscoveryState {
56
56
  perRouterPrecomputedMap: Map<string, PrecomputedEntry[]>;
57
57
  perRouterManifestDataMap: Map<string, Record<string, string>>;
58
58
 
59
- prerenderCollectedData: Record<string, any> | null;
60
- staticCollectedData: Record<
61
- string,
62
- { encoded: string; handles: Record<string, unknown[]> }
63
- > | null;
59
+ prerenderManifestEntries: Record<string, string> | null;
60
+ staticManifestEntries: Record<string, string> | null;
64
61
  handlerChunkInfo: ChunkInfo | null;
65
62
  staticHandlerChunkInfo: ChunkInfo | null;
66
63
  rscEntryFileName: string | null;
@@ -96,8 +93,8 @@ export function createDiscoveryState(
96
93
  perRouterPrecomputedMap: new Map(),
97
94
  perRouterManifestDataMap: new Map(),
98
95
 
99
- prerenderCollectedData: null,
100
- staticCollectedData: null,
96
+ prerenderManifestEntries: null,
97
+ staticManifestEntries: null,
101
98
  handlerChunkInfo: null,
102
99
  staticHandlerChunkInfo: null,
103
100
  rscEntryFileName: null,
package/src/vite/index.ts CHANGED
@@ -1,12 +1,13 @@
1
1
  /**
2
2
  * Public API for @rangojs/router/vite
3
3
  *
4
- * Only the rango() plugin factory and its option types are part of the
5
- * public API. All other utilities are internal implementation details
6
- * consumed via direct imports within the package.
4
+ * Exports: rango() plugin factory, poke() dev utility plugin,
5
+ * and related option types. All other utilities are internal implementation
6
+ * details consumed via direct imports within the package.
7
7
  */
8
8
 
9
9
  export { rango } from "./rango.js";
10
+ export { poke } from "./plugins/refresh-cmd.js";
10
11
 
11
12
  export type {
12
13
  RangoNodeOptions,
@@ -0,0 +1,115 @@
1
+ import type { Plugin, ResolvedConfig } from "vite";
2
+
3
+ const CLIENT_IN_SERVER_PROXY_PREFIX =
4
+ "virtual:vite-rsc/client-in-server-package-proxy/";
5
+
6
+ /**
7
+ * Extract the bare package name from an absolute node_modules path.
8
+ * Handles scoped packages (@org/name) and nested node_modules.
9
+ * Returns null if the path doesn't contain a valid package reference.
10
+ *
11
+ * NOTE: This is a lossy transformation. It maps a specific submodule path
12
+ * (e.g., pkg/internal/context.js) to the package root (pkg). The load()
13
+ * hook then re-exports via the bare specifier, which resolves to the
14
+ * package entry point. This works for packages that barrel-export their
15
+ * "use client" symbols from the root, which covers the common case
16
+ * (component libraries like @mantine/core, @chakra-ui/react, etc.).
17
+ * Packages whose client symbols are only available from deep subpaths
18
+ * (not re-exported from the root) would lose those symbols after the
19
+ * rewrite. A more precise approach would resolve through the package's
20
+ * exports map to find the correct entry point, but that adds significant
21
+ * complexity for a rare edge case.
22
+ * See: https://github.com/cloudflare/vinext/pull/413
23
+ */
24
+ export function extractPackageName(absolutePath: string): string | null {
25
+ // Find the last /node_modules/ segment (handles nested node_modules)
26
+ const marker = "/node_modules/";
27
+ const idx = absolutePath.lastIndexOf(marker);
28
+ if (idx === -1) return null;
29
+
30
+ const afterModules = absolutePath.slice(idx + marker.length);
31
+
32
+ if (afterModules.startsWith("@")) {
33
+ // Scoped package: @org/name
34
+ const parts = afterModules.split("/");
35
+ if (parts.length < 2 || !parts[1]) return null;
36
+ return `${parts[0]}/${parts[1]}`;
37
+ }
38
+
39
+ // Unscoped package: name
40
+ const name = afterModules.split("/")[0];
41
+ return name || null;
42
+ }
43
+
44
+ /**
45
+ * Vite plugin that deduplicates client references from third-party packages
46
+ * in dev mode.
47
+ *
48
+ * When @vitejs/plugin-rsc encounters a "use client" submodule inside a
49
+ * package imported from a server component, it creates a
50
+ * client-in-server-package-proxy virtual module that re-exports from the
51
+ * absolute file path. In the client environment, this absolute path bypasses
52
+ * Vite's pre-bundling, while direct client imports of the same package go
53
+ * through .vite/deps/. Two separate module instances are created, breaking
54
+ * React contexts (createContext runs twice, provider/consumer mismatch).
55
+ *
56
+ * This plugin intercepts absolute node_modules imports from proxy modules
57
+ * in the client environment and rewrites them to bare specifier imports
58
+ * that go through pre-bundling, ensuring a single module instance.
59
+ *
60
+ * Dev-only: production builds use the SSR manifest which handles module
61
+ * identity correctly.
62
+ */
63
+ export function clientRefDedup(): Plugin {
64
+ let clientExclude: string[] = [];
65
+
66
+ return {
67
+ name: "@rangojs/router:client-ref-dedup",
68
+ enforce: "pre",
69
+ apply: "serve",
70
+
71
+ configResolved(config: ResolvedConfig) {
72
+ // Respect user's optimizeDeps.exclude — if a package is explicitly
73
+ // excluded from pre-bundling, we shouldn't redirect it there.
74
+ const clientEnv = config.environments?.["client"];
75
+ clientExclude =
76
+ clientEnv?.optimizeDeps?.exclude ?? config.optimizeDeps?.exclude ?? [];
77
+ },
78
+
79
+ resolveId(source, importer, options) {
80
+ // Only intercept in the client environment
81
+ if (this.environment?.name !== "client") return;
82
+
83
+ // Only handle imports from client-in-server-package-proxy virtual modules
84
+ if (!importer?.includes(CLIENT_IN_SERVER_PROXY_PREFIX)) return;
85
+
86
+ // Only handle absolute node_modules paths
87
+ if (!source.includes("/node_modules/")) return;
88
+
89
+ // Must have an importer
90
+ if (!importer) return;
91
+
92
+ const packageName = extractPackageName(source);
93
+ if (!packageName) return;
94
+
95
+ // Don't redirect packages that are excluded from optimization
96
+ if (clientExclude.includes(packageName)) return;
97
+
98
+ // Return a virtual module that re-exports via bare specifier
99
+ return `\0rango:dedup/${packageName}`;
100
+ },
101
+
102
+ load(id) {
103
+ if (!id.startsWith("\0rango:dedup/")) return;
104
+
105
+ const packageName = id.slice("\0rango:dedup/".length);
106
+
107
+ // Re-export via bare specifier so Vite routes through pre-bundling
108
+ return [
109
+ `export * from ${JSON.stringify(packageName)};`,
110
+ `import * as __all__ from ${JSON.stringify(packageName)};`,
111
+ `export default __all__.default;`,
112
+ ].join("\n");
113
+ },
114
+ };
115
+ }
@@ -0,0 +1,65 @@
1
+ import type { Plugin } from "vite";
2
+
3
+ /**
4
+ * Vite plugin that triggers a full browser reload when Ctrl+R is pressed
5
+ * in the terminal running the dev server.
6
+ *
7
+ * Usage:
8
+ * ```ts
9
+ * import { poke } from "@rangojs/router/vite";
10
+ *
11
+ * export default defineConfig({
12
+ * plugins: [rango(), poke()],
13
+ * });
14
+ * ```
15
+ */
16
+ export function poke(): Plugin {
17
+ return {
18
+ name: "vite-plugin-poke",
19
+ apply: "serve",
20
+
21
+ configureServer(server) {
22
+ const stdin = process.stdin;
23
+
24
+ // Raw mode delivers individual keystrokes as immediate single-byte
25
+ // events instead of waiting for Enter (cooked/line-buffered mode).
26
+ // Without it, Ctrl+R (0x12) is never delivered as a discrete byte.
27
+ // When stdin is a pipe (CI, spawned process) setRawMode is unavailable
28
+ // but data already arrives unbuffered, so the isTTY guard suffices.
29
+ const previousRawMode = stdin.isTTY ? stdin.isRaw : null;
30
+ if (stdin.isTTY) {
31
+ stdin.setRawMode(true);
32
+ }
33
+
34
+ const onData = (data: Buffer) => {
35
+ if (data.length !== 1) return;
36
+
37
+ // Ctrl+C (0x03) — defensive fallback. This plugin enables raw mode
38
+ // before Vite's internal stdin handler is registered (user plugins
39
+ // run first), so there is a brief window where Ctrl+C would be
40
+ // swallowed. Re-emit SIGINT so the process exits as expected.
41
+ if (data[0] === 0x03) {
42
+ process.emit("SIGINT", "SIGINT");
43
+ return;
44
+ }
45
+
46
+ // Ctrl+R = 0x12 in raw mode
47
+ if (data[0] === 0x12) {
48
+ server.hot.send({ type: "full-reload", path: "*" });
49
+ server.config.logger.info(" browser reload (ctrl+r)", {
50
+ timestamp: true,
51
+ });
52
+ }
53
+ };
54
+
55
+ stdin.on("data", onData);
56
+
57
+ server.httpServer?.on("close", () => {
58
+ stdin.off("data", onData);
59
+ if (stdin.isTTY && previousRawMode !== null) {
60
+ stdin.setRawMode(previousRawMode);
61
+ }
62
+ });
63
+ },
64
+ };
65
+ }
package/src/vite/rango.ts CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  exposeRouterId,
8
8
  } from "./plugins/expose-internal-ids.js";
9
9
  import { useCacheTransform } from "./plugins/use-cache-transform.js";
10
+ import { clientRefDedup } from "./plugins/client-ref-dedup.js";
10
11
  import { VIRTUAL_IDS } from "./plugins/virtual-entries.js";
11
12
  import {
12
13
  getExcludeDeps,
@@ -200,6 +201,11 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
200
201
  serverHandler: false,
201
202
  }) as PluginOption,
202
203
  );
204
+
205
+ // Deduplicate client references from third-party packages in dev mode.
206
+ // Prevents module duplication when server components import "use client"
207
+ // packages that are also imported directly by client components.
208
+ plugins.push(clientRefDedup());
203
209
  } else {
204
210
  // Node preset: full RSC plugin integration
205
211
  const nodeOptions = resolvedOptions as RangoNodeOptions;
@@ -393,6 +399,11 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
393
399
  }) as PluginOption,
394
400
  );
395
401
  }
402
+
403
+ // Deduplicate client references from third-party packages in dev mode.
404
+ // Prevents module duplication when server components import "use client"
405
+ // packages that are also imported directly by client components.
406
+ plugins.push(clientRefDedup());
396
407
  }
397
408
 
398
409
  // Fix HMR for "use client" components.