#1889·stylex

Vite CSS filename does not change after a StyleX-only edit with cssCodeSplit: false

Author: skovhusCreated Sep 15, 2026Updated Sep 15, 2026

Describe the issue

With @stylexjs/[email protected], Vite 8.3.0, and build.cssCodeSplit: false, a StyleX-only edit changes the emitted CSS bytes but leaves its content-hashed filename unchanged.

The generated class changes from xyamay9 to x1miatn0, and the new JavaScript uses the new class. The old CSS does not contain it. Since HTML and the manifest still reference the same CSS URL, a browser or CDN retaining the old CSS can serve a stylesheet that lacks the class required by the new JavaScript.

This follows up on the closed issue #1387. The fix from PR #1501 is present in the tested release, but the single-stylesheet build still reaches a fallback that skips rehashing.

Expected behavior

The CSS filename changes when the final CSS content changes, including changes from StyleX. HTML and the manifest reference the resulting file. Identical input produces an identical filename.

Steps to reproduce

Tested on macOS 15.7.9 (arm64), Node.js 24.18.0, pnpm 10.30.3, Vite 8.3.0, and @stylexjs/stylex, @stylexjs/unplugin, and @stylexjs/babel-plugin all at 0.19.0. These are unpatched npm packages; 0.19.0 is the current latest tag for both StyleX plugins. No browser is needed to reproduce the build mismatch.

  1. Create an empty directory with a src/ subdirectory and save the files below.

  2. Use Node.js 24.18.0 and pnpm 10.30.3, then run:

    bash
    pnpm install
    pnpm reproduce
  3. Observe BUG REPRODUCED and exit code 1. Each Vite build succeeds; the script fails the content-hash check after testing a StyleX-only edit and both controls.

The script changes only paddingTop: 16 to paddingTop: 32, then restores the inputs. It also checks an unchanged rebuild and an ordinary CSS edit. All builds use fresh Node processes and clean output directories. Complete builds, HTML, manifests, and full SHA-256 digests are saved in results/.

For a manual check, run pnpm build, copy dist elsewhere, change paddingTop: 16 to paddingTop: 32 in src/main.js, and run pnpm build again. Compare the stylesheet names and contents.

Observed output (SHA-256 digests shortened to 12 characters):

baseline    assets/style-CW4HsblT.css sha256=25f2e4557b02
unchanged   assets/style-CW4HsblT.css sha256=25f2e4557b02
stylex-only assets/style-CW4HsblT.css sha256=3b00d50c1c23
plain-css   assets/style-Do5hquVH.css sha256=56e1ff21bfb4
BUG REPRODUCED: different CSS has the same filename
Controls passed. Inputs restored. Evidence saved in results/.

Test case

The complete reproduction is below: one HTML page, one JavaScript module with one StyleX declaration, one ordinary stylesheet, Vite configuration, and a verification script. It has no React dependency or custom build plugins.

package.json
json
{
  "name": "stylex-babel-css-hash-repro",
  "private": true,
  "type": "module",
  "packageManager": "[email protected]",
  "engines": {
    "node": ">=22.12.0"
  },
  "scripts": {
    "build": "vite build",
    "reproduce": "node reproduce.mjs"
  },
  "dependencies": {
    "@stylexjs/stylex": "0.19.0"
  },
  "devDependencies": {
    "@stylexjs/unplugin": "0.19.0",
    "@stylexjs/babel-plugin": "0.19.0",
    "unplugin": "2.3.11",
    "vite": "8.3.0"
  }
}
pnpm-workspace.yaml
yaml
packages:
  - .
autoInstallPeers: false
vite.config.mjs
javascript
import stylex from "@stylexjs/unplugin";

export default {
  plugins: [stylex.vite({ dev: false })],
  build: { cssCodeSplit: false, manifest: true },
};
index.html
xml
<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <title>StyleX CSS hash reproduction</title>
  </head>
  <body>
    <main id="example">This text has padding set by StyleX.</main>
    <script type="module" src="/src/main.js"></script>
  </body>
</html>
src/main.js
javascript
import * as stylex from "@stylexjs/stylex";
import "./base.css";

const styles = stylex.create({
  example: { paddingTop: 16 },
});

document.getElementById("example").className = stylex.props(styles.example).className;
src/base.css
css
body { margin: 24px; }
reproduce.mjs
javascript
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { cpSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";

process.chdir(fileURLToPath(new URL(".", import.meta.url)));
const originalJs = readFileSync("src/main.js", "utf8");
const originalCss = readFileSync("src/base.css", "utf8");
const builds = [];
assert.match(originalJs, /paddingTop: 16/);
assert.match(originalCss, /margin: 24px/);

try {
  rmSync("results", { recursive: true, force: true });
  for (const [variant, padding, margin] of [
    ["baseline", 16, 24],
    ["unchanged", 16, 24],
    ["stylex-only", 32, 24],
    ["plain-css", 32, 40],
  ]) {
    writeFileSync("src/main.js", originalJs.replace("paddingTop: 16", `paddingTop: ${padding}`));
    writeFileSync("src/base.css", originalCss.replace("margin: 24px", `margin: ${margin}px`));
    rmSync("dist", { recursive: true, force: true });

    // Each build gets a fresh process to avoid shared compiler state.
    const log = execFileSync(process.execPath, ["node_modules/vite/bin/vite.js", "build"], {
      env: { ...process.env, NODE_ENV: "production" },
      encoding: "utf8",
    });
    mkdirSync(`results/${variant}`, { recursive: true });
    cpSync("dist", `results/${variant}`, { recursive: true });
    writeFileSync(`results/${variant}/build.log`, log);

    const html = readFileSync("dist/index.html", "utf8");
    const links = [...html.matchAll(/<link\b[^>]*href="([^"]+\.css)"/g)];
    assert.equal(links.length, 1, "Expected one stylesheet linked by HTML");
    const cssFile = links[0][1].replace(/^\//, "");
    const css = readFileSync(`dist/${cssFile}`, "utf8");
    const manifest = JSON.parse(readFileSync("dist/.vite/manifest.json", "utf8"));
    const manifestFiles = Object.values(manifest).flatMap((entry) => [entry.file, ...(entry.css ?? [])]);
    assert.ok(manifestFiles.includes(cssFile), "Manifest must reference the emitted stylesheet");
    assert.match(css, new RegExp(`padding-top:\\s*${padding}px`));
    assert.match(css, new RegExp(`margin:\\s*${margin}px`));
    assert.doesNotMatch(css, /@stylex-placeholder|__stylex_build_placeholder__/);

    const jsFile = manifest["index.html"].file;
    const js = readFileSync(`dist/${jsFile}`, "utf8");
    const [, stylexClass] = css.match(/\.([\w-]+)\s*\{\s*padding-top:/) ?? [];
    assert.ok(stylexClass && js.includes(stylexClass), "JavaScript must use the generated CSS class");
    builds.push({
      variant, cssFile, stylexClass, jsFile,
      cssSha256: createHash("sha256").update(css).digest("hex"),
      jsSha256: createHash("sha256").update(js).digest("hex"),
    });
    console.log(`${variant.padEnd(11)} ${cssFile} sha256=${builds.at(-1).cssSha256.slice(0, 12)}`);
  }
} finally {
  writeFileSync("src/main.js", originalJs);
  writeFileSync("src/base.css", originalCss);
}

const [baseline, unchanged, stylexOnly, plainCss] = builds;
assert.deepEqual({ ...baseline, variant: unchanged.variant }, unchanged, "Unchanged build must be identical");
assert.notEqual(baseline.cssSha256, stylexOnly.cssSha256, "StyleX edit must change CSS");
assert.notEqual(baseline.jsSha256, stylexOnly.jsSha256, "StyleX edit must change JavaScript");
assert.notEqual(baseline.stylexClass, stylexOnly.stylexClass, "StyleX edit must change the generated class");
assert.ok(!readFileSync(`results/baseline/${baseline.cssFile}`, "utf8").includes(stylexOnly.stylexClass));
assert.notEqual(stylexOnly.cssSha256, plainCss.cssSha256, "Ordinary CSS edit must change CSS");
assert.notEqual(stylexOnly.cssFile, plainCss.cssFile, "Ordinary CSS edit must change filename");

const collision = baseline.cssFile === stylexOnly.cssFile;
const { dependencies, devDependencies } = JSON.parse(readFileSync("package.json", "utf8"));
writeFileSync("results/report.json", JSON.stringify({
  node: process.version, dependencies, devDependencies, controlsPassed: true,
  sameCssFilenameAfterStylexEdit: collision, builds,
}, null, 2) + "\n");
console.log(collision ? "BUG REPRODUCED: different CSS has the same filename" : "PASS: StyleX edit changes the CSS filename");
console.log("Controls passed. Inputs restored. Evidence saved in results/.");
process.exitCode = collision ? 1 : 0;

Additional comments

The Babel compiler emits the correct new class and CSS. Tracing the Vite hooks shows the remaining failure in the adapter:

  1. StyleX's generateBundle runs with the correct rules already collected, but no CSS asset exists yet.
  2. Vite emits the ordinary stylesheet and assigns its hashed filename.
  3. StyleX's writeBundle appends its rules on disk. The filename remains unchanged.

See the Vite adapter and the core plugin's enforce: 'pre'.

As a diagnostic experiment, changing only generateBundle to { order: "post", handler: plugin.generateBundle } makes this reproduction enter the existing rehash path: 16px emits style-B2ydcg4O.css, and 32px emits style-DmGQdaq2.css. The compiler and final CSS bytes are unchanged for each input, and HTML and the manifest reference the new filename. This isolates the bug to the integration's hook timing.

PR #1442 added the rehash helper. PR #1501 fixed its unsupported bundle assignments and closed #1387. Neither fixes the fallback reached when the CSS asset is absent from generateBundle. Issue #1378 covers the broader problem of appending CSS outside the normal CSS pipeline.

This report covers Vite's single-stylesheet mode. The hook-order experiment is limited to this reduced case; a general fix needs checks for code splitting, other asset references, and repeated builds.