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/__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) => 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, ] }