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) 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 = '',