From 751addf03795e31d2b15746623bef9f8f5af6bed Mon Sep 17 00:00:00 2001 From: Ryan Atkinson Date: Thu, 4 Jun 2026 11:50:30 -0400 Subject: [PATCH] wip --- .changeset/yellow-bushes-care.md | 5 ++ package-lock.json | 36 ++++----- package.json | 4 +- src/lib/vite_plugin_fuz_css.ts | 123 ++++++++++++++----------------- 4 files changed, 75 insertions(+), 93 deletions(-) create mode 100644 .changeset/yellow-bushes-care.md diff --git a/.changeset/yellow-bushes-care.md b/.changeset/yellow-bushes-care.md new file mode 100644 index 00000000..ea246373 --- /dev/null +++ b/.changeset/yellow-bushes-care.md @@ -0,0 +1,5 @@ +--- +'@fuzdev/fuz_css': minor +--- + +fix: vite plugin FOUC in dev diff --git a/package-lock.json b/package-lock.json index ab25c926..d6f22def 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,7 +14,7 @@ "@fuzdev/fuz_code": "^0.45.1", "@fuzdev/fuz_ui": "^0.198.1", "@fuzdev/fuz_util": "^0.63.2", - "@fuzdev/gro": "^0.201.1", + "@fuzdev/gro": "^0.202.0", "@ryanatkn/eslint-config": "^0.12.1", "@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/adapter-static": "^3.0.10", @@ -33,7 +33,7 @@ "prettier-plugin-svelte": "^3.4.1", "svelte": "^5.55.5", "svelte-check": "^4.4.6", - "svelte-docinfo": "^0.3.0", + "svelte-docinfo": "^0.4.0", "svelte2tsx": "^0.7.51", "tslib": "^2.8.1", "typescript": "^5.9.3", @@ -966,9 +966,9 @@ } }, "node_modules/@fuzdev/gro": { - "version": "0.201.1", - "resolved": "https://registry.npmjs.org/@fuzdev/gro/-/gro-0.201.1.tgz", - "integrity": "sha512-ALOQ70KPxjgStLeXeAWkWXeIQFOMLk+LThncmHrLI84JIwaq8woDMFyZs4JgPZTRKEHEk8LqbNx6wKJ2iKhGZg==", + "version": "0.202.0", + "resolved": "https://registry.npmjs.org/@fuzdev/gro/-/gro-0.202.0.tgz", + "integrity": "sha512-ezfQRI+zfFNNvMyEo9Jmy3L/aSvlFAq4+jKy/bCrc4raMVtGpueZO18kSnOtMykf+xw6ARS3NdYQassh7sDOWw==", "dev": true, "license": "MIT", "dependencies": { @@ -979,8 +979,7 @@ "prettier": "^3.7.4", "prettier-plugin-svelte": "^3.5.1", "ts-blank-space": "^0.6.2", - "tslib": "^2.8.1", - "zod": "^4.3.6" + "tslib": "^2.8.1" }, "bin": { "gro": "dist/gro.js" @@ -1002,7 +1001,8 @@ "svelte": "^5", "svelte-docinfo": ">=0.2.0", "typescript": "^5", - "vitest": "^3 || ^4" + "vitest": "^3 || ^4", + "zod": "^4" }, "peerDependenciesMeta": { "@sveltejs/kit": { @@ -3995,9 +3995,9 @@ } }, "node_modules/svelte-docinfo": { - "version": "0.3.0", - "resolved": "https://registry.npmjs.org/svelte-docinfo/-/svelte-docinfo-0.3.0.tgz", - "integrity": "sha512-hNdrtkIrsakMbq+9aylgetBfIyxKCUMWEt/jzFN8/Ldat5ZduVNifmZInk0VTcHbjpuMKgrmLBgqolPYinF00A==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/svelte-docinfo/-/svelte-docinfo-0.4.0.tgz", + "integrity": "sha512-4hEomWcznpOBjPyNUrY/KJXNwQUK16XEg6g6kiE3AydINa7D9kRY6uSag6P04Q9cO+DYTRtkwZqJgfisVBZBdQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4006,23 +4006,15 @@ "es-module-lexer": "^2.0.0", "picomatch": "^4.0.4", "tinyglobby": "^0.2.15", - "typescript": "^5.9.3", - "zod": "^4.3.6" + "typescript": "^5.9.3" }, "bin": { "svelte-docinfo": "dist/main.js" }, "peerDependencies": { "svelte": "^5.0.0", - "svelte2tsx": "^0.7.30" - }, - "peerDependenciesMeta": { - "svelte": { - "optional": true - }, - "svelte2tsx": { - "optional": true - } + "svelte2tsx": "^0.7.30", + "zod": "^4" } }, "node_modules/svelte-docinfo/node_modules/es-module-lexer": { diff --git a/package.json b/package.json index bd5c26c3..920280bd 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "@fuzdev/fuz_code": "^0.45.1", "@fuzdev/fuz_ui": "^0.198.1", "@fuzdev/fuz_util": "^0.63.2", - "@fuzdev/gro": "^0.201.1", + "@fuzdev/gro": "^0.202.0", "@ryanatkn/eslint-config": "^0.12.1", "@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/adapter-static": "^3.0.10", @@ -91,7 +91,7 @@ "prettier-plugin-svelte": "^3.4.1", "svelte": "^5.55.5", "svelte-check": "^4.4.6", - "svelte-docinfo": "^0.3.0", + "svelte-docinfo": "^0.4.0", "svelte2tsx": "^0.7.51", "tslib": "^2.8.1", "typescript": "^5.9.3", diff --git a/src/lib/vite_plugin_fuz_css.ts b/src/lib/vite_plugin_fuz_css.ts index c4aed220..daa47c41 100644 --- a/src/lib/vite_plugin_fuz_css.ts +++ b/src/lib/vite_plugin_fuz_css.ts @@ -74,20 +74,27 @@ const FUZ_CSS_PLACEHOLDER_DECL_RE = /--fuz-css-placeholder\s*:\s*1\s*;?/g; const FUZ_CSS_PLACEHOLDER_EMPTY_ROOT_RE = /:root\s*\{\s*\}/; const VIRTUAL_ID = 'virtual:fuz.css'; -// In dev mode, resolve to .js so Vite treats it as JS (for HMR handling) -// In build mode, resolve to .css for proper CSS bundling -const RESOLVED_VIRTUAL_ID_JS = '\0virtual:fuz.css.js'; -const RESOLVED_VIRTUAL_ID_CSS = '\0virtual:fuz.css'; - -// TODO investigate: Dev mode uses a JS wrapper that injects CSS and self-accepts HMR. -// We couldn't get plain CSS with `css-update` to work for virtual modules - there's a -// mismatch between `data-vite-dev-id` (set to `\0virtual:fuz.css`) and the importable -// URL (`/@id/__x00__virtual:fuz.css`). UnoCSS uses `js-update` with `mod.url` for plain -// CSS but that broke all HMR when we tried it. Areas to investigate: -// - How does Vite's CSS HMR actually resolve virtual module URLs? -// - Why does UnoCSS's approach work for them but not here? -// - Is there a way to control what `data-vite-dev-id` gets set to? -// The current JS wrapper approach works reliably, but plain CSS would be cleaner. +/** + * Resolved id of the virtual module, in both dev and build — a leading-slash + * path ending in `.css`, deliberately *not* a `\0`-prefixed virtual id. + * + * Two properties matter: + * - The `.css` extension makes Vite and frameworks treat the module as CSS + * (it passes Vite's `isCSSRequest`). In dev, Vite's CSS pipeline injects it + * client-side with HMR, and SvelteKit's dev FOUC-prevention inlines it into + * the SSR'd `` so the first paint is already styled. + * - The plain URL (no `\0`) is what makes that inlining actually happen. A + * `\0` id is encoded as `/@id/__x00__virtual:fuz.css` in the import graph, + * and SvelteKit looks deps up by URL via `moduleGraph.getModuleByUrl()`, + * which can't resolve the `__x00__`-encoded form back to its node — so a `\0` + * id is silently skipped and the page flashes unstyled on every refresh. + * + * This mirrors UnoCSS's `/__uno.css`. `resolveId` claims both the + * `virtual:fuz.css` specifier and this resolved id (so re-resolution of + * `?inline`/`?direct` query variants stays ours rather than hitting the + * filesystem). + */ +const RESOLVED_VIRTUAL_ID = '/__fuz.css'; /** * Skip cache on CI (no point writing cache that won't be reused). @@ -348,20 +355,19 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug last_generated_css = new_css; pending_css = new_css; // Store for reuse in load() to avoid regenerating - const mod = server!.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID_JS); + const mod = server!.moduleGraph.getModuleById(RESOLVED_VIRTUAL_ID); if (mod) { server!.moduleGraph.invalidateModule(mod); - // TODO investigate: This hardcoded path matches Vite's URL encoding for virtual - // modules. Using `mod.url` doesn't work (it's `\0virtual:fuz.css.js`). Could break - // if Vite changes their encoding scheme. Is there a proper API for this? - const hmr_path = '/@id/__x00__virtual:fuz.css.js'; + // Vite wraps the CSS module so it self-accepts (`import.meta.hot.accept()`), + // re-running `updateStyle` with fresh content on a `js-update`. The module's + // plain URL is its own id (no `\0` encoding), so it doubles as the HMR path. server!.hot.send({ type: 'update', updates: [ { type: 'js-update', - path: hmr_path, - acceptedPath: hmr_path, + path: RESOLVED_VIRTUAL_ID, + acceptedPath: RESOLVED_VIRTUAL_ID, timestamp: Date.now(), }, ], @@ -427,63 +433,42 @@ export const vite_plugin_fuz_css = (options: VitePluginFuzCssOptions = {}): Plug resolveId(id) { if (id === VIRTUAL_ID) { - // In dev mode, resolve to .js for HMR support - // In build mode, resolve to .css for proper bundling - return is_dev ? RESOLVED_VIRTUAL_ID_JS : RESOLVED_VIRTUAL_ID_CSS; + return RESOLVED_VIRTUAL_ID; + } + // Claim the resolved id (and its `?inline`/`?direct`/`?used` query + // variants) so re-resolution stays ours instead of hitting the + // filesystem — SvelteKit's dev SSR inlining loads `/__fuz.css?inline`. + if (id.split('?', 1)[0] === RESOLVED_VIRTUAL_ID) { + return id; } return undefined; }, async load(id) { - // Dev mode: JS module that injects CSS and handles HMR - if (id === RESOLVED_VIRTUAL_ID_JS) { - virtual_module_loaded = true; - // Defer resource loading to first virtual module access - if (include_base || include_theme) { - await ensure_bundled_resources(); - } - // Use pending CSS from HMR if available, avoiding redundant generation + // Match the base id and any query variant (`?inline`, `?direct`, `?used`) + // Vite or SvelteKit appends — SvelteKit's dev SSR inlining loads `?inline`. + if (id.split('?', 1)[0] !== RESOLVED_VIRTUAL_ID) { + return undefined; + } + virtual_module_loaded = true; + // Defer resource loading to first virtual module access + if (include_base || include_theme) { + await ensure_bundled_resources(); + } + if (is_dev) { + // Dev: return real CSS. Vite's CSS pipeline wraps it for client-side + // injection with HMR, and SvelteKit's dev SSR inlines it into the + // document `` so the first paint is styled (no FOUC on refresh). + // Reuse CSS computed during an HMR pass when available. const css = pending_css ?? render_css(); pending_css = null; last_generated_css = css; // Track for HMR diffing - const escaped_css = JSON.stringify(css); - return ` -const css = ${escaped_css}; - -// Inject CSS on the client only. During SSR (SvelteKit dev prerenders the -// layout that imports this module) document is undefined, so this is a no-op -// on the server; Vite applies styles client-side after hydration. -if (typeof document !== 'undefined') { - // Find existing style tag or create new one - let style = document.querySelector('style[data-fuz-css]'); - if (!style) { - style = document.createElement('style'); - style.setAttribute('data-fuz-css', ''); - document.head.appendChild(style); - } - style.textContent = css; -} - -if (import.meta.hot) { - import.meta.hot.accept(); -} - -export {}; -`; + return css; } - // Build mode: plain CSS - if (id === RESOLVED_VIRTUAL_ID_CSS) { - virtual_module_loaded = true; - // Defer resource loading to first virtual module access - if (include_base || include_theme) { - await ensure_bundled_resources(); - } - // Emit a marker rule (not a comment — comments are minified away) so - // generateBundle can find the importer's CSS asset (loaded on every - // page) and splice the full generated CSS in there. - return `:root{${FUZ_CSS_PLACEHOLDER}:1}`; - } - return undefined; + // Build: emit a marker rule (not a comment — comments are minified away) + // so generateBundle can find the importer's CSS asset (loaded on every + // page) and splice the full generated CSS in there. + return `:root{${FUZ_CSS_PLACEHOLDER}:1}`; }, generateBundle(_options, bundle) {