fix(bun-plugin,rsbuild-plugin): stop baking absolute CSS paths into transformed code - #657
Merged
Merged
Conversation
Developing one repository in several checkouts at once (git worktrees, a
CI matrix, sibling clones) made every checkout but the first fail every
`bun test` that touches a Devup UI component:
error: Cannot find module '<other checkout>/df/devup-ui/devup-ui.css'
from '<this checkout>/src/Component.tsx'
Root cause: `onResolve` answered the injected stylesheet import with
`join(cssDir, fileName)`, an absolute path derived from `process.cwd()`
at module load. Bun persists transpiled modules in a machine-wide
on-disk cache (`<bun cache>/@t@`) keyed by module contents only, with
plugin-resolved import specifiers already baked in; the key covers
neither the cwd nor the importing file. Checkouts of one repository hold
byte-identical sources, so the second checkout reuses the first one's
cache entry and imports the first one's absolute CSS path. The extractor
was never at fault: it emits the relative specifier it is handed.
Resolve the stylesheet onto a virtual namespaced id instead, so nothing
derived from the cwd can reach a cache entry, and serve it from an
`onLoad` in that namespace (Bun's runtime has no CSS loader, and the
stylesheet is a bundler-side build artifact). Deciding on the *shape* of
the resolved directory rather than on equality with this checkout's
absolute cssDir also repairs cache entries an older plugin version
already poisoned, so no cache wipe is needed.
Regression tests, both verified failing before this change and passing
after: `__regression__/worktree-isolation.bun.ts` drives two checkouts
through two sequential `bun test` child processes over the shared cache
and reproduces the exact error above on the old code, and
`src/__tests__/css-id.test.ts` locks the resolution invariants.
Scope: next-plugin, webpack-plugin and vite-plugin pass a *relative*
cssDir into codeExtract and are unaffected. rsbuild-plugin's non-atom
path (packages/rsbuild-plugin/src/plugin.ts:255) does bake an absolute
cssDir into transformed code and has the same latent defect, but rspack
caches per project so no cross-checkout harm could be reproduced; left
unfixed and recorded rather than changed on a guess.
Verified: bun test 5178 pass / 0 fail, regression suite 2/2, eslint
clean, cargo fmt --check and cargo clippy -D warnings exit 0.
Same defect class as the bun-plugin fix: a machine-absolute path baked
into transformed module text.
The non-atom path passed the absolute `cssDir` straight to `codeExtract`,
so every transformed module carried
import "C:\\...\\<checkout>\\df\\devup-ui/devup-ui-0.css";
Non-atom with `singleCss: false` is the default configuration, so this is
what rsbuild users get out of the box. It makes the transform output
depend on where the repository happens to be checked out: byte-identical
sources in two checkouts produce different code, which breaks any
content-addressed or relocated build cache exactly the way Bun's
machine-wide transpiler cache broke `bun test`. It also mixed path
separators in a single specifier on Windows.
next-plugin, webpack-plugin and vite-plugin already pass
`relative(dirname(id), cssDir)`; rsbuild was the only one that did not,
and only in the branch users hit by default. Always compute the relative
specifier, and keep atom mode's POSIX-normalized extraction filename
(that one exists to match the absolute-keyed canonical map / FILE_ROUTES,
which is unrelated). `cssFile` is consumed via `basename()`, as in
webpack and next, so file numbering and write locations are unchanged.
extractCssDir = ./../df/devup-ui
import "./../df/devup-ui/devup-ui-0.css";
Regression test `src/__tests__/checkout-isolation.test.ts` drives the
plugin's transform from two checkouts and asserts they hand the extractor
the same, checkout-independent specifier. Verified failing before this
change:
Expected: "\repos\app\worktree-b\df\devup-ui"
Received: "\repos\app\worktree-a\df\devup-ui"
vite-plugin's `resolveId` also returns an absolute path, but that is an
in-memory Rollup module id that never reaches emitted module text, so it
is left alone.
Verified: bun test 5180 pass / 0 fail, rsbuild-plugin/src/plugin.ts at
100% coverage, eslint clean, cargo fmt --check exit 0.
Contributor
Changepacks@devup-ui/bun-plugin@1.0.16 - packages/bun-plugin/package.jsonMaybe you forgot to write the following files to the latest version |
Codecov Report✅ All modified and coverable lines are covered by tests.
🚀 New features to boost your workflow:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Machine-absolute CSS paths escape from two plugins into transformed module text. In
bun-pluginthis breaks tests outright; inrsbuild-pluginit makes the default build output depend on where the repository is checked out. Both are fixed here by the rule the other three plugins already follow: the stylesheet import must be relative to the importer, and nothing derived fromprocess.cwd()may reach emitted output.Symptom
Developing one repository in several checkouts at once —
git worktree, a CI matrix, sibling clones — makes every checkout but the first fail everybun testthat touches a Devup UI component. Found on a project running 9 worktrees in parallel: three of them reported 468, 468 and 314 failing tests, in worktrees where only Rust or Markdown had been edited, or nothing at all.git stashdid not change the result, so nobody could tell whether their own changes were broken.The importing file is in one worktree; the stylesheet it is told to import is an absolute path into a different, unrelated worktree.
Reproduction
Two directories with a byte-identical source file,
bunfig.tomlpreloading@devup-ui/bun-plugin, run one after the other:This is now
packages/bun-plugin/__regression__/worktree-isolation.bun.ts.Root cause
bun-plugin
plugin.tsfrozecssDir = resolve('df', 'devup-ui')— an absolute path derived fromprocess.cwd()at module load — andonResolveanswered the injected stylesheet import with{ path: join(cssDir, fileName) }.Bun persists transpiled modules in a machine-wide on-disk cache (
<bun cache>/@t@/*.pile) keyed by module contents only, with plugin-resolved import specifiers already baked in. Neither the cwd nor the importing file is part of the key. A poisoned entry looks like this:Checkouts of one repository hold byte-identical sources, so they collapse onto one cache entry, and every checkout after the first imports the first one's absolute CSS path.
The extractor was never at fault. Given a relative
css_dirit emits exactly that (libs/extractor/src/lib.rs,main_css_path); the absolute path was created in JS, inonResolve:Two things made this worse: the shared package directories are hardlinked across worktrees by
bun install, guaranteeing identical inputs; andBUN_RUNTIME_TRANSPILER_CACHE_DIRis ignored in Bun 1.4.1, so the cache cannot be isolated per checkout.rsbuild-plugin
The same defect class, found while auditing the other plugins. The non-atom path passed the absolute
cssDirstraight tocodeExtract, so every transformed module carried an absolute specifier — with mixed separators on Windows:Non-atom with
singleCss: falseis the default configuration, so this is what rsbuild users get out of the box. Byte-identical sources in two checkouts therefore produce different transform output, which is precisely what makes a content-addressed or relocated build cache unsafe. rspack caches per project, so this did not manifest as the loudbun testfailure — but the underlying property is the same one that broke Bun.Fix
bun-plugin — resolve the stylesheet onto a virtual namespaced id (
{ path: 'devup-ui.css', namespace: 'devup-ui' }) in the newpackages/bun-plugin/src/css-id.ts, and serve it from anonLoadin that namespace. Nothing derived from the cwd can reach a cache entry any more, so sharing one is harmless. The module is empty on purpose: Bun's runtime has no CSS loader (onLoadaccepts only the script/data loaders) and the stylesheet is a bundler-side build artifact.Resolution is decided on the shape of the resolved directory (
<distDir>/devup-ui/) rather than on equality with this checkout's absolutecssDir. That keeps the function independent ofprocess.cwd()and, importantly, repairs cache entries an older plugin version already poisoned — no cache wipe required. Verified with the poisoned pile left on disk:rsbuild-plugin — always compute
relative(dirname(resourcePath), cssDir), as next/webpack/vite already do. Atom mode's POSIX-normalized extraction filename is kept (it exists to match the absolute-keyed canonical map / FILE_ROUTES, which is unrelated), andcssFileis consumed viabasename()exactly as in webpack and next, so file numbering and write locations are unchanged.Regression tests
All were verified failing before the change and passing after.
packages/bun-plugin/__regression__/worktree-isolation.bun.ts— drives two checkouts through two sequentialbun testchild processes over the shared cache and asserts each stays on its own stylesheet. Restoring the oldonResolvereproduces the exact production error. Wired intobun run --filter @devup-ui/bun-plugin test:regression.packages/bun-plugin/src/__tests__/css-id.test.ts— two checkouts yield the same path-free id, a foreign absolute path is repaired, stylesheets outside the dist css dir keep Bun's normal resolution.packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts— drives the plugin's transform from two checkouts and asserts they hand the extractor the same, checkout-independent specifier. Before the fix:Scope — all plugins audited
css_dirpassed tocodeExtractbun-pluginonResolvereturned an absolute pathrsbuild-plugin(src/plugin.ts:255)next-plugin(src/loader.ts:221)webpack-plugin(src/loader.ts:57)vite-plugin(src/plugin.ts:465)vite-plugin'sresolveIdalso returns an absolute path (src/plugin.ts:436), but that is an in-memory Rollup module id that never reaches emitted module text, and the comment there shows it is deliberately stable to keep build hashes identical. Different class — left alone.Verification
bun test— 5180 pass / 0 fail (also re-run by the pre-commit hook on both commits)bun run --filter @devup-ui/bun-plugin test:regression— 2 pass / 0 failpackages/rsbuild-plugin/src/plugin.tsandpackages/bun-plugin/src/css-id.tsat 100% coverageeslintclean,cargo fmt --all -- --checkandcargo clippy --all-targets --all-features -- -D warningsexit 0No Rust source was changed.
bun.lockcarries a workspace-version refresh produced bybun install.