From a90583063d0ca2a462fffa6b754e4ffff5fa9dfa Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 28 Sep 2026 00:48:03 +0900 Subject: [PATCH 1/2] fix(vite-plugin): stop dev CSS writes from reloading server environments In dev the plugin wrote `/* */` into df/devup-ui/*.css on every transform, in every environment. The write is how the dev server learns that a sheet changed, but its content always differed, so a transform that added nothing still signalled a change. A module runner cannot apply CSS, so the rsc and ssr environments answered each signal with a full reload, re-imported their entries, transformed the importers again and wrote again: an endless rsc:update loop while a tab is open, and a 500 when the reload lands during the first render. Write a sheet only when its CSS changed (the transform's `css` is only set when it added styles), and return [] from `hotUpdate` for devup-ui sheets in server environments. Those only reference sheets by URL; the client environment still refreshes their contents through Vite's CSS HMR, which is also what keeps globalCss edits live. Defining `hotUpdate` makes Vite 6+ skip `handleHotUpdate`, so the devup.json reload moves into it for the client environment. --- ...angepack_log_vite_plugin_dev_css_loop.json | 7 + .../vite-plugin/src/__tests__/plugin.test.ts | 169 +++++++++++++----- packages/vite-plugin/src/plugin.ts | 46 +++-- 3 files changed, 165 insertions(+), 57 deletions(-) create mode 100644 .changepacks/changepack_log_vite_plugin_dev_css_loop.json 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..597d7e1b 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,17 +61,10 @@ interface ViteTestPlugin { root?: string }) => Promise watchChange: (id: string) => Promise - handleHotUpdate: (context: { - file: string - server: { - moduleGraph: { - invalidateModule: (...args: unknown[]) => void - } - ws: { send: (...args: unknown[]) => void } - } - modules: object[] - timestamp: number - }) => Promise + hotUpdate: ( + this: { environment: HotUpdateEnvironment }, + options: { file: string; modules: object[]; timestamp: number }, + ) => Promise load: (id: string) => string | undefined transform: ( this: { @@ -207,7 +206,7 @@ describe('devupUIVitePlugin', () => { config: expect.any(Function), 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), @@ -844,56 +843,56 @@ describe('devupUIVitePlugin', () => { await plugin.watchChange('wrong') }) + function createHotUpdateEnvironment(consumer: 'client' | 'server') { + return { + config: { consumer }, + moduleGraph: { invalidateModule: mock() }, + hot: { send: mock() }, + } + } + it('should invalidate and reload on devup hot update', async () => { writeFileSpy.mockResolvedValueOnce(undefined) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockReturnValue(true) readFileSpy.mockResolvedValueOnce(JSON.stringify({ theme: 'theme' })) - const invalidateModule = mock() - const send = mock() + const environment = createHotUpdateEnvironment('client') const module = {} const plugin = createPlugin({}) - const result = await plugin.handleHotUpdate({ - file: 'devup.json', - server: { - moduleGraph: { invalidateModule }, - ws: { send }, - }, - modules: [module], - timestamp: 1, - }) + 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(invalidateModule).toHaveBeenCalledWith( + expect(environment.moduleGraph.invalidateModule).toHaveBeenCalledWith( module, expect.any(Set), 1, true, ) - expect(send).toHaveBeenCalledWith({ type: 'full-reload' }) + expect(environment.hot.send).toHaveBeenCalledWith({ type: 'full-reload' }) expect(result).toEqual([]) }) - it('should skip hot update for unrelated files', async () => { + 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 invalidateModule = mock() - const send = mock() + const environment = createHotUpdateEnvironment('client') const plugin = createPlugin({}) - const result = await plugin.handleHotUpdate({ - file: 'other.json', - server: { - moduleGraph: { invalidateModule }, - ws: { send }, - }, - modules: [], - timestamp: 1, - }) + const result = await plugin.hotUpdate.call( + { environment }, + { file, modules: [{}], timestamp: 1 }, + ) expect(result).toBeUndefined() expect(writeFileSpy).not.toHaveBeenCalledWith( @@ -901,8 +900,51 @@ describe('devupUIVitePlugin', () => { expect.any(String), 'utf-8', ) - expect(invalidateModule).not.toHaveBeenCalled() - expect(send).not.toHaveBeenCalled() + 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 () => { @@ -976,9 +1018,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 +1177,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..b37ef8d3 100644 --- a/packages/vite-plugin/src/plugin.ts +++ b/packages/vite-plugin/src/plugin.ts @@ -31,7 +31,7 @@ import { setDebug, setPrefix, } from '@devup-ui/wasm' -import type { ModuleNode, PluginOption, UserConfig } from 'vite' +import type { EnvironmentModuleNode, PluginOption, UserConfig } from 'vite' /** * CSS entry files emitted by devup-ui: `devup-ui.css`, `devup-ui-3.css`, ... @@ -271,6 +271,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,7 +412,21 @@ export function DevupUI({ } } }, - async handleHotUpdate({ file, server, modules, timestamp }) { + // 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 } @@ -416,16 +439,16 @@ export function DevupUI({ singleCss, }) - const invalidatedModules = new Set() + const invalidatedModules = new Set() for (const mod of modules) { - server.moduleGraph.invalidateModule( + environment.moduleGraph.invalidateModule( mod, invalidatedModules, timestamp, true, ) } - server.ws.send({ type: 'full-reload' }) + environment.hot.send({ type: 'full-reload' }) return [] }, resolveId(id, importer) { @@ -492,22 +515,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 { From 713b31fcccde3b98f58d887c9e1d69568b139f6b Mon Sep 17 00:00:00 2001 From: owjs3901 Date: Mon, 28 Sep 2026 01:09:21 +0900 Subject: [PATCH 2/2] fix(vite-plugin): keep handleHotUpdate as the Vite 5 fallback Vite 5 has no hotUpdate hook, so removing handleHotUpdate dropped the devup.json full reload there. Vite 6+ calls handleHotUpdate only for a plugin without hotUpdate, so keeping both changes nothing on newer Vite. --- .../vite-plugin/src/__tests__/plugin.test.ts | 75 ++++++++++++++++++- packages/vite-plugin/src/plugin.ts | 33 +++++++- 2 files changed, 106 insertions(+), 2 deletions(-) diff --git a/packages/vite-plugin/src/__tests__/plugin.test.ts b/packages/vite-plugin/src/__tests__/plugin.test.ts index 597d7e1b..4bbde6e0 100644 --- a/packages/vite-plugin/src/__tests__/plugin.test.ts +++ b/packages/vite-plugin/src/__tests__/plugin.test.ts @@ -65,6 +65,17 @@ interface ViteTestPlugin { this: { environment: HotUpdateEnvironment }, options: { file: string; modules: object[]; timestamp: number }, ) => Promise + handleHotUpdate: (context: { + file: string + server: { + moduleGraph: { + invalidateModule: (...args: unknown[]) => void + } + ws: { send: (...args: unknown[]) => void } + } + modules: object[] + timestamp: number + }) => Promise load: (id: string) => string | undefined transform: ( this: { @@ -206,6 +217,7 @@ describe('devupUIVitePlugin', () => { config: expect.any(Function), load: expect.any(Function), watchChange: expect.any(Function), + handleHotUpdate: expect.any(Function), hotUpdate: expect.any(Function), enforce: 'pre', transform: expect.any(Function), @@ -843,6 +855,67 @@ describe('devupUIVitePlugin', () => { await plugin.watchChange('wrong') }) + it('should invalidate and reload on devup hot update', async () => { + writeFileSpy.mockResolvedValueOnce(undefined) + getThemeInterfaceSpy.mockReturnValue('interface code') + existsSyncSpy.mockReturnValue(true) + readFileSpy.mockResolvedValueOnce(JSON.stringify({ theme: 'theme' })) + const invalidateModule = mock() + const send = mock() + const module = {} + const plugin = createPlugin({}) + + const result = await plugin.handleHotUpdate({ + file: 'devup.json', + server: { + moduleGraph: { invalidateModule }, + ws: { send }, + }, + modules: [module], + timestamp: 1, + }) + + expect(writeFileSpy).toHaveBeenCalledWith( + join('df', 'theme.d.ts'), + 'interface code', + 'utf-8', + ) + expect(invalidateModule).toHaveBeenCalledWith( + module, + expect.any(Set), + 1, + true, + ) + expect(send).toHaveBeenCalledWith({ type: 'full-reload' }) + expect(result).toEqual([]) + }) + + it('should skip hot update for unrelated files', async () => { + existsSyncSpy.mockReturnValue(true) + const invalidateModule = mock() + const send = mock() + const plugin = createPlugin({}) + + const result = await plugin.handleHotUpdate({ + file: 'other.json', + server: { + moduleGraph: { invalidateModule }, + ws: { send }, + }, + modules: [], + timestamp: 1, + }) + + expect(result).toBeUndefined() + expect(writeFileSpy).not.toHaveBeenCalledWith( + join('df', 'theme.d.ts'), + expect.any(String), + 'utf-8', + ) + expect(invalidateModule).not.toHaveBeenCalled() + expect(send).not.toHaveBeenCalled() + }) + function createHotUpdateEnvironment(consumer: 'client' | 'server') { return { config: { consumer }, @@ -851,7 +924,7 @@ describe('devupUIVitePlugin', () => { } } - it('should invalidate and reload on devup hot update', async () => { + it('should invalidate and reload the client on a devup.json change', async () => { writeFileSpy.mockResolvedValueOnce(undefined) getThemeInterfaceSpy.mockReturnValue('interface code') existsSyncSpy.mockReturnValue(true) diff --git a/packages/vite-plugin/src/plugin.ts b/packages/vite-plugin/src/plugin.ts index b37ef8d3..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 { EnvironmentModuleNode, 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`, ... @@ -451,6 +456,32 @@ export function DevupUI({ 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 + } + + await writeDataFiles({ + package: libPackage, + cssDir, + devupFile, + distDir, + singleCss, + }) + + const invalidatedModules = new Set() + for (const mod of modules) { + server.moduleGraph.invalidateModule( + mod, + invalidatedModules, + timestamp, + true, + ) + } + server.ws.send({ type: 'full-reload' }) + return [] + }, resolveId(id, importer) { const fileName = basename(id).split('?')[0] if (