From f474e49439f7711e7c8f92e4e47aa934cfb68dcc Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 9 Sep 2026 09:21:00 +0900 Subject: [PATCH 1/2] fix(bun-plugin): stop leaking one checkout's CSS path into another 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 '/df/devup-ui/devup-ui.css' from '/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 (`/@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. --- bun.lock | 22 +-- .../__regression__/worktree-isolation.bun.ts | 133 +++++++++++++++++ packages/bun-plugin/package.json | 2 +- .../bun-plugin/src/__tests__/css-id.test.ts | 138 ++++++++++++++++++ packages/bun-plugin/src/css-id.ts | 62 ++++++++ packages/bun-plugin/src/plugin.ts | 34 +++-- 6 files changed, 364 insertions(+), 27 deletions(-) create mode 100644 packages/bun-plugin/__regression__/worktree-isolation.bun.ts create mode 100644 packages/bun-plugin/src/__tests__/css-id.test.ts create mode 100644 packages/bun-plugin/src/css-id.ts diff --git a/bun.lock b/bun.lock index 97d2bdd00..307aa7568 100644 --- a/bun.lock +++ b/bun.lock @@ -469,11 +469,11 @@ }, "bindings/devup-ui-wasm": { "name": "@devup-ui/wasm", - "version": "1.0.78", + "version": "1.0.79", }, "packages/bun-plugin": { "name": "@devup-ui/bun-plugin", - "version": "1.0.15", + "version": "1.0.16", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -488,7 +488,7 @@ }, "packages/components": { "name": "@devup-ui/components", - "version": "0.1.53", + "version": "0.1.55", "dependencies": { "@devup-ui/react": "workspace:^", "clsx": "^2.1", @@ -515,7 +515,7 @@ }, "packages/eslint-plugin": { "name": "@devup-ui/eslint-plugin", - "version": "1.0.18", + "version": "1.0.19", "dependencies": { "@typescript-eslint/utils": "^8.68", "typescript-eslint": "^8.68", @@ -532,7 +532,7 @@ }, "packages/next-plugin": { "name": "@devup-ui/next-plugin", - "version": "1.0.83", + "version": "1.0.86", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -550,14 +550,14 @@ }, "packages/plugin-utils": { "name": "@devup-ui/plugin-utils", - "version": "1.0.13", + "version": "1.0.14", "devDependencies": { "typescript": "^7.0.2", }, }, "packages/react": { "name": "@devup-ui/react", - "version": "1.0.40", + "version": "1.0.41", "dependencies": { "csstype-extra": "latest", "react": "^19.2", @@ -578,7 +578,7 @@ }, "packages/reset-css": { "name": "@devup-ui/reset-css", - "version": "1.0.27", + "version": "1.0.28", "dependencies": { "@devup-ui/react": "workspace:^", }, @@ -591,7 +591,7 @@ }, "packages/rsbuild-plugin": { "name": "@devup-ui/rsbuild-plugin", - "version": "1.0.61", + "version": "1.0.62", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -607,7 +607,7 @@ }, "packages/vite-plugin": { "name": "@devup-ui/vite-plugin", - "version": "1.0.67", + "version": "1.0.69", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", @@ -622,7 +622,7 @@ }, "packages/webpack-plugin": { "name": "@devup-ui/webpack-plugin", - "version": "1.0.66", + "version": "1.0.67", "dependencies": { "@devup-ui/plugin-utils": "workspace:^", "@devup-ui/wasm": "workspace:^", diff --git a/packages/bun-plugin/__regression__/worktree-isolation.bun.ts b/packages/bun-plugin/__regression__/worktree-isolation.bun.ts new file mode 100644 index 000000000..6ad0a6e5c --- /dev/null +++ b/packages/bun-plugin/__regression__/worktree-isolation.bun.ts @@ -0,0 +1,133 @@ +import { + existsSync, + mkdirSync, + mkdtempSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' + +import { afterAll, expect, it } from 'bun:test' + +// NOTE: named `*.bun.ts`, NOT `*.test.ts`, so the root suite (root = "packages", +// source preload + 100% coverage gate) does not auto-discover it. Run it via +// `bun run --filter @devup-ui/bun-plugin test:regression`; it needs the BUILT +// plugin (dist/index.mjs) plus the WASM artifacts. +// +// Regression: developing one repository in several checkouts at once (git +// worktrees, a CI matrix, sibling clones) used to make every checkout but the +// first fail with +// +// error: Cannot find module '/df/devup-ui/devup-ui.css' +// from '/src/Component.tsx' +// +// Bun stores transpiled modules in a machine-wide on-disk cache +// (`/@t@`) keyed by module contents, with plugin-resolved import +// specifiers already baked in; the key covers neither the cwd nor the importing +// file. The checkouts hold byte-identical sources, so they share one cache +// entry — and the plugin used to answer `onResolve` with +// `/df/devup-ui/devup-ui.css`, an absolute path that is only correct for +// whichever checkout populated the entry first. +// +// This test drives the real failure: two checkouts whose fixture is byte for +// byte the same (and padded past the size at which Bun persists transpiled +// output), loaded by two separate `bun` processes that share that cache. + +const pluginEntry = resolve(import.meta.dir, '..', 'dist', 'index.mjs') + +// Byte-identical in both checkouts: that is what collapses them onto one cache +// entry. `css()` is compile-only, so the plugin erases the @devup-ui/react +// import entirely and the fixture needs no node_modules of its own — while the +// extractor still injects the `df/devup-ui/devup-ui.css` import under test. The +// dead exports pad the module past the size at which Bun persists transpiled +// output (comments are stripped before hashing, so padding must be code). +const fixture = [ + `import { css } from '@devup-ui/react'`, + ...Array.from( + { length: 4000 }, + (_, i) => + `export const pad${i} = 'devup-ui worktree isolation padding ${i}'`, + ), + `export const cls = css({ background: 'red', padding: '4px' })`, + '', +].join('\n') + +// A static import, loaded by `bun test` behind a preloaded plugin: the exact +// shape in which consumers hit this — and the shape Bun caches. +const fixtureTest = [ + `import { expect, it } from 'bun:test'`, + ``, + `import { cls } from './fixture'`, + ``, + `it('extracted its own stylesheet', () => {`, + ` console.log(JSON.stringify({ cwd: process.cwd(), cls }))`, + ` expect(cls).toBeTruthy()`, + `})`, + '', +].join('\n') + +const bunfig = `[test]\npreload = [${JSON.stringify(pluginEntry.replaceAll('\\', '/'))}]\n` + +const root = mkdtempSync(join(tmpdir(), 'devup-worktrees-')) + +afterAll(() => { + rmSync(root, { recursive: true, force: true }) +}) + +function makeCheckout(name: string) { + const dir = join(root, name) + mkdirSync(dir, { recursive: true }) + writeFileSync(join(dir, 'fixture.ts'), fixture, 'utf-8') + writeFileSync(join(dir, 'fixture.test.ts'), fixtureTest, 'utf-8') + writeFileSync(join(dir, 'bunfig.toml'), bunfig, 'utf-8') + return dir +} + +function loadIn(dir: string) { + const proc = Bun.spawnSync([process.execPath, 'test'], { + cwd: dir, + stdout: 'pipe', + stderr: 'pipe', + }) + const output = proc.stdout.toString() + proc.stderr.toString() + const reported = /^\{"cwd".*\}$/m.exec(output)?.[0] + return { + exitCode: proc.exitCode, + output, + reported: reported + ? (JSON.parse(reported) as { cwd: string; cls: string }) + : undefined, + } +} + +it('keeps two checkouts of one repository on their own stylesheet', () => { + const checkoutA = makeCheckout('checkout-a') + const checkoutB = makeCheckout('checkout-b') + + // Sequential, sharing this machine's Bun transpiler cache: A populates the + // entry, B reuses it. + const first = loadIn(checkoutA) + const second = loadIn(checkoutB) + + for (const [dir, run] of [ + [checkoutA, first], + [checkoutB, second], + ] as const) { + expect(run.exitCode, `${dir} failed to load:\n${run.output}`).toBe(0) + // Extraction really happened, so the injected stylesheet import — the thing + // being resolved — was actually present in the module under test. + expect( + run.reported?.cls, + `no extraction in ${dir}:\n${run.output}`, + ).toBeTruthy() + expect(run.reported?.cwd).toBe(dir) + // Each checkout materialised its own dist dir. + expect(existsSync(join(dir, 'df', 'devup-ui'))).toBe(true) + } + + // Neither checkout may reach into the other. Before the fix this is precisely + // where checkout B reported checkout A's absolute `df/devup-ui/devup-ui.css`. + expect(first.output).not.toContain(checkoutB) + expect(second.output).not.toContain(checkoutA) +}) diff --git a/packages/bun-plugin/package.json b/packages/bun-plugin/package.json index 4fa77936a..91decd959 100644 --- a/packages/bun-plugin/package.json +++ b/packages/bun-plugin/package.json @@ -22,7 +22,7 @@ "scripts": { "lint": "eslint", "build": "bun ../../node_modules/@typescript/native/bin/tsc && bun build --target node src/index.cjs.ts --production --env=disable --outfile dist/index.cjs --format cjs --packages external && bun build --target node src/index.ts --production --env=disable --outfile dist/index.mjs --format esm --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.cjs --format cjs --packages external && bun build --target node src/register.ts --production --env=disable --outfile dist/register.mjs --format esm --packages external", - "test:regression": "cd __regression__ && bun test ./preload-race.bun.ts" + "test:regression": "cd __regression__ && bun test ./preload-race.bun.ts ./worktree-isolation.bun.ts" }, "publishConfig": { "access": "public" diff --git a/packages/bun-plugin/src/__tests__/css-id.test.ts b/packages/bun-plugin/src/__tests__/css-id.test.ts new file mode 100644 index 000000000..2a48f93ed --- /dev/null +++ b/packages/bun-plugin/src/__tests__/css-id.test.ts @@ -0,0 +1,138 @@ +import { join } from 'node:path' + +import { describe, expect, it } from 'bun:test' + +import { cssDirName, cssNamespace, resolveCssId } from '../css-id' + +const distDir = 'df' + +// Two checkouts of one repository, as produced by `git worktree add`. They hold +// byte-identical sources at identical repository-relative paths and differ only +// in their root, which is exactly the situation Bun's content-keyed transpiler +// cache collapses into a single entry. +const checkoutA = join('/repos', 'app', 'worktree-a') +const checkoutB = join('/repos', 'app', 'worktree-b') +const importerIn = (checkout: string) => + join(checkout, 'src', 'components', 'Card.tsx') + +// The specifier the extractor injects: relative to the importing file. +const injectedSpecifier = '../../df/devup-ui/devup-ui.css' + +describe('resolveCssId', () => { + it('resolves the injected stylesheet onto the virtual namespace', () => { + expect( + resolveCssId(injectedSpecifier, importerIn(checkoutA), distDir), + ).toEqual({ + path: 'devup-ui.css', + namespace: cssNamespace, + }) + }) + + it('resolves numbered per-file stylesheets', () => { + expect( + resolveCssId( + '../../df/devup-ui/devup-ui-12.css', + importerIn(checkoutA), + distDir, + ), + ).toEqual({ + path: 'devup-ui-12.css', + namespace: cssNamespace, + }) + }) + + it('strips a query suffix from the stylesheet name', () => { + expect( + resolveCssId( + '../../df/devup-ui/devup-ui.css?inline', + importerIn(checkoutA), + distDir, + ), + ).toEqual({ + path: 'devup-ui.css', + namespace: cssNamespace, + }) + }) + + it('resolves against the cwd when there is no importer', () => { + expect( + resolveCssId( + join(distDir, cssDirName, 'devup-ui.css'), + undefined, + distDir, + ), + ).toEqual({ + path: 'devup-ui.css', + namespace: cssNamespace, + }) + }) + + // --- The regression this module exists for ------------------------------- + // + // Bun stores transpiled modules in a machine-wide cache keyed by module + // contents, with plugin-resolved specifiers baked in and neither the cwd nor + // the importer in the key. Any id that varies per checkout therefore leaks + // into the other checkout as + // "Cannot find module '/df/devup-ui/devup-ui.css'". + + it('yields the same, path-free id for two checkouts of one repository', () => { + const fromA = resolveCssId( + injectedSpecifier, + importerIn(checkoutA), + distDir, + ) + const fromB = resolveCssId( + injectedSpecifier, + importerIn(checkoutB), + distDir, + ) + + expect(fromA).toEqual(fromB) + // Nothing checkout-specific may survive into the resolved id. + expect(fromA?.path).not.toContain(checkoutA) + expect(fromB?.path).not.toContain(checkoutB) + }) + + it('repairs a foreign absolute path baked in by an older plugin version', () => { + // What a poisoned cache entry hands back: checkout A's absolute stylesheet + // path, replayed while checkout B is the one being loaded. + const poisoned = join(checkoutA, distDir, cssDirName, 'devup-ui.css') + + expect(resolveCssId(poisoned, importerIn(checkoutB), distDir)).toEqual({ + path: 'devup-ui.css', + namespace: cssNamespace, + }) + }) + + // --- Stylesheets that are not ours --------------------------------------- + + it('ignores a devup-ui.css that does not live in the dist css dir', () => { + expect( + resolveCssId('../../vendor/devup-ui.css', importerIn(checkoutA), distDir), + ).toBeUndefined() + }) + + it('ignores a css dir nested under a different dist dir', () => { + expect( + resolveCssId( + '../../other/devup-ui/devup-ui.css', + importerIn(checkoutA), + distDir, + ), + ).toBeUndefined() + }) + + it('ignores a differently named stylesheet in the dist css dir', () => { + expect( + resolveCssId( + '../../df/devup-ui/theme.css', + importerIn(checkoutA), + distDir, + ), + ).toBeUndefined() + }) + + it('ignores an empty specifier', () => { + expect(resolveCssId('', importerIn(checkoutA), distDir)).toBeUndefined() + }) +}) diff --git a/packages/bun-plugin/src/css-id.ts b/packages/bun-plugin/src/css-id.ts new file mode 100644 index 000000000..2e167c629 --- /dev/null +++ b/packages/bun-plugin/src/css-id.ts @@ -0,0 +1,62 @@ +import { basename, dirname, resolve } from 'node:path' + +/** + * Namespace the extracted stylesheet is resolved into. + * + * Bun persists transpiled modules in a machine-wide on-disk cache + * (`/@t@`) that is keyed by module contents, with plugin-resolved + * import specifiers already baked in; the key covers neither the cwd nor the + * importing file. Two checkouts of one repository — git worktrees, a CI + * matrix, sibling clones — hold byte-identical sources, so they share cache + * entries. Anything derived from `process.cwd()` that reaches an `onResolve` + * result therefore leaks into the other checkout: + * + * error: Cannot find module '/df/devup-ui/devup-ui.css' + * from '/src/Component.tsx' + * + * A namespaced id carries no filesystem path, so it is identical in every + * checkout and sharing a cache entry is harmless. + */ +export const cssNamespace = 'devup-ui' + +/** Directory, inside the plugin's dist dir, holding the extracted stylesheet. */ +export const cssDirName = 'devup-ui' + +const cssFileName = /^devup-ui(?:-\d+)?\.css$/ + +export interface DevupCssId { + path: string + namespace: string +} + +/** + * Maps a `//devup-ui[-N].css` import — the one the + * extractor injects into every transformed source file — onto a virtual module + * id, and returns `undefined` for any other stylesheet so it keeps Bun's normal + * resolution. + * + * The decision is made on the *shape* of the resolved directory rather than on + * equality with this checkout's absolute css dir. That keeps the function + * independent of `process.cwd()`, and it also repairs specifiers that an older + * plugin version already baked into a shared cache entry as another checkout's + * absolute path. + */ +export function resolveCssId( + specifier: string, + importer: string | undefined, + distDir: string, +): DevupCssId | undefined { + const fileName = basename(specifier).split('?')[0] + if (!fileName || !cssFileName.test(fileName)) return undefined + + const resolvedDir = dirname( + importer ? resolve(dirname(importer), specifier) : resolve(specifier), + ) + if ( + basename(resolvedDir) !== cssDirName || + basename(dirname(resolvedDir)) !== distDir + ) + return undefined + + return { path: fileName, namespace: cssNamespace } +} diff --git a/packages/bun-plugin/src/plugin.ts b/packages/bun-plugin/src/plugin.ts index e4473c3d5..3738d5609 100644 --- a/packages/bun-plugin/src/plugin.ts +++ b/packages/bun-plugin/src/plugin.ts @@ -1,6 +1,6 @@ import { existsSync } from 'node:fs' import { mkdir, writeFile } from 'node:fs/promises' -import { basename, dirname, join, relative, resolve } from 'node:path' +import { dirname, join, relative, resolve } from 'node:path' import { createThemeInterfaceArgs, @@ -18,10 +18,12 @@ import { } from '@devup-ui/wasm' import { plugin } from 'bun' +import { cssDirName, cssNamespace, resolveCssId } from './css-id' + const libPackage = '@devup-ui/react' const devupFile = 'devup.json' const distDir = 'df' -const cssDir = resolve(distDir, 'devup-ui') +const cssDir = resolve(distDir, cssDirName) const singleCss = true const importAliases = mergeImportAliases() @@ -58,17 +60,12 @@ async function initialize({ shorthands }: DevupUIBunPluginOptions = {}) { await writeDataFiles() } -function resolveCssPath(path: string, importer?: string) { - const fileName = basename(path).split('?')[0] - const resolvedPath = importer - ? resolve(dirname(importer), path) - : resolve(path) - const expectedPath = resolve(join(cssDir, fileName)) - - if (!relative(resolvedPath, expectedPath) || path.startsWith(cssDir)) { - return { path: join(cssDir, fileName) } - } - return undefined +// Devup UI is a preprocessor: the stylesheet is a build artifact consumed by a +// bundler, and Bun's runtime has no CSS loader (`onLoad` only accepts the +// script/data loaders). The injected import exists so bundlers pick the +// stylesheet up, so under the Bun runtime it resolves to an empty module. +function loadCssModule() { + return { contents: '', loader: 'js' as const } } async function loadSourceFile(filePath: string) { @@ -112,10 +109,17 @@ function register(options: DevupUIBunPluginOptions = {}) { await initialize(options) setDebug(true) - // Resolve devup-ui CSS files + // Resolve devup-ui CSS files onto a path-free virtual id, so nothing + // derived from this checkout's cwd can be baked into Bun's shared, + // content-keyed transpiler cache. See ./css-id. build.onResolve( { filter: /devup-ui(-\d+)?\.css$/ }, - ({ path, importer }) => resolveCssPath(path, importer), + ({ path, importer }) => resolveCssId(path, importer, distDir), + ) + + // Serve the virtual stylesheet resolved above + build.onLoad({ filter: /.*/, namespace: cssNamespace }, () => + loadCssModule(), ) // Load source files from packages directory (file namespace) From 84136318807506192dd6d159c7666d425a63d4fa Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Wed, 9 Sep 2026 09:35:15 +0900 Subject: [PATCH 2/2] fix(rsbuild-plugin): emit the stylesheet import relative to the importer 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:\\...\\\\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. --- .../src/__tests__/checkout-isolation.test.ts | 101 ++++++++++++++++++ packages/rsbuild-plugin/src/plugin.ts | 39 ++++--- 2 files changed, 123 insertions(+), 17 deletions(-) create mode 100644 packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts diff --git a/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts new file mode 100644 index 000000000..b57d9ef2a --- /dev/null +++ b/packages/rsbuild-plugin/src/__tests__/checkout-isolation.test.ts @@ -0,0 +1,101 @@ +import * as fs from 'node:fs' +import * as fsPromises from 'node:fs/promises' +import { join } from 'node:path' + +import * as wasm from '@devup-ui/wasm' +import { + afterAll, + beforeAll, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' + +import { DevupUI } from '../plugin' + +type CodeExtractResult = ReturnType +type RsbuildPlugin = ReturnType +type RsbuildSetupContext = Parameters[0] + +// Two checkouts of one repository — git worktrees, a CI matrix, sibling clones. +// They hold byte-identical sources at identical repository-relative paths and +// differ only in their root. +const checkoutA = join('/repos', 'app', 'worktree-a') +const checkoutB = join('/repos', 'app', 'worktree-b') + +const source = `import { Box } from '@devup-ui/react' +const App = () => ` + +let codeExtractSpy: ReturnType + +beforeAll(() => { + spyOn(fs, 'existsSync').mockReturnValue(true) + spyOn(fsPromises, 'writeFile').mockResolvedValue(undefined) + spyOn(fsPromises, 'mkdir').mockResolvedValue(undefined) + spyOn(fsPromises, 'readFile').mockResolvedValue('{}') + spyOn(wasm, 'registerTheme').mockReturnValue(undefined) + spyOn(wasm, 'getThemeInterface').mockReturnValue('') + spyOn(wasm, 'getDefaultTheme').mockReturnValue(undefined) + spyOn(wasm, 'getCss').mockReturnValue('') + spyOn(wasm, 'setDebug').mockReturnValue(undefined) + codeExtractSpy = spyOn(wasm, 'codeExtract').mockReturnValue({ + code: '
', + css: '', + cssFile: 'devup-ui-0.css', + map: undefined, + updatedBaseStyle: false, + free: mock(), + [Symbol.dispose]: mock(), + } as unknown as CodeExtractResult) +}) + +afterAll(() => { + mock.restore() +}) + +/** + * Runs the code transform for a file inside `checkout` and returns the css dir + * the plugin handed to the extractor — the value that ends up as the stylesheet + * import specifier baked into the emitted module. + */ +async function extractedCssDirIn(checkout: string) { + const transform = mock() + const plugin = DevupUI({ + distDir: join(checkout, 'df'), + cssDir: join(checkout, 'df', 'devup-ui'), + }) + await plugin.setup({ + transform, + modifyRsbuildConfig: mock(), + } as unknown as RsbuildSetupContext) + + codeExtractSpy.mockClear() + await transform.mock.calls[1][1]({ + code: source, + resourcePath: join(checkout, 'src', 'App.tsx'), + }) + return codeExtractSpy.mock.calls[0][3] as string +} + +describe('checkout isolation', () => { + // Regression: the emitted stylesheet import must not depend on where the + // repository happens to be checked out. An absolute specifier makes the + // transform output differ per checkout for byte-identical input, which breaks + // every content-addressed or relocated build cache — the same defect that made + // `bun test` import another worktree's stylesheet. + it('emits the same, checkout-independent stylesheet import from any checkout', async () => { + const fromA = await extractedCssDirIn(checkoutA) + const fromB = await extractedCssDirIn(checkoutB) + + expect(fromA).toBe(fromB) + expect(fromA).not.toContain(checkoutA) + expect(fromB).not.toContain(checkoutB) + }) + + it('emits a relative specifier the bundler resolves from the importer', async () => { + // ./../df/devup-ui, relative to /src/App.tsx + expect(await extractedCssDirIn(checkoutA)).toBe('./../df/devup-ui') + }) +}) diff --git a/packages/rsbuild-plugin/src/plugin.ts b/packages/rsbuild-plugin/src/plugin.ts index 8d1997ecc..fedc42e2d 100644 --- a/packages/rsbuild-plugin/src/plugin.ts +++ b/packages/rsbuild-plugin/src/plugin.ts @@ -246,23 +246,28 @@ export const DevupUI = ({ async ({ code, resourcePath }) => { if (createNodeModulesExcludeRegex(include).test(resourcePath)) return code - // Atom mode mirrors vite: the entry CODE imports the shared base - // (import_main_css_in_code=true) so rspack emits devup-ui.css once and - // links it from every entry (hoisted atoms shared, not inlined). A - // relative cssDir is required for that code import to resolve, and the - // extraction filename is POSIX-normalized to match the absolute-keyed - // canonical map / FILE_ROUTES. Non-atom keeps the prior behavior. - let extractCssDir = cssDir - let extractName = resourcePath - if (atomMode) { - let relCssDir = relative(dirname(resourcePath), cssDir).replaceAll( - '\\', - '/', - ) - if (!relCssDir.startsWith('./')) relCssDir = `./${relCssDir}` - extractCssDir = relCssDir - extractName = resourcePath.replaceAll('\\', '/') - } + // The stylesheet import is emitted relative to the importing file, as + // in the next/webpack/vite loaders. An absolute cssDir would bake this + // checkout's path into the emitted module, so byte-identical sources + // in two checkouts (git worktrees, a CI matrix, sibling clones) would + // produce different output and any content-addressed or relocated + // build cache would serve the wrong checkout's stylesheet. + // + // Atom mode additionally mirrors vite: the entry CODE imports the + // shared base (import_main_css_in_code=true) so rspack emits + // devup-ui.css once and links it from every entry (hoisted atoms + // shared, not inlined), and the extraction filename is + // POSIX-normalized to match the absolute-keyed canonical map / + // FILE_ROUTES. + let extractCssDir = relative( + dirname(resourcePath), + cssDir, + ).replaceAll('\\', '/') + if (!extractCssDir.startsWith('./')) + extractCssDir = `./${extractCssDir}` + const extractName = atomMode + ? resourcePath.replaceAll('\\', '/') + : resourcePath const { code: retCode, css = '',