Skip to content

Commit 12a0f23

Browse files
authored
feat: Unbundle react (#653)
### Problem When `rwsdk` is installed as a dependency, two issues arise: 1. **Dependency Resolution:** The Vite build process resolved React packages (`react`, `react-dom`, `react-server-dom-webpack`) relative to the `rwsdk` package's location, not the user's project. This blocks users from getting new features and fixes for react deps 2. **Plugin Conflicts:** The SDK's Vite plugin unconditionally included `@vitejs/plugin-react`. This blocks users from configuring vite react plugin their own way (e.g. to enable react compiler). ### Solution This PR addresses these issues with the following changes: 1. **Prioritize User Project for React Dependencies** * `react`, `react-dom`, and `react-server-dom-webpack` are now defined as optional `peerDependencies`. This allows users to add their own React dependencies rather than relying on the specific versions pinned in the SDK. * These packages are also kept in the SDK's `dependencies` to ensure backward compatibility and to provide a fallback. * We first attempt resolving these packages from the user's project root. It only falls back to the versions bundled with the SDK if they are not found in the host project. * The resolution logic within the plugin has been centralized into a single function, `resolveReactImport`, to reduce code duplication and improve maintainability. * The starter templates (`minimal` and `standard`) have been updated to include these React packages as direct dependencies, ensuring new projects work correctly out of the box. 2. **Conditional Vite React Plugin** * We now check the host project's `package.json` to determine if `@vitejs/plugin-react` is already listed as a dependency. * If the plugin is found, the SDK will not add it again, preventing configuration conflicts. This behavior is consistent with how the Cloudflare plugin is managed. * A manual override, `includeReactPlugin`, has been added to the plugin options for cases where the default behavior needs to be changed. ## Tests done - [x] pnpm - [x] yarn - [x] npm - [x] usage of new react features (e.g. react compiler) For each of the package manager tests done, checked: * login in standard starter in dev and production * grepped in `node_modules/.vite` to confirm the project's react deps were used only and not the fallback deps we have in the sdk ## Links * Experiment project using react-compiler: https://github.com/redwoodjs/sdk-experiments/tree/main/react-compiler * Fixes #623
1 parent b0eaf64 commit 12a0f23

4 files changed

Lines changed: 183 additions & 70 deletions

File tree

sdk/package.json

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,21 @@
157157
"wrangler": "^4.20.5"
158158
},
159159
"peerDependencies": {
160-
"vite": "^6.2.6"
160+
"vite": "^6.2.6",
161+
"react": ">=19.2.0-0 || >=19.2.0",
162+
"react-dom": ">=19.2.0-0 || >=19.2.0",
163+
"react-server-dom-webpack": ">=19.2.0-0 || >=19.2.0"
164+
},
165+
"peerDependenciesMeta": {
166+
"react": {
167+
"optional": true
168+
},
169+
"react-dom": {
170+
"optional": true
171+
},
172+
"react-server-dom-webpack": {
173+
"optional": true
174+
}
161175
},
162176
"packageManager": "pnpm@9.14.4+sha512.c8180b3fbe4e4bca02c94234717896b5529740a6cbadf19fa78254270403ea2f27d4e1d46a08a0f56c89b63dc8ebfd3ee53326da720273794e6200fcf0d184ab",
163177
"devDependencies": {
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import { readFile } from "fs/promises";
2+
import path from "path";
3+
4+
export async function hasOwnReactVitePlugin({
5+
rootProjectDir,
6+
}: {
7+
rootProjectDir: string;
8+
}) {
9+
const packageJsonPath = path.join(rootProjectDir, "package.json");
10+
try {
11+
const packageJson = JSON.parse(await readFile(packageJsonPath, "utf-8"));
12+
return !!(
13+
packageJson.dependencies?.["@vitejs/plugin-react"] ||
14+
packageJson.devDependencies?.["@vitejs/plugin-react"]
15+
);
16+
} catch (error) {
17+
console.error("Error reading package.json:", error);
18+
return false;
19+
}
20+
}

sdk/src/vite/reactConditionsResolverPlugin.mts

Lines changed: 140 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { ensureAliasArray } from "./ensureAliasArray.mjs";
77

88
const log = debug("rwsdk:vite:react-conditions-resolver-plugin");
99

10+
const REACT_PREFIXES = ["react", "react-dom", "react-server-dom-webpack"];
11+
1012
export const ENV_REACT_IMPORTS = {
1113
worker: [
1214
"react",
@@ -49,40 +51,74 @@ export const ENV_RESOLVERS = {
4951
}),
5052
};
5153

52-
export const ENV_IMPORT_MAPPINGS = Object.fromEntries(
53-
Object.keys(ENV_RESOLVERS).map((env) => [
54-
env,
55-
resolveEnvImportMappings(env as keyof typeof ENV_RESOLVERS),
56-
]),
57-
);
58-
59-
function resolveEnvImportMappings(env: keyof typeof ENV_RESOLVERS) {
60-
process.env.VERBOSE &&
61-
log("Resolving environment import mappings for env=%s", env);
54+
function resolveReactImport(
55+
id: string,
56+
envName: keyof typeof ENV_RESOLVERS,
57+
projectRootDir: string,
58+
isReactImportKnown = false,
59+
): string | undefined {
60+
if (!isReactImportKnown) {
61+
const isReactImport = REACT_PREFIXES.some(
62+
(prefix) => id === prefix || id.startsWith(`${prefix}/`),
63+
);
64+
if (!isReactImport) {
65+
return undefined;
66+
}
67+
}
6268

63-
const mappings = new Map<string, string>();
64-
const reactImports = ENV_REACT_IMPORTS[env];
69+
let resolved: string | undefined;
6570

66-
for (const importRequest of reactImports) {
71+
try {
72+
resolved = ENV_RESOLVERS[envName](projectRootDir, id) || undefined;
6773
process.env.VERBOSE &&
68-
log("Resolving import request=%s for env=%s", importRequest, env);
69-
70-
let resolved: string | false = false;
71-
74+
log(
75+
"Successfully resolved %s to %s for env=%s from project root",
76+
id,
77+
resolved,
78+
envName,
79+
);
80+
} catch {
81+
process.env.VERBOSE &&
82+
log(
83+
"Failed to resolve %s for env=%s from project root, trying ROOT_DIR",
84+
id,
85+
envName,
86+
);
7287
try {
73-
resolved = ENV_RESOLVERS[env](ROOT_DIR, importRequest);
88+
resolved = ENV_RESOLVERS[envName](ROOT_DIR, id) || undefined;
7489
process.env.VERBOSE &&
7590
log(
76-
"Successfully resolved %s to %s for env=%s",
77-
importRequest,
91+
"Successfully resolved %s to %s for env=%s from rwsdk root",
92+
id,
7893
resolved,
79-
env,
94+
envName,
8095
);
8196
} catch {
8297
process.env.VERBOSE &&
83-
log("Failed to resolve %s for env=%s", importRequest, env);
98+
log("Failed to resolve %s for env=%s", id, envName);
8499
}
100+
}
85101

102+
return resolved;
103+
}
104+
105+
function resolveEnvImportMappings(
106+
env: keyof typeof ENV_RESOLVERS,
107+
projectRootDir: string,
108+
) {
109+
process.env.VERBOSE &&
110+
log("Resolving environment import mappings for env=%s", env);
111+
112+
const mappings = new Map<string, string>();
113+
const reactImports = ENV_REACT_IMPORTS[env];
114+
115+
for (const importRequest of reactImports) {
116+
const resolved = resolveReactImport(
117+
importRequest,
118+
env,
119+
projectRootDir,
120+
true,
121+
);
86122
if (resolved) {
87123
mappings.set(importRequest, resolved);
88124
log("Added mapping for %s -> %s in env=%s", importRequest, resolved, env);
@@ -97,57 +133,80 @@ function resolveEnvImportMappings(env: keyof typeof ENV_RESOLVERS) {
97133
return mappings;
98134
}
99135

100-
function createEsbuildResolverPlugin(envName: string) {
101-
const mappings = ENV_IMPORT_MAPPINGS[envName];
102-
103-
if (!mappings) {
104-
return null;
105-
}
136+
export const reactConditionsResolverPlugin = ({
137+
projectRootDir,
138+
}: {
139+
projectRootDir: string;
140+
}): Plugin[] => {
141+
log("Initializing react conditions resolver plugin");
142+
let isBuild = false;
106143

107-
return {
108-
name: `rwsdk:react-conditions-resolver-esbuild-${envName}`,
109-
setup(build: any) {
110-
build.onResolve({ filter: /.*/ }, (args: any) => {
111-
process.env.VERBOSE &&
112-
log(
113-
"ESBuild resolving %s for env=%s, args=%O",
114-
args.path,
115-
envName,
116-
args,
117-
);
144+
const ENV_IMPORT_MAPPINGS = Object.fromEntries(
145+
Object.keys(ENV_RESOLVERS).map((env) => [
146+
env,
147+
resolveEnvImportMappings(
148+
env as keyof typeof ENV_RESOLVERS,
149+
projectRootDir,
150+
),
151+
]),
152+
);
118153

119-
const resolved = mappings.get(args.path);
154+
function createEsbuildResolverPlugin(
155+
envName: string,
156+
mappings: Map<string, string>,
157+
) {
158+
if (!mappings) {
159+
return null;
160+
}
120161

121-
if (resolved && args.importer !== "") {
162+
return {
163+
name: `rwsdk:react-conditions-resolver-esbuild-${envName}`,
164+
setup(build: any) {
165+
build.onResolve({ filter: /.*/ }, (args: any) => {
122166
process.env.VERBOSE &&
123167
log(
124-
"ESBuild resolving %s -> %s for env=%s",
168+
"ESBuild resolving %s for env=%s, args=%O",
125169
args.path,
126-
resolved,
127170
envName,
171+
args,
128172
);
129-
if (args.path === "react-server-dom-webpack/client.edge") {
130-
return;
131-
}
132-
return {
133-
path: resolved,
134-
};
135-
} else {
136-
process.env.VERBOSE &&
137-
log(
138-
"ESBuild no resolution found for %s for env=%s",
173+
174+
let resolved: string | undefined = mappings.get(args.path);
175+
176+
if (!resolved) {
177+
resolved = resolveReactImport(
139178
args.path,
140-
envName,
179+
envName as keyof typeof ENV_RESOLVERS,
180+
projectRootDir,
141181
);
142-
}
143-
});
144-
},
145-
};
146-
}
182+
}
147183

148-
export const reactConditionsResolverPlugin = (): Plugin[] => {
149-
log("Initializing react conditions resolver plugin");
150-
let isBuild = false;
184+
if (resolved && args.importer !== "") {
185+
process.env.VERBOSE &&
186+
log(
187+
"ESBuild resolving %s -> %s for env=%s",
188+
args.path,
189+
resolved,
190+
envName,
191+
);
192+
if (args.path === "react-server-dom-webpack/client.edge") {
193+
return;
194+
}
195+
return {
196+
path: resolved,
197+
};
198+
} else {
199+
process.env.VERBOSE &&
200+
log(
201+
"ESBuild no resolution found for %s for env=%s",
202+
args.path,
203+
envName,
204+
);
205+
}
206+
});
207+
},
208+
};
209+
}
151210

152211
return [
153212
{
@@ -178,7 +237,10 @@ export const reactConditionsResolverPlugin = (): Plugin[] => {
178237

179238
const envConfig = (config as any).environments[envName];
180239

181-
const esbuildPlugin = createEsbuildResolverPlugin(envName);
240+
const esbuildPlugin = createEsbuildResolverPlugin(
241+
envName,
242+
mappings as Map<string, string>,
243+
);
182244
if (esbuildPlugin && mappings) {
183245
envConfig.optimizeDeps ??= {};
184246
envConfig.optimizeDeps.esbuildOptions ??= {};
@@ -200,7 +262,7 @@ export const reactConditionsResolverPlugin = (): Plugin[] => {
200262

201263
const aliases = ensureAliasArray(envConfig);
202264

203-
for (const [find, replacement] of mappings) {
265+
for (const [find, replacement] of mappings as Map<string, string>) {
204266
const findRegex = new RegExp(
205267
`^${find.replace(/[-\/\\^$*+?.()|[\]{}]/g, "\\$&")}$`,
206268
);
@@ -211,7 +273,7 @@ export const reactConditionsResolverPlugin = (): Plugin[] => {
211273
log(
212274
"Environment %s configured with %d aliases and %d optimizeDeps includes",
213275
envName,
214-
mappings.size,
276+
(mappings as Map<string, string>).size,
215277
reactImports.length,
216278
);
217279
}
@@ -242,15 +304,26 @@ export const reactConditionsResolverPlugin = (): Plugin[] => {
242304
importer,
243305
);
244306

245-
const mappings = ENV_IMPORT_MAPPINGS[envName];
307+
const mappings =
308+
ENV_IMPORT_MAPPINGS[envName as keyof typeof ENV_IMPORT_MAPPINGS];
246309

247310
if (!mappings) {
248311
process.env.VERBOSE &&
249312
log("No mappings found for environment: %s", envName);
250313
return;
251314
}
252315

253-
const resolved = mappings.get(id);
316+
let resolved: string | undefined = (
317+
mappings as Map<string, string>
318+
).get(id);
319+
320+
if (!resolved) {
321+
resolved = resolveReactImport(
322+
id,
323+
envName as keyof typeof ENV_RESOLVERS,
324+
projectRootDir,
325+
);
326+
}
254327

255328
if (resolved) {
256329
log("Resolved %s -> %s for env=%s", id, resolved, envName);

sdk/src/vite/redwoodPlugin.mts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { cloudflare } from "@cloudflare/vite-plugin";
55

66
import { devServerConstantPlugin } from "./devServerConstant.mjs";
77
import { hasOwnCloudflareVitePlugin } from "./hasOwnCloudflareVitePlugin.mjs";
8+
import { hasOwnReactVitePlugin } from "./hasOwnReactVitePlugin.mjs";
89

910
import reactPlugin from "@vitejs/plugin-react";
1011
import tsconfigPaths from "vite-tsconfig-paths";
@@ -32,6 +33,7 @@ export type RedwoodPluginOptions = {
3233
silent?: boolean;
3334
rootDir?: string;
3435
includeCloudflarePlugin?: boolean;
36+
includeReactPlugin?: boolean;
3537
configPath?: string;
3638
entry?: {
3739
client?: string | string[];
@@ -80,6 +82,10 @@ export const redwoodPlugin = async (
8082
options.includeCloudflarePlugin ??
8183
!(await hasOwnCloudflareVitePlugin({ rootProjectDir: projectRootDir }));
8284

85+
const shouldIncludeReactPlugin =
86+
options.includeReactPlugin ??
87+
!(await hasOwnReactVitePlugin({ rootProjectDir: projectRootDir }));
88+
8389
// context(justinvdm, 31 Mar 2025): We assume that if there is no .wrangler directory,
8490
// then this is fresh install, and we run `npm run dev:init` here.
8591
if (
@@ -112,7 +118,7 @@ export const redwoodPlugin = async (
112118
serverFiles,
113119
projectRootDir,
114120
}),
115-
reactConditionsResolverPlugin(),
121+
reactConditionsResolverPlugin({ projectRootDir }),
116122
tsconfigPaths({ root: projectRootDir }),
117123
shouldIncludeCloudflarePlugin
118124
? cloudflare({
@@ -127,7 +133,7 @@ export const redwoodPlugin = async (
127133
viteEnvironment: { name: "worker" },
128134
workerEntryPathname,
129135
}),
130-
reactPlugin(),
136+
shouldIncludeReactPlugin ? reactPlugin() : [],
131137
directivesPlugin({
132138
projectRootDir,
133139
clientFiles,

0 commit comments

Comments
 (0)