@rangojs/router 0.0.0-experimental.102 → 0.0.0-experimental.104

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.
@@ -58,7 +58,7 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
58
58
  }
59
59
 
60
60
  const lines = [
61
- `import { setCachedManifest, setPrecomputedEntries, setRouteTrie, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
61
+ `import { setCachedManifest, setRouterManifest, registerRouterManifestLoader, clearAllRouterData } from "@rangojs/router/server";`,
62
62
  ...genFileImports,
63
63
  // Clear stale per-router cached data (manifest, trie, precomputed entries)
64
64
  // before re-populating. In Cloudflare dev mode, program reloads re-evaluate
@@ -101,28 +101,18 @@ export function generateRoutesManifestModule(state: DiscoveryState): string {
101
101
  }
102
102
  }
103
103
 
104
- // In dev mode, skip trie and precomputed entries injection. These are
105
- // computed once during initial discovery and become stale after route
106
- // changes. A stale trie would incorrectly match removed routes. The
107
- // handler falls back to Phase 2 regex matching against the live
108
- // router.urlpatterns, which is always correct after a program reload.
109
- // In build mode, the trie is always fresh (built from the final route
110
- // tree) so it's safe to inject.
111
- if (state.isBuildMode) {
112
- if (
113
- state.mergedPrecomputedEntries &&
114
- state.mergedPrecomputedEntries.length > 0
115
- ) {
116
- lines.push(
117
- `setPrecomputedEntries(${jsonParseExpression(state.mergedPrecomputedEntries)});`,
118
- );
119
- }
120
- if (state.mergedRouteTrie) {
121
- lines.push(
122
- `setRouteTrie(${jsonParseExpression(state.mergedRouteTrie)});`,
123
- );
124
- }
125
- }
104
+ // Per-router trie and precomputedEntries are NOT inlined eagerly.
105
+ // They live in the per-router lazy chunks (generatePerRouterModule) and
106
+ // are loaded via ensureRouterManifest(routerId), which is awaited before
107
+ // every request in router.fetch() and before findMatch is reached.
108
+ // Inlining the merged versions here would duplicate the per-router data
109
+ // (the merged trie/precomputedEntries equal the per-router data for
110
+ // single-router apps; for multi-router, the merged trie is dead code
111
+ // because find-match.ts only consumes per-router tries).
112
+ //
113
+ // In dev mode, the handler also falls back to Phase 2 regex matching
114
+ // against live router.urlpatterns, which is always correct after a
115
+ // program reload.
126
116
 
127
117
  // Register lazy loaders for per-router manifest modules.
128
118
  // Each import() uses a static string literal so Rollup creates separate chunks.
package/src/vite/rango.ts CHANGED
@@ -131,6 +131,14 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
131
131
  },
132
132
  resolve: {
133
133
  alias: rangoAliases,
134
+ // Force a single React/React-DOM copy across all three RSC
135
+ // environments. RSC requires exactly one react/react-dom instance
136
+ // per environment runtime; consumer install topologies (pnpm
137
+ // strict layout, experimental React pins, third-party "use client"
138
+ // packages) can otherwise resolve duplicate copies, causing
139
+ // "Invalid hook call" / lost context. Child environments inherit
140
+ // this root dedupe, and Vite merges it with any consumer dedupe.
141
+ dedupe: ["react", "react-dom"],
134
142
  },
135
143
  build: {
136
144
  rollupOptions: { onwarn },
@@ -157,10 +165,6 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
157
165
  build: {
158
166
  outDir: "./dist/rsc/ssr",
159
167
  },
160
- resolve: {
161
- // Ensure single React instance in SSR child environment
162
- dedupe: ["react", "react-dom"],
163
- },
164
168
  // Pre-bundle SSR entry and React for proper module linking with childEnvironments
165
169
  // All deps must be listed to avoid late discovery triggering ERR_OUTDATED_OPTIMIZED_DEP
166
170
  optimizeDeps: {
@@ -289,6 +293,14 @@ export async function rango(options?: RangoOptions): Promise<PluginOption[]> {
289
293
  },
290
294
  resolve: {
291
295
  alias: rangoAliases,
296
+ // Force a single React/React-DOM copy across all three RSC
297
+ // environments. RSC requires exactly one react/react-dom instance
298
+ // per environment runtime; consumer install topologies (pnpm
299
+ // strict layout, experimental React pins, third-party "use client"
300
+ // packages) can otherwise resolve duplicate copies, causing
301
+ // "Invalid hook call" / lost context. Child environments inherit
302
+ // this root dedupe, and Vite merges it with any consumer dedupe.
303
+ dedupe: ["react", "react-dom"],
292
304
  },
293
305
  environments: {
294
306
  client: {
@@ -52,6 +52,10 @@ import {
52
52
  import { postprocessBundle } from "./discovery/bundle-postprocess.js";
53
53
  import { createDiscoveryGate } from "./discovery/gate-state.js";
54
54
  import { resetStagedBuildAssets } from "./utils/prerender-utils.js";
55
+ import {
56
+ pickForwardedRunnerConfig,
57
+ selectForwardableResolvePlugins,
58
+ } from "./utils/forward-user-plugins.js";
55
59
  import { createRangoDebugger, timed, timedSync, NS } from "./debug.js";
56
60
 
57
61
  const debugDiscovery = createRangoDebugger(NS.discovery);
@@ -129,14 +133,27 @@ async function createTempRscServer(
129
133
  // instead of crashing Node's native loader.
130
134
  ensureCloudflareProtocolLoaderRegistered();
131
135
  const { default: rsc } = await import("@vitejs/plugin-rsc");
136
+ // Mirror the user's resolution config + plugins so discovery (and the
137
+ // prerender/static rendering that shares this runner) resolves modules the
138
+ // same way the real environment does. Falls back to the legacy alias-only
139
+ // behavior if configResolved hasn't populated the parity slice yet.
140
+ const runnerConfig = state.userRunnerConfig;
141
+ const resolveConfig = runnerConfig?.resolve ?? {
142
+ alias: state.userResolveAlias,
143
+ };
144
+ const esbuildConfig = runnerConfig?.esbuild ?? {
145
+ jsx: "automatic",
146
+ jsxImportSource: "react",
147
+ };
132
148
  return createViteServer({
133
149
  root: state.projectRoot,
134
150
  configFile: false,
135
151
  server: { middlewareMode: true },
136
152
  appType: "custom",
137
153
  logLevel: "silent",
138
- resolve: { alias: state.userResolveAlias },
139
- esbuild: { jsx: "automatic", jsxImportSource: "react" },
154
+ resolve: resolveConfig,
155
+ ...(runnerConfig?.define ? { define: runnerConfig.define } : {}),
156
+ esbuild: esbuildConfig as any,
140
157
  ...(options.cacheDir && { cacheDir: options.cacheDir }),
141
158
  plugins: [
142
159
  rsc({
@@ -155,6 +172,10 @@ async function createTempRscServer(
155
172
  // runtime. forceBuild produces hashed IDs for production bundle consistency.
156
173
  exposeInternalIds(options.forceBuild ? { forceBuild: true } : undefined),
157
174
  exposeRouterId(),
175
+ // Forwarded user resolution plugins (e.g. vite-tsconfig-paths). Stripped
176
+ // to resolveId/load and placed last so framework resolution runs first;
177
+ // Vite re-sorts by `enforce`, so `enforce: "pre"` resolvers still lead.
178
+ ...state.userResolvePlugins,
158
179
  ],
159
180
  });
160
181
  }
@@ -309,6 +330,16 @@ export function createRouterDiscoveryPlugin(
309
330
  viteMode = config.mode;
310
331
  // Capture user's resolve aliases for the temp server
311
332
  s.userResolveAlias = config.resolve.alias;
333
+ // Capture the data-only resolution config (resolve.*, define, esbuild)
334
+ // and the user's resolution plugins (resolveId/load) so the discovery
335
+ // temp server resolves modules the same way the real environment does.
336
+ // Without this, third-party resolvers (e.g. vite-tsconfig-paths) are
337
+ // absent during discovery/prerender/static rendering even though they
338
+ // apply at request time. See utils/forward-user-plugins.ts.
339
+ s.userRunnerConfig = pickForwardedRunnerConfig(config);
340
+ s.userResolvePlugins = selectForwardableResolvePlugins(
341
+ config.plugins as any,
342
+ );
312
343
  // Node preset: pick up auto-discovered router path from the config() hook.
313
344
  // The auto-discover plugin runs in config() using Vite's resolved root,
314
345
  // populating the mutable ref before configResolved fires.
@@ -1272,6 +1303,7 @@ export function createRouterDiscoveryPlugin(
1272
1303
  .join("\n");
1273
1304
  throw new Error(
1274
1305
  `[rsc-router] Build-time router discovery failed:\n${details}`,
1306
+ { cause: err },
1275
1307
  );
1276
1308
  } finally {
1277
1309
  delete (globalThis as any).__rscRouterDiscoveryActive;
@@ -0,0 +1,164 @@
1
+ /**
2
+ * Discovery Runner Config Parity
3
+ *
4
+ * The discovery temp server (createTempRscServer) runs the user's handler
5
+ * graph through a throwaway Node Vite server built with `configFile: false`.
6
+ * Without help, that server only sees a fixed Rango-owned plugin set, so any
7
+ * user resolution provided by third-party plugins (e.g. vite-tsconfig-paths)
8
+ * is absent during discovery, prerender, and static handler rendering — even
9
+ * though it applies at request time.
10
+ *
11
+ * These helpers extract the resolution-relevant slice of the user's resolved
12
+ * config (resolve.*, define, esbuild) and forward the user's resolution
13
+ * plugins into the temp server so discovery resolves modules the same way the
14
+ * real environment does.
15
+ */
16
+
17
+ import type { Plugin, ResolvedConfig, UserConfig } from "vite";
18
+
19
+ /**
20
+ * Whether a user plugin must NOT be forwarded into the discovery temp server.
21
+ *
22
+ * Framework-owned plugins are matched precisely -- by exact name or a
23
+ * namespaced prefix -- rather than by a loose substring/word prefix, so an
24
+ * unrelated user resolver named e.g. `rsc-paths` or `cloudflare-kv-alias` is
25
+ * still forwarded (it would otherwise reproduce issue #500).
26
+ *
27
+ * - `vite:*` Vite core + official plugins (incl.
28
+ * @vitejs/plugin-react's `vite:react-*`). The temp
29
+ * server already provides its own core; forwarding
30
+ * these would duplicate or conflict with it.
31
+ * - `rsc` / `rsc:*` @vitejs/plugin-rsc. createTempRscServer instantiates
32
+ * its own rsc() plugin; forwarding would duplicate it.
33
+ * Matched exactly (`rsc`) or by the `rsc:` namespace --
34
+ * NOT every `rsc`-prefixed name.
35
+ * - `@rangojs/router*` Our own plugins. The discovery plugin spawns the
36
+ * temp server, so forwarding would recurse infinitely.
37
+ * - `@cloudflare/vite-plugin*` / @cloudflare/vite-plugin (emits the scoped
38
+ * `vite-plugin-cloudflare*` `@cloudflare/vite-plugin` and unscoped
39
+ * `vite-plugin-cloudflare` / `vite-plugin-cloudflare:*`).
40
+ * Forwarding re-inits workerd, defeating the Node temp
41
+ * server. Matched specifically so a scoped user resolver
42
+ * like `@cloudflare/kv-alias` is still forwarded.
43
+ */
44
+ function isDenied(name: string): boolean {
45
+ return (
46
+ name.startsWith("vite:") ||
47
+ name === "rsc" ||
48
+ name.startsWith("rsc:") ||
49
+ name.startsWith("@rangojs/router") ||
50
+ name.startsWith("@cloudflare/vite-plugin") ||
51
+ name.startsWith("vite-plugin-cloudflare")
52
+ );
53
+ }
54
+
55
+ /**
56
+ * A plugin participates in resolution if it exposes `resolveId` or `load`.
57
+ * Plugins that only transform, configure the server, or hook into the build
58
+ * lifecycle do not affect how bare specifiers resolve, so we skip them to keep
59
+ * the forwarded surface minimal.
60
+ */
61
+ function hasResolutionHooks(p: Plugin): boolean {
62
+ return Boolean((p as any).resolveId || (p as any).load);
63
+ }
64
+
65
+ /**
66
+ * Strip a resolved plugin instance down to its resolution hooks plus the
67
+ * gating fields that decide whether/where it runs.
68
+ *
69
+ * We reuse the SAME instance objects captured from `config.plugins`. By the
70
+ * time `configResolved` fires on the discovery plugin, every plugin's own
71
+ * `config`/`configResolved` has already run on the main server, so any state
72
+ * a `resolveId` hook depends on (e.g. vite-tsconfig-paths' compiled path
73
+ * matcher, held in closure) is already populated. Forwarding only the
74
+ * resolution hooks therefore preserves correct resolution while avoiding a
75
+ * second `buildStart`/`configureServer`/`config` lifecycle in the temp server.
76
+ *
77
+ * `enforce` and `applyToEnvironment` are preserved so ordering and per-environment
78
+ * gating match the real pipeline.
79
+ *
80
+ * `apply` is intentionally dropped. Vite filters plugins by `apply` against the
81
+ * command during config resolution, so the `config.plugins` we read here is
82
+ * already command-filtered by the main server (build: `apply: "build"` +
83
+ * unconditional; dev: `apply: "serve"` + unconditional). The discovery temp
84
+ * server is always created with `createServer` (`command === "serve"`), so a
85
+ * forwarded `apply: "build"` plugin would be filtered straight back out -- even
86
+ * during a production build, where build-only resolvers are exactly what
87
+ * static/prerender rendering needs. Since the source list is already correct for
88
+ * the current command, the forwarded copy must carry no `apply` gate.
89
+ */
90
+ function stripToResolutionHooks(p: Plugin): Plugin {
91
+ const stripped: Plugin = { name: p.name };
92
+ if ((p as any).enforce) (stripped as any).enforce = (p as any).enforce;
93
+ if ((p as any).applyToEnvironment)
94
+ (stripped as any).applyToEnvironment = (p as any).applyToEnvironment;
95
+ if ((p as any).resolveId) (stripped as any).resolveId = (p as any).resolveId;
96
+ if ((p as any).load) (stripped as any).load = (p as any).load;
97
+ return stripped;
98
+ }
99
+
100
+ /**
101
+ * Pick the user's resolution plugins from the resolved plugin list, denylist
102
+ * framework-owned plugins, keep only those with resolution hooks, and strip
103
+ * each to its resolution surface. Returns plugin objects safe to drop into the
104
+ * discovery temp server's `plugins` array.
105
+ */
106
+ export function selectForwardableResolvePlugins(
107
+ plugins: readonly Plugin[] | undefined,
108
+ ): Plugin[] {
109
+ if (!plugins) return [];
110
+ const forwarded: Plugin[] = [];
111
+ for (const p of plugins) {
112
+ const name = p?.name;
113
+ if (!name || isDenied(name)) continue;
114
+ if (!hasResolutionHooks(p)) continue;
115
+ forwarded.push(stripToResolutionHooks(p));
116
+ }
117
+ return forwarded;
118
+ }
119
+
120
+ /**
121
+ * The resolution-relevant slice of the user's resolved config that is plain
122
+ * data (no plugin re-execution): everything under `resolve` that influences
123
+ * how specifiers map to files, plus `define` and `esbuild` so transforms and
124
+ * compile-time constants match request time.
125
+ */
126
+ export interface ForwardedRunnerConfig {
127
+ resolve: UserConfig["resolve"];
128
+ define: UserConfig["define"];
129
+ esbuild: UserConfig["esbuild"];
130
+ }
131
+
132
+ /**
133
+ * Extract the data-only config slice to mirror into the discovery temp server.
134
+ * `alias` is included here so callers no longer need to thread it separately.
135
+ *
136
+ * `esbuild` keeps the user's options but always pins the RSC-required JSX
137
+ * runtime, since the temp server compiles the handler graph as React server
138
+ * components regardless of the user's app-level JSX config.
139
+ */
140
+ export function pickForwardedRunnerConfig(
141
+ config: ResolvedConfig,
142
+ ): ForwardedRunnerConfig {
143
+ const r = config.resolve ?? ({} as ResolvedConfig["resolve"]);
144
+ const resolve: NonNullable<UserConfig["resolve"]> = {};
145
+ if (r.alias !== undefined) resolve.alias = r.alias as any;
146
+ if (r.dedupe !== undefined) resolve.dedupe = r.dedupe;
147
+ if (r.conditions !== undefined) resolve.conditions = r.conditions;
148
+ if (r.mainFields !== undefined) resolve.mainFields = r.mainFields;
149
+ if (r.extensions !== undefined) resolve.extensions = r.extensions;
150
+ if (r.preserveSymlinks !== undefined)
151
+ resolve.preserveSymlinks = r.preserveSymlinks;
152
+
153
+ const userEsbuild = config.esbuild;
154
+ const esbuild: UserConfig["esbuild"] =
155
+ userEsbuild && typeof userEsbuild === "object"
156
+ ? { ...userEsbuild, jsx: "automatic", jsxImportSource: "react" }
157
+ : { jsx: "automatic", jsxImportSource: "react" };
158
+
159
+ return {
160
+ resolve,
161
+ define: config.define,
162
+ esbuild,
163
+ };
164
+ }