Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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"
}
87 changes: 87 additions & 0 deletions packages/vite-plugin/src/__tests__/environment-sheets-build.ts
Original file line number Diff line number Diff line change
@@ -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', '<script type="module" src="/src/client.js"></script>')
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') }),
)
186 changes: 186 additions & 0 deletions packages/vite-plugin/src/__tests__/environment-sheets.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>,
) => void
type BuildApp = {
order: string
handler: (builder: ViteBuilder) => Promise<void>
}

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,
)
25 changes: 24 additions & 1 deletion packages/vite-plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<void> } },
]
activePlugins.push(plugins[0])
await plugins[0].generateBundle({}, {
'base.css': { source: 'stale', name: 'devup-ui.css' },
} as unknown as Record<string, { source: string; name: string }>)
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}`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof outputs>) =>
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) =>
Expand Down
Loading
Loading