Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_vite_plugin_dev_css_loop.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"packages/vite-plugin/package.json": "Patch"
},
"note": "Stop the dev server from reloading in a loop with vinext and other server environments: write a devup-ui CSS file only when its CSS changed, and keep those changes out of the server environments so a render is never reset midway",
"date": "2026-09-27T15:43:21.4199154Z"
}
164 changes: 161 additions & 3 deletions packages/vite-plugin/src/__tests__/plugin.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,12 @@ interface ViteConfig {
define?: Record<string, string>
}

interface HotUpdateEnvironment {
config: { consumer: 'client' | 'server' }
moduleGraph: { invalidateModule: (...args: unknown[]) => void }
hot: { send: (...args: unknown[]) => void }
}

interface ViteTestPlugin {
name: string
sharedDuringBuild: true
Expand All @@ -55,6 +61,10 @@ interface ViteTestPlugin {
root?: string
}) => Promise<void>
watchChange: (id: string) => Promise<void>
hotUpdate: (
this: { environment: HotUpdateEnvironment },
options: { file: string; modules: object[]; timestamp: number },
) => Promise<unknown[] | undefined>
handleHotUpdate: (context: {
file: string
server: {
Expand Down Expand Up @@ -208,6 +218,7 @@ describe('devupUIVitePlugin', () => {
load: expect.any(Function),
watchChange: expect.any(Function),
handleHotUpdate: expect.any(Function),
hotUpdate: expect.any(Function),
enforce: 'pre',
transform: expect.any(Function),
apply: expect.any(Function),
Expand Down Expand Up @@ -905,6 +916,110 @@ describe('devupUIVitePlugin', () => {
expect(send).not.toHaveBeenCalled()
})

function createHotUpdateEnvironment(consumer: 'client' | 'server') {
return {
config: { consumer },
moduleGraph: { invalidateModule: mock() },
hot: { send: mock() },
}
}

it('should invalidate and reload the client on a devup.json change', async () => {
writeFileSpy.mockResolvedValueOnce(undefined)
getThemeInterfaceSpy.mockReturnValue('interface code')
existsSyncSpy.mockReturnValue(true)
readFileSpy.mockResolvedValueOnce(JSON.stringify({ theme: 'theme' }))
const environment = createHotUpdateEnvironment('client')
const module = {}
const plugin = createPlugin({})

const result = await plugin.hotUpdate.call(
{ environment },
{ file: 'devup.json', modules: [module], timestamp: 1 },
)

expect(writeFileSpy).toHaveBeenCalledWith(
join('df', 'theme.d.ts'),
'interface code',
'utf-8',
)
expect(environment.moduleGraph.invalidateModule).toHaveBeenCalledWith(
module,
expect.any(Set),
1,
true,
)
expect(environment.hot.send).toHaveBeenCalledWith({ type: 'full-reload' })
expect(result).toEqual([])
})

it.each([
['an unrelated file', 'other.json'],
// The client refreshes sheet contents through Vite's regular css HMR.
['a devup sheet', join(resolve('df', 'devup-ui'), 'devup-ui-3.css')],
])('should leave client hot updates of %s to vite', async (_name, file) => {
existsSyncSpy.mockReturnValue(true)
const environment = createHotUpdateEnvironment('client')
const plugin = createPlugin({})

const result = await plugin.hotUpdate.call(
{ environment },
{ file, modules: [{}], timestamp: 1 },
)

expect(result).toBeUndefined()
expect(writeFileSpy).not.toHaveBeenCalledWith(
join('df', 'theme.d.ts'),
expect.any(String),
'utf-8',
)
expect(environment.moduleGraph.invalidateModule).not.toHaveBeenCalled()
expect(environment.hot.send).not.toHaveBeenCalled()
})

// A module runner cannot apply CSS: Vite would restart the render with a
// full reload, and the modules transformed again would write again.
it.each(['devup-ui.css', 'devup-ui-3.css'])(
'should keep %s updates out of server environments',
async (fileName) => {
const environment = createHotUpdateEnvironment('server')
const plugin = createPlugin({})

const result = await plugin.hotUpdate.call(
{ environment },
{
file: join(resolve('df', 'devup-ui'), fileName),
modules: [{}],
timestamp: 1,
},
)

expect(result).toEqual([])
expect(environment.hot.send).not.toHaveBeenCalled()
},
)

it.each([
['a source module', join(resolve('src'), 'App.tsx')],
[
'an app sheet named like a devup sheet',
resolve('public', 'devup-ui.css'),
],
['devup.json', 'devup.json'],
])('should leave server hot updates of %s to vite', async (_name, file) => {
existsSyncSpy.mockReturnValue(true)
const environment = createHotUpdateEnvironment('server')
const plugin = createPlugin({})

const result = await plugin.hotUpdate.call(
{ environment },
{ file, modules: [{}], timestamp: 1 },
)

expect(result).toBeUndefined()
expect(environment.hot.send).not.toHaveBeenCalled()
})

it('should print error when watch change error', async () => {
writeFileSpy.mockResolvedValueOnce(undefined)
getThemeInterfaceSpy.mockReturnValue('interface code')
Expand Down Expand Up @@ -976,9 +1091,7 @@ describe('devupUIVitePlugin', () => {
)
expect(writeFileSpy).toHaveBeenCalledWith(
join(resolve('df', 'devup-ui'), 'devup-ui.css'),
expect.stringMatching(
/\/\* node_modules[/\\]@devup-ui[/\\]hello[/\\]index\.tsx \d+ \*\//,
),
'css code',
'utf-8',
)
expect(
Expand Down Expand Up @@ -1137,6 +1250,51 @@ describe('devupUIVitePlugin', () => {
expect(writeFileSpy).not.toHaveBeenCalled()
})

// Every write wakes the dev server's watcher, so a transform that leaves a
// sheet unchanged must not touch it: the reload the write causes would
// transform the module again, which would write again.
it('writes a sheet only when its css changes', async () => {
const plugin = createPlugin({})
const sheet = join(resolve('df', 'devup-ui'), 'devup-ui-3.css')
const transformWith = (css: string | undefined) => {
codeExtractSpy.mockReturnValue(
createCodeExtractResult({ css, cssFile: 'devup-ui-3.css' }),
)
return plugin.transform('code', 'foo.tsx')
}

await transformWith('.a{color:red}')
await transformWith('.a{color:red}')
// `css` is unset when the transform added no styles
await transformWith(undefined)
expect(writeFileSpy.mock.calls).toEqual([[sheet, '.a{color:red}', 'utf-8']])

await transformWith('.a{color:red}.b{color:blue}')
expect(writeFileSpy.mock.calls).toEqual([
[sheet, '.a{color:red}', 'utf-8'],
[sheet, '.a{color:red}.b{color:blue}', 'utf-8'],
])
})

it('writes the base sheet only when it changes', async () => {
const plugin = createPlugin({})
const baseSheet = join(resolve('df', 'devup-ui'), 'devup-ui.css')
codeExtractSpy.mockReturnValue(
createCodeExtractResult({ cssFile: '', updatedBaseStyle: true }),
)

getCssSpy.mockReturnValue('*{margin:0}')
await plugin.transform('code', 'layout.tsx')
await plugin.transform('code', 'layout.tsx')
getCssSpy.mockReturnValue('*{margin:0}body{font-family:Pretendard}')
await plugin.transform('code', 'layout.tsx')

expect(writeFileSpy.mock.calls).toEqual([
[baseSheet, '*{margin:0}', 'utf-8'],
[baseSheet, '*{margin:0}body{font-family:Pretendard}', 'utf-8'],
])
})

it('should not generate bundle when css file is not found', async () => {
const plugin = createPlugin({})
const bundle = {}
Expand Down
69 changes: 58 additions & 11 deletions packages/vite-plugin/src/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ import {
setDebug,
setPrefix,
} from '@devup-ui/wasm'
import type { ModuleNode, PluginOption, UserConfig } from 'vite'
import type {
EnvironmentModuleNode,
ModuleNode,
PluginOption,
UserConfig,
} from 'vite'

/**
* CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ...
Expand Down Expand Up @@ -271,6 +276,15 @@ export function DevupUI({
const cssMap = new Map()
let serverBundleToForward: Record<string, ViteOutputWithMetadata> | undefined
let isServe = false
// The dev server watches cssDir, so every write is an update signal. A
// module transformed again writes its sheet again, and the reload that
// signal causes transforms it once more: signal only a changed sheet.
const writtenCss = new Map<string, string>()
function writeCssFile(fileName: string, css: string): Promise<void> {
if (writtenCss.get(fileName) === css) return Promise.resolve()
writtenCss.set(fileName, css)
return writeFile(join(cssDir, fileName), css, 'utf-8')
}
return {
name: 'devup-ui',
// The WASM sheet and transform state are intentionally shared. Vite
Expand Down Expand Up @@ -403,6 +417,46 @@ export function DevupUI({
}
}
},
// Runs once per environment. Vite 6+ ignores `handleHotUpdate` on a plugin
// that defines this hook, so the devup.json reload lives here as well.
async hotUpdate({ file, modules, timestamp }) {
const { environment } = this
if (environment.config.consumer === 'server') {
// A module runner cannot apply CSS, so Vite answers a sheet change
// with a full reload: the render restarts mid-request, and the modules
// it transforms again write their sheets again. Server environments
// only reference sheets by URL; the client refreshes their contents.
const fileName = basename(file)
return DEVUP_CSS_FILE_RE.test(fileName) &&
resolve(file) === resolve(cssDir, fileName)
? []
: undefined
}
if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) {
return
}

await writeDataFiles({
package: libPackage,
cssDir,
devupFile,
distDir,
singleCss,
})

const invalidatedModules = new Set<EnvironmentModuleNode>()
for (const mod of modules) {
environment.moduleGraph.invalidateModule(
mod,
invalidatedModules,
timestamp,
true,
)
}
environment.hot.send({ type: 'full-reload' })
return []
},
// Vite 5 fallback: Vite 6+ does not call this hook when `hotUpdate` exists.
async handleHotUpdate({ file, server, modules, timestamp }) {
if (resolve(file) !== resolve(devupFile) || !existsSync(devupFile)) {
return
Expand Down Expand Up @@ -492,22 +546,15 @@ export function DevupUI({

if (updatedBaseStyle) {
// update base style
promises.push(
writeFile(join(cssDir, 'devup-ui.css'), getCss(null, false), 'utf-8'),
)
promises.push(writeCssFile('devup-ui.css', getCss(null, false)))
}

if (cssFile) {
const fileNum = getFileNumByFilename(cssFile)
const prevCss = cssMap.get(fileNum)
if (prevCss && prevCss.length < css.length) cssMap.set(fileNum, css)
promises.push(
writeFile(
join(cssDir, basename(cssFile)),
`/* ${id} ${Date.now()} */`,
'utf-8',
),
)
// `css` is only set when this transform added styles to the sheet.
if (css) promises.push(writeCssFile(basename(cssFile), css))
}
await Promise.all(promises)
return {
Expand Down
Loading