From cd9375146116666e48897ae696993d15a38cb494 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 15 Nov 2026 18:06:58 +0900 Subject: [PATCH 1/2] fix(vite-plugin): complete every environment's sheets after the app build Vite builds the environments of one app (client, ssr, ...) one after another on one shared engine. Each environment emits its devup-ui sheets as they stand when it finishes, so a style only a later environment extracts (a module only the server renders) never reached the client CSS, and the output depended on the build order. Record what each environment emitted of every devup-ui sheet, and in a post buildApp hook, once every environment is built, build again each environment whose sheets changed since, after removing the files it wrote before. Every module is extracted by then, so a single pass leaves each sheet final. Co-Authored-By: Claude Opus 5.5 --- ..._log_vite_complete_environment_sheets.json | 5 + .../src/__tests__/environment-sheets-build.ts | 87 ++++++++ .../src/__tests__/environment-sheets.test.ts | 186 ++++++++++++++++++ .../vite-plugin/src/__tests__/plugin.test.ts | 25 ++- .../vite-plugin/src/environment-sheets.ts | 76 +++++++ packages/vite-plugin/src/plugin.ts | 10 +- 6 files changed, 387 insertions(+), 2 deletions(-) create mode 100644 .changepacks/changepack_log_vite_complete_environment_sheets.json create mode 100644 packages/vite-plugin/src/__tests__/environment-sheets-build.ts create mode 100644 packages/vite-plugin/src/__tests__/environment-sheets.test.ts create mode 100644 packages/vite-plugin/src/environment-sheets.ts diff --git a/.changepacks/changepack_log_vite_complete_environment_sheets.json b/.changepacks/changepack_log_vite_complete_environment_sheets.json new file mode 100644 index 000000000..e7fa53fa3 --- /dev/null +++ b/.changepacks/changepack_log_vite_complete_environment_sheets.json @@ -0,0 +1,5 @@ +{ + "changes": { "packages/vite-plugin/package.json": "Patch" }, + "note": "Build again, once every environment is built, each Vite environment whose devup-ui sheets changed since, so client CSS also holds styles only a later environment (such as ssr) extracted, whatever the build order", + "date": "2026-11-15T00:00:00.000Z" +} diff --git a/packages/vite-plugin/src/__tests__/environment-sheets-build.ts b/packages/vite-plugin/src/__tests__/environment-sheets-build.ts new file mode 100644 index 000000000..9e54f2896 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/environment-sheets-build.ts @@ -0,0 +1,87 @@ +// Builds a client and an ssr environment on one shared engine, in the order +// given, and prints the CSS each environment wrote. +import { + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { dirname, join } from 'node:path' + +import { createBuilder } from 'vite' + +import { DevupUI } from '../plugin' + +const [root, order, mode] = process.argv.slice(2) as [string, string, string] +rmSync(root, { recursive: true, force: true }) +const put = (path: string, code: string) => { + const file = join(root, path) + mkdirSync(dirname(file), { recursive: true }) + writeFileSync(file, code) +} +const style = (name: string, color: string) => + `export const ${name} = css({ color: '${color}' })` +put('devup.json', '{}') +put('package.json', '{"type":"module"}') +put('index.html', '') +put( + 'src/client.js', + `import { css } from '@devup-ui/react'\nimport { shared } from './shared.js'\n${style('client', 'red')}\nconsole.log(shared, client)`, +) +put( + 'src/shared.js', + `import { css } from '@devup-ui/react'\n${style('shared', 'blue')}`, +) +// Only the server renders this module, so only the ssr build extracts it +put( + 'src/server.js', + `import { css } from '@devup-ui/react'\nimport { shared } from './shared.js'\n${style('server', 'green')}\nexport const render = () => shared + server`, +) +process.chdir(root) +const builder = await createBuilder({ + configFile: false, + root, + logLevel: 'silent', + plugins: [ + DevupUI({ + engine: 'wasm', + distDir: join(root, 'df'), + devupFile: join(root, 'devup.json'), + singleCss: mode === 'single', + }), + ], + environments: { + client: { + build: { + outDir: 'dist/client', + rollupOptions: { input: join(root, 'index.html') }, + }, + }, + ssr: { + build: { + outDir: 'dist/server', + ssr: true, + emitAssets: true, + rollupOptions: { input: join(root, 'src/server.js') }, + }, + }, + }, + builder: { + async buildApp(app) { + for (const name of order.split(',')) + await app.build(app.environments[name]!) + }, + }, +}) +await builder.buildApp() +const css = (environment: string) => { + const dir = join(root, 'dist', environment, 'assets') + return readdirSync(dir) + .filter((file) => file.endsWith('.css')) + .map((file) => readFileSync(join(dir, file), 'utf8')) + .sort() +} +process.stdout.write( + JSON.stringify({ client: css('client'), server: css('server') }), +) diff --git a/packages/vite-plugin/src/__tests__/environment-sheets.test.ts b/packages/vite-plugin/src/__tests__/environment-sheets.test.ts new file mode 100644 index 000000000..615347008 --- /dev/null +++ b/packages/vite-plugin/src/__tests__/environment-sheets.test.ts @@ -0,0 +1,186 @@ +import { spawnSync } from 'node:child_process' +import { + existsSync, + mkdirSync, + mkdtempSync, + realpathSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { afterEach, expect, it } from 'bun:test' +import type { Plugin, ViteBuilder } from 'vite' + +import { createEnvironmentSheets } from '../environment-sheets' + +type WriteBundle = ( + this: { environment?: { name: string } }, + options: { dir?: string }, + bundle: Record, +) => void +type BuildApp = { + order: string + handler: (builder: ViteBuilder) => Promise +} + +const dirs: string[] = [] +afterEach(() => { + for (const dir of dirs.splice(0)) + rmSync(dir, { recursive: true, force: true }) +}) + +function hooks(plugin: Plugin) { + return { + writeBundle: plugin.writeBundle as unknown as WriteBundle, + buildApp: plugin.buildApp as unknown as BuildApp, + } +} + +function createBuilder(names: string[], built: string[] = []) { + const order: string[] = [] + const environments = Object.fromEntries( + names.map((name) => [name, { name, isBuilt: built.includes(name) }]), + ) + const builder = { + environments, + build: async (environment: { name: string; isBuilt: boolean }) => { + order.push(environment.name) + environment.isBuilt = true + }, + } as unknown as ViteBuilder + return { builder, order } +} + +it('is a build-only plugin shared by every environment, run after buildApp', () => { + // Given + const { plugin } = createEnvironmentSheets(() => '') + // Then + expect(plugin.name).toBe('devup-ui:complete-environment-sheets') + expect(plugin.apply).toBe('build') + expect(plugin.sharedDuringBuild).toBe(true) + expect(hooks(plugin).buildApp.order).toBe('post') +}) + +it('builds every environment once when no sheet changed', async () => { + // Given + const sheets = createEnvironmentSheets(() => 'a{}') + const { builder, order } = createBuilder(['client', 'ssr']) + // When + await hooks(sheets.plugin).buildApp.handler(builder) + // Then + expect(order).toEqual(['client', 'ssr']) +}) + +it('leaves environments already built by an earlier buildApp hook', async () => { + // Given + const sheets = createEnvironmentSheets(() => 'a{}') + const { builder, order } = createBuilder(['client', 'ssr'], ['client']) + // When + await hooks(sheets.plugin).buildApp.handler(builder) + // Then + expect(order).toEqual([]) +}) + +it('builds again only the environments whose sheets changed since', async () => { + // Given + let css = 'a{}' + const sheets = createEnvironmentSheets(() => css) + const { builder, order } = createBuilder(['client', 'ssr']) + const build = builder.build.bind(builder) + builder.build = async (environment) => { + await build(environment) + // The server extracts a module the client never imports + if (environment.name === 'ssr') css = 'a{}b{}' + sheets.emit(environment.name).set(null, css) + } + // When + await hooks(sheets.plugin).buildApp.handler(builder) + // Then + expect(order).toEqual(['client', 'ssr', 'client']) +}) + +it('removes the output an environment wrote before building it again', async () => { + // Given + const dir = mkdtempSync(join(tmpdir(), 'devup-environment-sheets-')) + dirs.push(dir) + mkdirSync(join(dir, 'assets')) + writeFileSync(join(dir, 'assets', 'devup-ui-a1.css'), 'a{}') + writeFileSync(join(dir, 'index.html'), '') + let css = 'a{}' + const sheets = createEnvironmentSheets(() => css) + const { writeBundle, buildApp } = hooks(sheets.plugin) + writeBundle.call( + { environment: { name: 'client' } }, + { dir }, + { 'assets/devup-ui-a1.css': {}, 'index.html': {} }, + ) + sheets.emit('client').set(1, css) + css = 'a{}b{}' + const { builder } = createBuilder(['client', 'ssr'], ['client', 'ssr']) + const exists: boolean[] = [] + const build = builder.build.bind(builder) + builder.build = async (environment) => { + exists.push(existsSync(join(dir, 'assets', 'devup-ui-a1.css'))) + exists.push(existsSync(join(dir, 'index.html'))) + await build(environment) + } + // When + await buildApp.handler(builder) + // Then + expect(exists).toEqual([false, false]) +}) + +it('builds again an environment whose output was not written', async () => { + // Given + let css = 'a{}' + const sheets = createEnvironmentSheets(() => css) + const { writeBundle, buildApp } = hooks(sheets.plugin) + writeBundle.call({ environment: { name: 'client' } }, {}, { 'a.css': {} }) + writeBundle.call({}, { dir: 'dist' }, { 'a.css': {} }) + sheets.emit('client').set(1, css) + css = 'b{}' + const { builder, order } = createBuilder(['client'], ['client']) + // When + await buildApp.handler(builder) + // Then + expect(order).toEqual(['client']) +}) + +function build(order: string, mode: string) { + // Vite cannot resolve modules under a Windows 8.3 short path + const root = join( + realpathSync.native(tmpdir()), + `devup-environment-sheets-${order.replace(',', '-')}-${mode}`, + ) + dirs.push(root) + const result = spawnSync( + process.execPath, + [join(import.meta.dir, 'environment-sheets-build.ts'), root, order, mode], + { encoding: 'utf8' }, + ) + expect(result.stderr).toBe('') + return JSON.parse(result.stdout) as { client: string[]; server: string[] } +} + +it.each(['single', 'split'])( + 'gives every environment the styles of its sheets whatever the build order (%s)', + (mode) => { + // When + const clientFirst = build('client,ssr', mode) + const ssrFirst = build('ssr,client', mode) + // Then + if (mode === 'single') { + expect(clientFirst.client.join()).toContain('color:green') + expect(clientFirst).toEqual(ssrFirst) + } + for (const output of [clientFirst, ssrFirst]) { + expect(output.client.join()).toContain('color:red') + expect(output.client.join()).toContain('color:blue') + expect(output.server.join()).toContain('color:green') + expect(output.server.join()).toContain('color:blue') + } + }, + 120_000, +) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index cbc3643da..871cfe415 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -13,7 +13,7 @@ import { mock, spyOn, } from 'bun:test' -import type { Plugin } from 'vite' +import type { Plugin, ViteBuilder } from 'vite' import { DevupUI } from '../plugin' @@ -787,6 +787,29 @@ describe('devupUIVitePlugin', () => { expect(bundle['chunk.js']).not.toHaveProperty('source') }) + it('builds an environment again once a sheet it emitted has grown', async () => { + getCssSpy.mockReturnValue('a{}') + const plugins = DevupUI({ engine: 'wasm' }) as unknown as [ + ViteTestPlugin, + ViteTestRestorePlugin, + { buildApp: { handler: (builder: ViteBuilder) => Promise } }, + ] + activePlugins.push(plugins[0]) + await plugins[0].generateBundle({}, { + 'base.css': { source: 'stale', name: 'devup-ui.css' }, + } as unknown as Record) + getCssSpy.mockReturnValue('a{}b{}') + const client = { name: 'client', isBuilt: true } + const build = mock(async () => {}) + + await plugins[2].buildApp.handler({ + environments: { client }, + build, + } as unknown as ViteBuilder) + + expect(build).toHaveBeenCalledWith(client) + }) + it('leaves an app asset whose name merely ends in devup-ui.css alone', async () => { getCssSpy.mockImplementation( (fileNum: number | null) => `sheet:${fileNum}`, diff --git a/packages/vite-plugin/src/environment-sheets.ts b/packages/vite-plugin/src/environment-sheets.ts new file mode 100644 index 000000000..776887634 --- /dev/null +++ b/packages/vite-plugin/src/environment-sheets.ts @@ -0,0 +1,76 @@ +import { rm } from 'node:fs/promises' +import { join } from 'node:path' + +import type { Plugin } from 'vite' + +type Sheets = Map + +/** + * The environments of one app build (client, ssr, ...) share one engine, and + * Vite builds them one after another. A sheet an environment emits holds the + * styles extracted so far, so a style that only a later environment extracts + * (a module only the server renders) never reaches it, and what each + * environment holds depends on the order they build in. + * + * This records what each environment emitted of every devup-ui sheet, and + * once every environment is built, builds again each one whose sheets have + * changed since. Every module has been extracted by then, so the sheets of + * that build are final: each environment ends up holding every style of the + * sheets it emits, whatever the order. + */ +export function createEnvironmentSheets( + read: (fileNum: number | null) => string, +) { + const emitted = new Map() + const written = new Map() + const changed = (name: string) => + [...(emitted.get(name) ?? [])].some( + ([fileNum, css]) => read(fileNum) !== css, + ) + const plugin: Plugin = { + name: 'devup-ui:complete-environment-sheets', + sharedDuringBuild: true, + apply: 'build', + writeBundle(options, bundle) { + const dir = options.dir + if (!dir || !this.environment) return + written.set( + this.environment.name, + Object.keys(bundle).map((file) => join(dir, file)), + ) + }, + buildApp: { + order: 'post', + async handler(builder) { + const environments = Object.values(builder.environments) + // Vite builds every environment after these hooks unless one is built + // already; build them here so that the sheets are checked after all + if (environments.every((environment) => !environment.isBuilt)) + for (const environment of environments) + await builder.build(environment) + // A build again extracts the modules it extracted before, adding no + // style, so a single pass leaves every sheet final + for (const environment of environments.filter(({ name }) => + changed(name), + )) { + // Its output is written again in full, under names that can change + await Promise.all( + (written.get(environment.name) ?? []).map((file) => + rm(file, { force: true }), + ), + ) + await builder.build(environment) + } + }, + }, + } + return { + /** The sheets one build of `environment` emits, recorded from now on. */ + emit(environment: string): Sheets { + const sheets: Sheets = new Map() + emitted.set(environment, sheets) + return sheets + }, + plugin, + } +} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index f9ce736fc..cbd288abb 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -36,6 +36,7 @@ import type { } from 'vite' import { createCompiledDependencies } from './compiled-dependencies' +import { createEnvironmentSheets } from './environment-sheets' import { createViteReservation } from './reservation' import { createServerWatch } from './server-watch' @@ -386,6 +387,9 @@ export function DevupUI({ compiledDependencies.hasSupplier(file), }) const cssMap = new Map() + const environmentSheets = createEnvironmentSheets((fileNum) => + compiler.getCss(fileNum, false), + ) const compiledDependencies = createCompiledDependencies() let stopWatching: (() => void) | undefined let watcherSession: ExtractionSession | undefined @@ -862,6 +866,7 @@ export function DevupUI({ if (!extractCss) return const writesOutput = this.environment?.config.build?.write !== false const cssFiles = new Set() + const sheets = environmentSheets.emit(this.environment?.name ?? 'client') // `load` can only snapshot the sheet as it stood when the module was // pulled in, and module order varies per build, so the emitted asset was @@ -874,8 +879,10 @@ export function DevupUI({ const cssName = getDevupCssChunkName(asset.name) if (!cssName) continue if (!('source' in asset)) continue - const source = compiler.getCss(getFileNumByFilename(cssName), false) + const fileNum = getFileNumByFilename(cssName) + const source = compiler.getCss(fileNum, false) asset.source = source + sheets.set(fileNum, source) cssFiles.add(file) } @@ -914,5 +921,6 @@ export function DevupUI({ transform: own(plugin.transform), }, restorePlugin, + environmentSheets.plugin, ] } From 0f2827e017faa02da29da247f79bcea7e89cea52 Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Sun, 15 Nov 2026 21:20:26 +0900 Subject: [PATCH 2/2] test(vite-plugin): compare every environment's sheets in the order matrix An environment whose sheets changed after the app build is now built again, and only in one order, so the matrix compares the extractions once each. The sheets each environment emits no longer depend on the order, so the environments scenario compares the written assets too. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/reservation-native-matrix.test.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/packages/vite-plugin/src/__tests__/reservation-native-matrix.test.ts b/packages/vite-plugin/src/__tests__/reservation-native-matrix.test.ts index 9d77826cf..9eb83b745 100644 --- a/packages/vite-plugin/src/__tests__/reservation-native-matrix.test.ts +++ b/packages/vite-plugin/src/__tests__/reservation-native-matrix.test.ts @@ -180,9 +180,14 @@ for (const scenario of [ row.updatedBaseStyle, ]) .sort() - assert.deepEqual(outputs(ab), outputs(ba)) - if (scenario !== 'environments') - assert.deepEqual(ab.assets, ba.assets) + // An environment whose sheets changed after the app build is built + // again, and only in one order: compare what was extracted, once + const unique = (rows: ReturnType) => + scenario === 'environments' + ? [...new Set(rows.map((row) => JSON.stringify(row)))] + : rows + assert.deepEqual(unique(outputs(ab)), unique(outputs(ba))) + assert.deepEqual(ab.assets, ba.assets) assert.equal(ab.css, ba.css) if (scenario === 'history') { const rows = (value: typeof ab) =>