Skip to content

Commit ce60d48

Browse files
Brooooooklynclaude
andauthored
fix: resolve island CSS paths and deploy compat flag mismatch (#71)
## Control flow analysis ### Build time (works) 1. User runs `void deploy` from `playground/react-pages/` 2. The CLI shells out to `vite build`, which invokes `@cloudflare/vite-plugin` 3. The plugin builds two environments: **client** (browser JS) and **ssr** (worker bundle) 4. During the SSR build, the virtual renderer module is generated: ```js import React from "react"; import { renderToString } from "react-dom/server"; import { SharedContext, ErrorsContext, RouterContext } from "@void/react"; ``` 5. `@void/react` is a pre-built npm package whose dist imports `{ createContext, useContext } from "react"` 6. Rolldown bundles everything into a module graph under `dist/ssr/` -- `index.js` + `assets/*.js` chunks 7. React ends up in a single CJS-wrapped chunk (`react-BVLPaBiV.js`) shared by all consumers. **No dual React.** 8. `@cloudflare/vite-plugin` writes `dist/ssr/wrangler.json` with `compatibility_flags: ["nodejs_als"]` and `compatibility_date: "2026-03-29"` ### Deploy packaging (the first bug) 9. The CLI calls `readWranglerCompat(root, config)` to get compat settings for the deploy manifest 10. It looks for `wrangler.json` at the **project root** (`playground/react-pages/`) -- **does not exist** 11. It looks for `worker.compatibility_flags` in `void.json` -- **not set** 12. Falls back to hardcoded defaults: `compatibility_date: "2026-02-12"`, `compatibilityFlags: undefined` 13. The build output's `dist/ssr/wrangler.json` (which has the correct settings) is **never read** 14. The CLI packages all worker files from `dist/ssr/` into a FormData with the manifest 15. The manifest has `compatibilityFlags: undefined` ### Server-side deploy (the second bug) 16. The platform API (`packages/api/src/routes/deploy.ts`) receives the FormData 17. It builds WfP upload metadata: ```js compatibility_flags: manifest.compatibilityFlags ?? ["nodejs_compat"] ``` 18. Since `manifest.compatibilityFlags` is `undefined`, the fallback `["nodejs_compat"]` kicks in 19. The worker is uploaded to Workers for Platforms with: - `compatibility_flags: ["nodejs_compat"]` (wrong -- should be `["nodejs_als"]`) - `compatibility_date: "2026-02-12"` (wrong -- should be `"2026-03-29"`) ### Runtime (the crash) 20. A request hits `https://void-test-it.void.app/` 21. The dispatch worker (`packages/dispatch`) routes it to the user's WfP worker 22. The worker's `index.js` entry handles the request via Hono 23. The route handler calls `handlePageGet(...)` which calls `renderPage(pageObj, assetTags)` 24. The virtual renderer builds the React element tree: ```js element = React.createElement(SharedContext.Provider, { value: shared }, React.createElement(ErrorsContext.Provider, { value: errors }, React.createElement(RouterContext.Provider, { value: null }, React.createElement(Layout, null, React.createElement(HomePage, props))))) ``` 25. It calls `renderToString(element)` -- this enters React's server renderer 26. React's server renderer sets `ReactSharedInternals.H = serverDispatcher` and begins rendering 27. The `Layout` component runs, calls `useShared()` -> `useContext(SharedContext)` 28. `useContext` internally does `ReactSharedInternals.H.useContext(context)` 29. **Under `nodejs_compat` at compat date `2026-02-12` on WfP**, `ReactSharedInternals.H` is `null` at this point -- the dispatcher set by `renderToString` is not visible to the `useContext` call 30. **`null.useContext`** -> `TypeError: Cannot read properties of null (reading 'useContext')` 31. The worker returns 500 ### Why it works locally - **`vite dev`**: No bundling. `@void/react` is excluded from dep optimization (`optimizeDeps.exclude`), so all code shares the same live React module. `nodejs_als` is used by the Cloudflare plugin's dev server. - **`wrangler dev` on build output**: Reads `dist/ssr/wrangler.json` directly, which has the correct `nodejs_als` flag and `2026-03-29` date. The local workerd runtime evaluates modules correctly under these settings. ### The fix The deploy pipeline now reads the build output's `wrangler.json` as a fallback source for compat settings (priority: `void.json` > project `wrangler.json` > build output `wrangler.json` > hardcoded default). This ensures the deployed worker runs with the same flags the build was designed for. Also fixes a separate build failure where island CSS imports with relative paths (e.g. `import "./global.css"` in a layout) were copied verbatim into the virtual islands-client entry module, where they could not resolve. Now rewrites them to absolute paths. Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8bbdfe2 commit ce60d48

9 files changed

Lines changed: 224 additions & 15 deletions

File tree

‎packages/react/src/plugin-islands.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
rebuildIslandCssMap,
1212
computePagesNeedingClientJS,
1313
configureIslandCssServer,
14+
rewriteCssImportForVirtualModule,
1415
ISLANDS_RENDERER_ID,
1516
ISLANDS_CLIENT_ENTRY_ID,
1617
} from "void/pages-islands-plugin";
@@ -375,7 +376,7 @@ function generateIslandClientEntry(
375376
cssImportRe.lastIndex = 0;
376377
let match;
377378
while ((match = cssImportRe.exec(content)) !== null) {
378-
lines.push(match[0].trim() + ";");
379+
lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file));
379380
}
380381
}
381382
}

‎packages/solid/src/plugin-islands.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { generateComponentManifest } from "./pages-manifest";
77
import {
88
resolveSpecifier,
99
needsIslandClientBundle,
10+
rewriteCssImportForVirtualModule,
1011
fileHasCssImports,
1112
extractCssImports,
1213
rebuildIslandCssMap as rebuildIslandCssMapShared,
@@ -397,7 +398,7 @@ function generateIslandClientEntry(
397398
cssImportRe.lastIndex = 0;
398399
let match;
399400
while ((match = cssImportRe.exec(content)) !== null) {
400-
lines.push(match[0].trim() + ";");
401+
lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file));
401402
}
402403
}
403404
}

‎packages/svelte/src/plugin-islands.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages";
66
import {
77
resolveSpecifier,
88
needsIslandClientBundle,
9+
rewriteCssImportForVirtualModule,
910
fileHasCssImports,
1011
extractCssImports,
1112
rebuildIslandCssMap as rebuildIslandCssMapShared,
@@ -441,7 +442,7 @@ function generateIslandClientEntry(
441442
let cssMatch;
442443
cssImportRe.lastIndex = 0;
443444
while ((cssMatch = cssImportRe.exec(scriptContent)) !== null) {
444-
lines.push(cssMatch[0].trim() + ";");
445+
lines.push(rewriteCssImportForVirtualModule(cssMatch[0], cssMatch[1], file));
445446
}
446447
}
447448
}

‎packages/void/src/cli/deploy.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -515,8 +515,11 @@ async function runFrameworkDeploy(
515515
log.info("Wrapper generated");
516516
}
517517

518-
// 8. Read wrangler compat settings (void.json > wrangler.jsonc > default)
519-
const wranglerCompat = readWranglerCompat(root, voidConfig);
518+
// 8. Read wrangler compat settings (void.json > wrangler.jsonc > build output > default)
519+
// Use the original workerDir (not actualWorkerDir) because bundleEntry() may
520+
// reassign actualWorkerDir to a temporary output — the plugin-generated
521+
// wrangler.json with correct compat settings lives in the original build output.
522+
const wranglerCompat = readWranglerCompat(root, voidConfig, workerDir);
520523

521524
// 9. Resolve revalidate + prerender from void.json
522525
const revalidate = voidConfig.routing?.revalidate;
@@ -837,8 +840,8 @@ async function runFullDeploy(
837840
persistDir: join(root, ".void"),
838841
});
839842

840-
// 8. Read wrangler compat settings (void.json > wrangler.jsonc > default)
841-
const wranglerCompat = readWranglerCompat(root, deployConfig);
843+
// 8. Read wrangler compat settings (void.json > wrangler.jsonc > build output > default)
844+
const wranglerCompat = readWranglerCompat(root, deployConfig, join(distDir, workerDirName));
842845

843846
// 9. Load .env vars for production (excludes .local files)
844847
const envVars = loadDeployEnv(root);

‎packages/void/src/cli/wrangler.ts‎

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,20 @@ export interface WranglerCompat {
1919

2020
/**
2121
* Read compatibility settings from `wrangler.jsonc`/`wrangler.json` and
22-
* `void.json`, with priority: void.json > wrangler.jsonc > built-in default.
22+
* `void.json`, with priority: void.json > wrangler.jsonc > build output > built-in default.
2323
*
2424
* Always returns a `compatibilityDate` (falls back to `DEFAULT_COMPAT_DATE`).
25+
*
26+
* @param buildOutputDir - Optional path to the build output directory (e.g.
27+
* `dist/ssr`) which may contain a plugin-generated `wrangler.json` with the
28+
* correct compat settings for the built worker.
2529
*/
26-
export function readWranglerCompat(root: string, config?: VoidConfig): WranglerCompat {
27-
// 1. Read wrangler.jsonc / wrangler.json
30+
export function readWranglerCompat(
31+
root: string,
32+
config?: VoidConfig,
33+
buildOutputDir?: string,
34+
): WranglerCompat {
35+
// 1. Read wrangler.jsonc / wrangler.json from project root
2836
let raw: string | undefined;
2937
for (const name of ["wrangler.jsonc", "wrangler.json"]) {
3038
try {
@@ -55,16 +63,37 @@ export function readWranglerCompat(root: string, config?: VoidConfig): WranglerC
5563
}
5664
}
5765

66+
// 1b. Read build output wrangler.json as fallback (generated by @cloudflare/vite-plugin)
67+
let buildDate: string | undefined;
68+
let buildFlags: string[] | undefined;
69+
if (buildOutputDir) {
70+
try {
71+
const buildRaw = readFileSync(join(buildOutputDir, "wrangler.json"), "utf-8");
72+
const buildParsed = JSON.parse(buildRaw);
73+
if (typeof buildParsed.compatibility_date === "string") {
74+
buildDate = buildParsed.compatibility_date;
75+
}
76+
if (
77+
Array.isArray(buildParsed.compatibility_flags) &&
78+
buildParsed.compatibility_flags.every((f: unknown) => typeof f === "string")
79+
) {
80+
buildFlags = buildParsed.compatibility_flags;
81+
}
82+
} catch {
83+
// no build output wrangler.json
84+
}
85+
}
86+
5887
// 2. Override with void.json worker fields (higher priority)
5988
const voidDate = config?.worker?.compatibility_date;
6089
const voidFlags = config?.worker?.compatibility_flags;
6190

62-
// 3. Merge: void.json > wrangler.jsonc > default
91+
// 3. Merge: void.json > project wrangler.jsonc > build output > default
6392
const result: WranglerCompat = {
64-
compatibilityDate: voidDate ?? wranglerDate ?? DEFAULT_COMPAT_DATE,
93+
compatibilityDate: voidDate ?? wranglerDate ?? buildDate ?? DEFAULT_COMPAT_DATE,
6594
};
6695

67-
const flags = voidFlags ?? wranglerFlags;
96+
const flags = voidFlags ?? wranglerFlags ?? buildFlags;
6897
if (flags && flags.length > 0) {
6998
result.compatibilityFlags = flags;
7099
}

‎packages/void/src/pages/islands-plugin.ts‎

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { normalizePath, type ViteDevServer } from "vite";
22
import { existsSync, readFileSync, readdirSync } from "node:fs";
3-
import { resolve } from "node:path";
3+
import { dirname, resolve } from "node:path";
44
import { resolveEffectiveLayoutChain } from "./scan";
55
import type { PageScanResult } from "./types";
66

@@ -75,6 +75,22 @@ export function fileHasCssImports(filePath: string): boolean {
7575
}
7676
}
7777

78+
/**
79+
* Rewrite a matched CSS import line so relative specifiers resolve from the
80+
* virtual module. Absolute and bare specifiers are kept as-is.
81+
*/
82+
export function rewriteCssImportForVirtualModule(
83+
matchedLine: string,
84+
specifier: string,
85+
sourceFilePath: string,
86+
): string {
87+
if (specifier.startsWith(".")) {
88+
const absPath = normalizePath(resolve(dirname(sourceFilePath), specifier));
89+
return `import ${JSON.stringify(absPath)};`;
90+
}
91+
return matchedLine.trim() + ";";
92+
}
93+
7894
/** Extract CSS import statements from a file and return them as @import rules. */
7995
export function extractCssImports(filePath: string): string {
8096
const code = readFileSync(filePath, "utf-8");
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import { describe, it, expect } from "vitest";
2+
import { rewriteCssImportForVirtualModule } from "../../src/pages/islands-plugin";
3+
4+
describe("rewriteCssImportForVirtualModule", () => {
5+
it("rewrites relative CSS import to absolute path", () => {
6+
const result = rewriteCssImportForVirtualModule(
7+
'import "./global.css"',
8+
"./global.css",
9+
"/project/pages/layout.tsx",
10+
);
11+
expect(result).toBe('import "/project/pages/global.css";');
12+
});
13+
14+
it("rewrites parent-relative CSS import to absolute path", () => {
15+
const result = rewriteCssImportForVirtualModule(
16+
'import "../styles/theme.css"',
17+
"../styles/theme.css",
18+
"/project/pages/blog/index.tsx",
19+
);
20+
expect(result).toBe('import "/project/pages/styles/theme.css";');
21+
});
22+
23+
it("preserves bare specifiers (package imports)", () => {
24+
const result = rewriteCssImportForVirtualModule(
25+
'import "@void/md/theme.css"',
26+
"@void/md/theme.css",
27+
"/project/pages/layout.tsx",
28+
);
29+
expect(result).toBe('import "@void/md/theme.css";');
30+
});
31+
32+
it("preserves absolute specifiers", () => {
33+
const result = rewriteCssImportForVirtualModule(
34+
'import "/absolute/path/style.css"',
35+
"/absolute/path/style.css",
36+
"/project/pages/layout.tsx",
37+
);
38+
expect(result).toBe('import "/absolute/path/style.css";');
39+
});
40+
41+
it("trims whitespace from non-relative imports", () => {
42+
const result = rewriteCssImportForVirtualModule(
43+
' import "some-lib/styles.css" ',
44+
"some-lib/styles.css",
45+
"/project/pages/layout.tsx",
46+
);
47+
expect(result).toBe('import "some-lib/styles.css";');
48+
});
49+
50+
it("normalizes backslashes to forward slashes", () => {
51+
// normalizePath ensures Windows-style paths become valid import specifiers
52+
const result = rewriteCssImportForVirtualModule(
53+
'import "./card.css"',
54+
"./card.css",
55+
"/project/pages/components/deep/Card.tsx",
56+
);
57+
// Should always use forward slashes regardless of OS
58+
expect(result).not.toContain("\\");
59+
expect(result).toBe('import "/project/pages/components/deep/card.css";');
60+
});
61+
});

‎packages/void/test/unit/wrangler.test.ts‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,102 @@ describe("readWranglerCompat", () => {
134134
setup("wrangler.json", '{ "compatibility_date": "2026-02" }');
135135
expect(() => readWranglerCompat(tmpDir)).toThrow("YYYY-MM-DD format");
136136
});
137+
138+
it("falls back to build output wrangler.json when no project config exists", () => {
139+
rmSync(tmpDir, { recursive: true, force: true });
140+
mkdirSync(tmpDir, { recursive: true });
141+
const buildDir = join(tmpDir, "dist", "ssr");
142+
mkdirSync(buildDir, { recursive: true });
143+
writeFileSync(
144+
join(buildDir, "wrangler.json"),
145+
JSON.stringify({
146+
compatibility_date: "2026-03-29",
147+
compatibility_flags: ["nodejs_als"],
148+
}),
149+
);
150+
expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({
151+
compatibilityDate: "2026-03-29",
152+
compatibilityFlags: ["nodejs_als"],
153+
});
154+
});
155+
156+
it("project wrangler.json takes priority over build output", () => {
157+
setup(
158+
"wrangler.json",
159+
JSON.stringify({
160+
compatibility_date: "2026-04-01",
161+
compatibility_flags: ["nodejs_compat"],
162+
}),
163+
);
164+
const buildDir = join(tmpDir, "dist", "ssr");
165+
mkdirSync(buildDir, { recursive: true });
166+
writeFileSync(
167+
join(buildDir, "wrangler.json"),
168+
JSON.stringify({
169+
compatibility_date: "2026-03-29",
170+
compatibility_flags: ["nodejs_als"],
171+
}),
172+
);
173+
expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({
174+
compatibilityDate: "2026-04-01",
175+
compatibilityFlags: ["nodejs_compat"],
176+
});
177+
});
178+
179+
it("void.json takes priority over both project and build output", () => {
180+
setup(
181+
"wrangler.json",
182+
JSON.stringify({ compatibility_date: "2026-01-01", compatibility_flags: ["flag_a"] }),
183+
);
184+
const buildDir = join(tmpDir, "dist", "ssr");
185+
mkdirSync(buildDir, { recursive: true });
186+
writeFileSync(
187+
join(buildDir, "wrangler.json"),
188+
JSON.stringify({ compatibility_date: "2026-02-01", compatibility_flags: ["flag_b"] }),
189+
);
190+
const config: VoidConfig = {
191+
worker: { compatibility_date: "2026-06-01", compatibility_flags: ["flag_c"] },
192+
};
193+
expect(readWranglerCompat(tmpDir, config, buildDir)).toEqual({
194+
compatibilityDate: "2026-06-01",
195+
compatibilityFlags: ["flag_c"],
196+
});
197+
});
198+
199+
it("build output date used when project has no date but build output does", () => {
200+
setup("wrangler.json", '{ "name": "test" }');
201+
const buildDir = join(tmpDir, "dist", "ssr");
202+
mkdirSync(buildDir, { recursive: true });
203+
writeFileSync(
204+
join(buildDir, "wrangler.json"),
205+
JSON.stringify({ compatibility_date: "2026-03-29" }),
206+
);
207+
expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({
208+
compatibilityDate: "2026-03-29",
209+
});
210+
});
211+
212+
it("build output flags used when project has no flags but build output does", () => {
213+
setup("wrangler.json", '{ "compatibility_date": "2026-04-01" }');
214+
const buildDir = join(tmpDir, "dist", "ssr");
215+
mkdirSync(buildDir, { recursive: true });
216+
writeFileSync(
217+
join(buildDir, "wrangler.json"),
218+
JSON.stringify({ compatibility_flags: ["nodejs_als"] }),
219+
);
220+
expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({
221+
compatibilityDate: "2026-04-01",
222+
compatibilityFlags: ["nodejs_als"],
223+
});
224+
});
225+
226+
it("ignores missing build output directory gracefully", () => {
227+
rmSync(tmpDir, { recursive: true, force: true });
228+
mkdirSync(tmpDir, { recursive: true });
229+
expect(readWranglerCompat(tmpDir, undefined, join(tmpDir, "nonexistent"))).toEqual({
230+
compatibilityDate: DEFAULT_COMPAT_DATE,
231+
});
232+
});
137233
});
138234

139235
const noBindings: InferredBindings = {

‎packages/vue/src/plugin-islands.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages";
66
import {
77
resolveSpecifier,
88
needsIslandClientBundle,
9+
rewriteCssImportForVirtualModule,
910
rebuildIslandCssMap as rebuildIslandCssMapShared,
1011
computePagesNeedingClientJS as computePagesNeedingClientJSShared,
1112
configureIslandCssServer,
@@ -442,7 +443,7 @@ function generateIslandClientEntry(
442443
// Extract JS-style CSS imports (e.g. import "@void/md/theme.css")
443444
cssImportRe.lastIndex = 0;
444445
while ((match = cssImportRe.exec(content)) !== null) {
445-
lines.push(match[0].trim() + ";");
446+
lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file));
446447
}
447448
}
448449
}

0 commit comments

Comments
 (0)