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,
]
}