Skip to content

fix(vite-plugin): stop dev CSS writes from reloading server environments - #679

Merged
owjs3901 merged 2 commits into
mainfrom
owjs3901/vite-plugin-dev-css-loop
Sep 28, 2026
Merged

owjs3901 merged 2 commits into
mainfrom
owjs3901/vite-plugin-dev-css-loop

Conversation

@owjs3901

@owjs3901 owjs3901 commented Sep 27, 2026 •

Copy link
Copy Markdown
Contributor

문제

vinext(Vite environment API: client / ssr / rsc)로 dev 서버를 띄우고 탭을 열어 두면, 아무것도 편집하지 않아도 rsc:update / css-update / rsc:prune가 끝없이 반복됩니다. 기동 직후 첫 요청은 500이 납니다. service-template #11과 braillify-studio #49에서 드러났고, 템플릿 main(vite 8.2.2, vinext beta.8, 1.0.69)에서도 재현됩니다.

원인

격리 복사본의 플러그인 dist와 vite / plugin-rsc의 hotUpdate 경로에 로그를 넣어 확인한 순서입니다.

  1. transform이 모든 환경에서 변환할 때마다 df/devup-ui/devup-ui-N.css에 /* <id> <Date.now()> */를 씁니다. 내용이 매번 달라서, 스타일이 그대로여도 watcher는 변경으로 받습니다. CSS 내용은 load가 WASM 메모리에서 주므로 이 쓰기는 "시트가 바뀌었다"는 신호 역할만 합니다.

  2. rsc·ssr 환경은 module runner라 CSS HMR이 없습니다. Vite는 시트 변경을 page reload → program reload로 처리하고, runner는 entry를 다시 import합니다.

  3. 그때 시트를 import한 모듈을 다시 변환하면서(fetchModule 경로) 1의 쓰기가 다시 일어납니다. 브라우저는 매번 rsc:update를 받아 ?_rsc를 다시 가져옵니다.

    transform rsc src/app/page.tsx  ← fetchModule
    write devup-ui-2.css
    (rsc) page reload df/devup-ui/devup-ui-2.css
    (rsc) program reload
    transform rsc src/app/page.tsx  ← fetchModule   (반복)
    
  4. 첫 렌더 중에도 같은 쓰기가 일어나면 ssr runner가 렌더 도중 reset되어 500이 납니다.

수정 (packages/vite-plugin만)

  • 시트는 CSS가 바뀔 때만 씁니다. 파일마다 마지막으로 쓴 내용을 기억해 같으면 쓰지 않고, 타임스탬프 주석 대신 실제 CSS를 씁니다. codeExtract의 css는 새 스타일이 추가됐을 때만 채워지므로, 다시 변환되기만 한 모듈은 아무것도 쓰지 않습니다.
  • hotUpdate에서 서버 환경(consumer === 'server')의 devup-ui 시트 변경에는 []를 돌려줍니다. 서버 환경은 시트를 URL로만 참조하고, 내용은 client 환경이 지금처럼 Vite CSS HMR로 갱신합니다. globalCss(기본 시트)도 이 경로로 반영되므로 서버 재시작이 필요 없습니다.
  • hotUpdate를 정의하면 Vite 6+는 같은 플러그인의 handleHotUpdate를 부르지 않습니다. 그래서 devup.json 처리(데이터 파일 재작성, invalidate, full reload)를 client 환경의 hotUpdate에도 두었습니다. hotUpdate가 없는 Vite 5를 위해 main의 handleHotUpdate는 그대로 남겨 fallback으로 씁니다.

브라우저에 직접 갱신을 보내는 방식은 필요 없었습니다. 쓰기를 조건부로 바꾸자 기존 client CSS HMR로 충분했습니다. 반대로 쓰기 조건화만으로는 부족합니다: 서버 환경 처리를 뺀 변형은 cold start 첫 요청이 3번 모두 500이었습니다.

검증

격리 복사본(git archive + bun install --frozen-lockfile)에 빌드한 플러그인을 넣고, 조사 메모의 측정 방법을 그대로 썼습니다(T에 편집, T+15초에 되돌림, 200ms 샘플, WS 프레임·문서 로드·?_rsc 요청 집계). Windows 11, 시나리오별 1회.

Cold start 첫 요청 (df/, .vinext/, node_modules/.vite를 지우고 기동, 각 3회)

앱 1.0.71 쓰기 조건화만 이 PR
service-template #11 admin /dashboard 500 ×3 500 ×3 200 ×3 (page/program reload 0)
braillify-studio #49 front /login 500 ×3 – 200 ×3 (page/program reload 0)

편집 시나리오 (적용까지 ms / toggles. toggles 1 = 한 번에 적용, 그 이상 = 깜빡임)

service-template #11 admin /dashboard (vite 8.3.1, vinext beta.12, plugin-rsc 0.5.35)

시나리오 1.0.71 이 PR
첫 문서 500 200
C1 서버 컴포넌트 스타일 488 / 9, rsc:update 75, ?_rsc 139 484 / 1, rsc:update 1, ?_rsc 2
C2 클라이언트 컴포넌트 새 atom 323 / 1, rsc:update 59 471 / 1, rsc:update 0
C3 globalCss 1394 / 1, rsc:update 125 261 / 1, rsc:update 1
C6 devup.json 테마 변수 1153 / 1 622 / 1 (플러그인의 full reload)
편집 후 20초 idle rsc:update 142, css-update 75 프레임 0
서버 로그 (전체) page reload 1262, program reload 626 page reload 2, program reload 1 (C2의 ssr, Vite 기본 동작)

service-template #11 front / (서버 컴포넌트 bg 편집): 1.0.71은 674 / 14(14.5초 시점에 미적용), idle rsc:update 86 → 이 PR은 269 / 1, idle 0.

braillify-studio #49 front /login

시나리오 1.0.71 이 PR
첫 문서 500 200
B1 서버 컴포넌트 스타일 685 / 10(14.5초 시점에 미적용), rsc:update 77 477 / 1, rsc:update 1
B2 클라이언트 컴포넌트 새 atom 443 / 1, rsc:update 61 269 / 1, rsc:update 0
B3 globalCss 1121 / 1, rsc:update 102 269 / 1, rsc:update 1
B6 devup.json 테마 변수 908 / 1 513 / 1 (플러그인의 full reload)
편집 후 20초 idle rsc:update 95 프레임 0

일반 Vite (apps/vite, client 전용, singleCss): 컴포넌트 새 atom 1.0.71 261 / 1 → 이 PR 249 / 1. 둘 다 문서 reload 0, idle 프레임 0.

production build

  • apps/vite: dist가 바이트 단위로 같습니다(CSS·JS·HTML).
  • service-template admin(vinext build): 클래스 이름을 정규화한 CSS 규칙 집합이 같습니다(파일 17개, 규칙 421개). 바이트 비교는 의미가 없습니다. 배포된 1.0.71로 세 번 빌드해도 전역 클래스 순서와 시트 번호가 빌드마다 달랐습니다.

테스트: 같은 CSS면 쓰지 않음, 기본 시트도 바뀔 때만 씀, 서버 환경은 devup-ui 시트 변경을 []로 넘김, 서버 환경의 소스·이름만 같은 앱 CSS·client 환경 시트는 Vite에 맡김, client 환경의 devup.json reload를 추가했습니다. 기존 handleHotUpdate 테스트는 그대로 둡니다. bun test 5214 pass, plugin.ts 커버리지 100%.

#678과의 관계

git merge-tree --write-tree로 #678 head(b89e7b31)와 합쳐 보면 충돌이 없습니다. #678은 configResolved(module resolver)와 codeExtract 직후(addWatchFile)를 고치고, 이 PR은 시트 쓰기를 고치고 hotUpdate를 추가합니다.

알려진 한계

  • 'use client' 컴포넌트를 편집하면 ssr 환경의 page reload + program reload는 남습니다. devup-ui와 무관한 Vite module runner 기본 동작입니다.
  • singleCss: true에서는 devup.json 테마 변경이 dev에서 반영되지 않습니다(1.0.71도 같음). writeDataFiles가 singleCss일 때 기본 시트를 쓰지 않아 캐시된 시트 모듈이 무효화되지 않습니다. 별개 문제라 이 PR에서는 고치지 않았습니다.
  • 조사 메모의 "C6(devup.json)이 반영되지 않는다"는 측정 오류였습니다. #FF00FF는 #F0F로 줄여 출력되는데 측정 코드는 #ff00ff를 찾았습니다. per-file 모드에서는 1.0.71도 반영됩니다.
  • Vite 5에서는 main의 handleHotUpdate가 그대로 devup.json을 처리합니다. 단위 테스트로만 확인했고, Vite 5 앱에서 실행해 보지는 않았습니다.
  • dev의 resolveId가 ?t=로 매번 새 id를 만드는 동작은 그대로라 시트 하나에 모듈 id가 쌓입니다(admin의 devup-ui.css는 49개). 루프가 없어져 더 늘지 않지만 손대지 않았습니다.
  • Windows에서만 측정했습니다. 템플릿 main 조합(vite 8.2.2, vinext beta.8)은 이 PR로 다시 재지 않았습니다.

In dev the plugin wrote `/* <id> <timestamp> */` 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.
@github-actions

Copy link
Copy Markdown
Contributor

Changepacks

@devup-ui/components@0.1.58 → 0.1.59 - packages/components/package.json

Patch

  • Auto-update: depends on '@devup-ui/vite-plugin' via a local workspace dependency

@devup-ui/vite-plugin@1.0.71 → 1.0.72 - packages/vite-plugin/package.json

Patch

  • 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

@codecov

codecov Bot commented Sep 27, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
packages/vite-plugin/src/plugin.ts 100.00% <100.00%> (ø)
🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

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.
@owjs3901
owjs3901 merged commit 6b79a13 into main Sep 28, 2026
4 checks passed
@owjs3901
owjs3901 deleted the owjs3901/vite-plugin-dev-css-loop branch September 28, 2026 04:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant