From 0da594e126e8e256fce05fbf7011b838993f38bd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 3 Apr 2026 12:06:21 +0800 Subject: [PATCH] fix: resolve island CSS paths and deploy compat flag mismatch Two fixes: 1. Island CSS build failure: the island client entry extracted CSS imports from page/layout files verbatim (e.g. `import "./global.css"`), but relative paths don't resolve from the virtual module. Now resolves relative specifiers to absolute paths (with normalizePath for Windows) via a shared rewriteCssImportForVirtualModule helper. Affects all four adapter packages (react, vue, svelte, solid). 2. Deploy compat flag mismatch: the deploy CLI read compatibility settings from the project root's wrangler.json (which typically doesn't exist for void apps), falling back to `nodejs_compat` with an old compat date. But the build output's wrangler.json (generated by @cloudflare/vite-plugin) specifies `nodejs_als` with the correct date. Now falls back to the build output's settings before using hardcoded defaults. Uses the original workerDir (not actualWorkerDir) in framework deploys so bundleEntry() temp dirs don't shadow the plugin-generated config. Co-Authored-By: Claude Opus 4.6 (1M context) --- packages/react/src/plugin-islands.ts | 3 +- packages/solid/src/plugin-islands.ts | 3 +- packages/svelte/src/plugin-islands.ts | 3 +- packages/void/src/cli/deploy.ts | 11 ++- packages/void/src/cli/wrangler.ts | 41 ++++++-- packages/void/src/pages/islands-plugin.ts | 18 +++- .../void/test/unit/island-css-rewrite.test.ts | 61 ++++++++++++ packages/void/test/unit/wrangler.test.ts | 96 +++++++++++++++++++ packages/vue/src/plugin-islands.ts | 3 +- 9 files changed, 224 insertions(+), 15 deletions(-) create mode 100644 packages/void/test/unit/island-css-rewrite.test.ts diff --git a/packages/react/src/plugin-islands.ts b/packages/react/src/plugin-islands.ts index 1c7fe440c..b91c731b5 100644 --- a/packages/react/src/plugin-islands.ts +++ b/packages/react/src/plugin-islands.ts @@ -11,6 +11,7 @@ import { rebuildIslandCssMap, computePagesNeedingClientJS, configureIslandCssServer, + rewriteCssImportForVirtualModule, ISLANDS_RENDERER_ID, ISLANDS_CLIENT_ENTRY_ID, } from "void/pages-islands-plugin"; @@ -375,7 +376,7 @@ function generateIslandClientEntry( cssImportRe.lastIndex = 0; let match; while ((match = cssImportRe.exec(content)) !== null) { - lines.push(match[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file)); } } } diff --git a/packages/solid/src/plugin-islands.ts b/packages/solid/src/plugin-islands.ts index c601aae7b..8d346a3e0 100644 --- a/packages/solid/src/plugin-islands.ts +++ b/packages/solid/src/plugin-islands.ts @@ -7,6 +7,7 @@ import { generateComponentManifest } from "./pages-manifest"; import { resolveSpecifier, needsIslandClientBundle, + rewriteCssImportForVirtualModule, fileHasCssImports, extractCssImports, rebuildIslandCssMap as rebuildIslandCssMapShared, @@ -397,7 +398,7 @@ function generateIslandClientEntry( cssImportRe.lastIndex = 0; let match; while ((match = cssImportRe.exec(content)) !== null) { - lines.push(match[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file)); } } } diff --git a/packages/svelte/src/plugin-islands.ts b/packages/svelte/src/plugin-islands.ts index 382e8c30e..ea0c8824e 100644 --- a/packages/svelte/src/plugin-islands.ts +++ b/packages/svelte/src/plugin-islands.ts @@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages"; import { resolveSpecifier, needsIslandClientBundle, + rewriteCssImportForVirtualModule, fileHasCssImports, extractCssImports, rebuildIslandCssMap as rebuildIslandCssMapShared, @@ -441,7 +442,7 @@ function generateIslandClientEntry( let cssMatch; cssImportRe.lastIndex = 0; while ((cssMatch = cssImportRe.exec(scriptContent)) !== null) { - lines.push(cssMatch[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(cssMatch[0], cssMatch[1], file)); } } } diff --git a/packages/void/src/cli/deploy.ts b/packages/void/src/cli/deploy.ts index 97d3eec63..2642ae2ad 100644 --- a/packages/void/src/cli/deploy.ts +++ b/packages/void/src/cli/deploy.ts @@ -515,8 +515,11 @@ async function runFrameworkDeploy( log.info("Wrapper generated"); } - // 8. Read wrangler compat settings (void.json > wrangler.jsonc > default) - const wranglerCompat = readWranglerCompat(root, voidConfig); + // 8. Read wrangler compat settings (void.json > wrangler.jsonc > build output > default) + // Use the original workerDir (not actualWorkerDir) because bundleEntry() may + // reassign actualWorkerDir to a temporary output — the plugin-generated + // wrangler.json with correct compat settings lives in the original build output. + const wranglerCompat = readWranglerCompat(root, voidConfig, workerDir); // 9. Resolve revalidate + prerender from void.json const revalidate = voidConfig.routing?.revalidate; @@ -837,8 +840,8 @@ async function runFullDeploy( persistDir: join(root, ".void"), }); - // 8. Read wrangler compat settings (void.json > wrangler.jsonc > default) - const wranglerCompat = readWranglerCompat(root, deployConfig); + // 8. Read wrangler compat settings (void.json > wrangler.jsonc > build output > default) + const wranglerCompat = readWranglerCompat(root, deployConfig, join(distDir, workerDirName)); // 9. Load .env vars for production (excludes .local files) const envVars = loadDeployEnv(root); diff --git a/packages/void/src/cli/wrangler.ts b/packages/void/src/cli/wrangler.ts index 3d7723e32..0022db32e 100644 --- a/packages/void/src/cli/wrangler.ts +++ b/packages/void/src/cli/wrangler.ts @@ -19,12 +19,20 @@ export interface WranglerCompat { /** * Read compatibility settings from `wrangler.jsonc`/`wrangler.json` and - * `void.json`, with priority: void.json > wrangler.jsonc > built-in default. + * `void.json`, with priority: void.json > wrangler.jsonc > build output > built-in default. * * Always returns a `compatibilityDate` (falls back to `DEFAULT_COMPAT_DATE`). + * + * @param buildOutputDir - Optional path to the build output directory (e.g. + * `dist/ssr`) which may contain a plugin-generated `wrangler.json` with the + * correct compat settings for the built worker. */ -export function readWranglerCompat(root: string, config?: VoidConfig): WranglerCompat { - // 1. Read wrangler.jsonc / wrangler.json +export function readWranglerCompat( + root: string, + config?: VoidConfig, + buildOutputDir?: string, +): WranglerCompat { + // 1. Read wrangler.jsonc / wrangler.json from project root let raw: string | undefined; for (const name of ["wrangler.jsonc", "wrangler.json"]) { try { @@ -55,16 +63,37 @@ export function readWranglerCompat(root: string, config?: VoidConfig): WranglerC } } + // 1b. Read build output wrangler.json as fallback (generated by @cloudflare/vite-plugin) + let buildDate: string | undefined; + let buildFlags: string[] | undefined; + if (buildOutputDir) { + try { + const buildRaw = readFileSync(join(buildOutputDir, "wrangler.json"), "utf-8"); + const buildParsed = JSON.parse(buildRaw); + if (typeof buildParsed.compatibility_date === "string") { + buildDate = buildParsed.compatibility_date; + } + if ( + Array.isArray(buildParsed.compatibility_flags) && + buildParsed.compatibility_flags.every((f: unknown) => typeof f === "string") + ) { + buildFlags = buildParsed.compatibility_flags; + } + } catch { + // no build output wrangler.json + } + } + // 2. Override with void.json worker fields (higher priority) const voidDate = config?.worker?.compatibility_date; const voidFlags = config?.worker?.compatibility_flags; - // 3. Merge: void.json > wrangler.jsonc > default + // 3. Merge: void.json > project wrangler.jsonc > build output > default const result: WranglerCompat = { - compatibilityDate: voidDate ?? wranglerDate ?? DEFAULT_COMPAT_DATE, + compatibilityDate: voidDate ?? wranglerDate ?? buildDate ?? DEFAULT_COMPAT_DATE, }; - const flags = voidFlags ?? wranglerFlags; + const flags = voidFlags ?? wranglerFlags ?? buildFlags; if (flags && flags.length > 0) { result.compatibilityFlags = flags; } diff --git a/packages/void/src/pages/islands-plugin.ts b/packages/void/src/pages/islands-plugin.ts index 67ea683fa..0b86e1ff1 100644 --- a/packages/void/src/pages/islands-plugin.ts +++ b/packages/void/src/pages/islands-plugin.ts @@ -1,6 +1,6 @@ import { normalizePath, type ViteDevServer } from "vite"; import { existsSync, readFileSync, readdirSync } from "node:fs"; -import { resolve } from "node:path"; +import { dirname, resolve } from "node:path"; import { resolveEffectiveLayoutChain } from "./scan"; import type { PageScanResult } from "./types"; @@ -75,6 +75,22 @@ export function fileHasCssImports(filePath: string): boolean { } } +/** + * Rewrite a matched CSS import line so relative specifiers resolve from the + * virtual module. Absolute and bare specifiers are kept as-is. + */ +export function rewriteCssImportForVirtualModule( + matchedLine: string, + specifier: string, + sourceFilePath: string, +): string { + if (specifier.startsWith(".")) { + const absPath = normalizePath(resolve(dirname(sourceFilePath), specifier)); + return `import ${JSON.stringify(absPath)};`; + } + return matchedLine.trim() + ";"; +} + /** Extract CSS import statements from a file and return them as @import rules. */ export function extractCssImports(filePath: string): string { const code = readFileSync(filePath, "utf-8"); diff --git a/packages/void/test/unit/island-css-rewrite.test.ts b/packages/void/test/unit/island-css-rewrite.test.ts new file mode 100644 index 000000000..bf3e4a038 --- /dev/null +++ b/packages/void/test/unit/island-css-rewrite.test.ts @@ -0,0 +1,61 @@ +import { describe, it, expect } from "vitest"; +import { rewriteCssImportForVirtualModule } from "../../src/pages/islands-plugin"; + +describe("rewriteCssImportForVirtualModule", () => { + it("rewrites relative CSS import to absolute path", () => { + const result = rewriteCssImportForVirtualModule( + 'import "./global.css"', + "./global.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "/project/pages/global.css";'); + }); + + it("rewrites parent-relative CSS import to absolute path", () => { + const result = rewriteCssImportForVirtualModule( + 'import "../styles/theme.css"', + "../styles/theme.css", + "/project/pages/blog/index.tsx", + ); + expect(result).toBe('import "/project/pages/styles/theme.css";'); + }); + + it("preserves bare specifiers (package imports)", () => { + const result = rewriteCssImportForVirtualModule( + 'import "@void/md/theme.css"', + "@void/md/theme.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "@void/md/theme.css";'); + }); + + it("preserves absolute specifiers", () => { + const result = rewriteCssImportForVirtualModule( + 'import "/absolute/path/style.css"', + "/absolute/path/style.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "/absolute/path/style.css";'); + }); + + it("trims whitespace from non-relative imports", () => { + const result = rewriteCssImportForVirtualModule( + ' import "some-lib/styles.css" ', + "some-lib/styles.css", + "/project/pages/layout.tsx", + ); + expect(result).toBe('import "some-lib/styles.css";'); + }); + + it("normalizes backslashes to forward slashes", () => { + // normalizePath ensures Windows-style paths become valid import specifiers + const result = rewriteCssImportForVirtualModule( + 'import "./card.css"', + "./card.css", + "/project/pages/components/deep/Card.tsx", + ); + // Should always use forward slashes regardless of OS + expect(result).not.toContain("\\"); + expect(result).toBe('import "/project/pages/components/deep/card.css";'); + }); +}); diff --git a/packages/void/test/unit/wrangler.test.ts b/packages/void/test/unit/wrangler.test.ts index 5b7f1c054..24f06d899 100644 --- a/packages/void/test/unit/wrangler.test.ts +++ b/packages/void/test/unit/wrangler.test.ts @@ -134,6 +134,102 @@ describe("readWranglerCompat", () => { setup("wrangler.json", '{ "compatibility_date": "2026-02" }'); expect(() => readWranglerCompat(tmpDir)).toThrow("YYYY-MM-DD format"); }); + + it("falls back to build output wrangler.json when no project config exists", () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ + compatibility_date: "2026-03-29", + compatibility_flags: ["nodejs_als"], + }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-03-29", + compatibilityFlags: ["nodejs_als"], + }); + }); + + it("project wrangler.json takes priority over build output", () => { + setup( + "wrangler.json", + JSON.stringify({ + compatibility_date: "2026-04-01", + compatibility_flags: ["nodejs_compat"], + }), + ); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ + compatibility_date: "2026-03-29", + compatibility_flags: ["nodejs_als"], + }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-04-01", + compatibilityFlags: ["nodejs_compat"], + }); + }); + + it("void.json takes priority over both project and build output", () => { + setup( + "wrangler.json", + JSON.stringify({ compatibility_date: "2026-01-01", compatibility_flags: ["flag_a"] }), + ); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ compatibility_date: "2026-02-01", compatibility_flags: ["flag_b"] }), + ); + const config: VoidConfig = { + worker: { compatibility_date: "2026-06-01", compatibility_flags: ["flag_c"] }, + }; + expect(readWranglerCompat(tmpDir, config, buildDir)).toEqual({ + compatibilityDate: "2026-06-01", + compatibilityFlags: ["flag_c"], + }); + }); + + it("build output date used when project has no date but build output does", () => { + setup("wrangler.json", '{ "name": "test" }'); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ compatibility_date: "2026-03-29" }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-03-29", + }); + }); + + it("build output flags used when project has no flags but build output does", () => { + setup("wrangler.json", '{ "compatibility_date": "2026-04-01" }'); + const buildDir = join(tmpDir, "dist", "ssr"); + mkdirSync(buildDir, { recursive: true }); + writeFileSync( + join(buildDir, "wrangler.json"), + JSON.stringify({ compatibility_flags: ["nodejs_als"] }), + ); + expect(readWranglerCompat(tmpDir, undefined, buildDir)).toEqual({ + compatibilityDate: "2026-04-01", + compatibilityFlags: ["nodejs_als"], + }); + }); + + it("ignores missing build output directory gracefully", () => { + rmSync(tmpDir, { recursive: true, force: true }); + mkdirSync(tmpDir, { recursive: true }); + expect(readWranglerCompat(tmpDir, undefined, join(tmpDir, "nonexistent"))).toEqual({ + compatibilityDate: DEFAULT_COMPAT_DATE, + }); + }); }); const noBindings: InferredBindings = { diff --git a/packages/vue/src/plugin-islands.ts b/packages/vue/src/plugin-islands.ts index 4fa53de03..b65ea45e2 100644 --- a/packages/vue/src/plugin-islands.ts +++ b/packages/vue/src/plugin-islands.ts @@ -6,6 +6,7 @@ import type { PageScanResult } from "void/pages"; import { resolveSpecifier, needsIslandClientBundle, + rewriteCssImportForVirtualModule, rebuildIslandCssMap as rebuildIslandCssMapShared, computePagesNeedingClientJS as computePagesNeedingClientJSShared, configureIslandCssServer, @@ -442,7 +443,7 @@ function generateIslandClientEntry( // Extract JS-style CSS imports (e.g. import "@void/md/theme.css") cssImportRe.lastIndex = 0; while ((match = cssImportRe.exec(content)) !== null) { - lines.push(match[0].trim() + ";"); + lines.push(rewriteCssImportForVirtualModule(match[0], match[1], file)); } } }