diff --git a/.changepacks/changepack_log_vite_plugin_dev_css_loop.json b/.changepacks/changepack_log_vite_plugin_dev_css_loop.json new file mode 100644 index 00000000..7d368ddc --- /dev/null +++ b/.changepacks/changepack_log_vite_plugin_dev_css_loop.json @@ -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" +} diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 53399fe8..4bbde6e0 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -41,6 +41,12 @@ interface ViteConfig { define?: Record } +interface HotUpdateEnvironment { + config: { consumer: 'client' | 'server' } + moduleGraph: { invalidateModule: (...args: unknown[]) => void } + hot: { send: (...args: unknown[]) => void } +} + interface ViteTestPlugin { name: string sharedDuringBuild: true @@ -55,6 +61,10 @@ interface ViteTestPlugin { root?: string }) => Promise watchChange: (id: string) => Promise + hotUpdate: ( + this: { environment: HotUpdateEnvironment }, + options: { file: string; modules: object[]; timestamp: number }, + ) => Promise handleHotUpdate: (context: { file: string server: { @@ -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), @@ -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') @@ -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( @@ -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 = {} diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index 4d39cb6a..c053bf58 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -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`, ... @@ -271,6 +276,15 @@ export function DevupUI({ const cssMap = new Map() let serverBundleToForward: Record | 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() + function writeCssFile(fileName: string, css: string): Promise { + 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 @@ -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() + 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 @@ -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 {