Skip to content

fix(vite-plugin): keep server page css dependencies in vinext/RSC builds - #747

Merged
owjs3901 merged 2 commits into
mainfrom
fix/vite-rsc-shared-css
Oct 3, 2026
Merged

owjs3901 merged 2 commits into
mainfrom
fix/vite-rsc-shared-css

Conversation

@owjs3901

@owjs3901 owjs3901 commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Refs 사용자 버그 리포트 (2026-10-03, lottolegend-front apps/admin Vinext/RSC 프로덕션 빌드). 독립 PR이며 다른 PR에 쌓여 있지 않습니다. 먼저 병합해 patch 릴리스로 내보낼 수 있습니다.

요약

Vinext/RSC 프로덕션 빌드에서 @devup-ui/vite-plugin이 서버 페이지가 필요로 하는 공통 컴포넌트 CSS를 로딩 목록에서 빠뜨리던 문제를 고칩니다.

원인은 generateBundle이 "중복 emit 방지"와 "CSS 의존성 삭제"를 한 동작으로 처리한 것입니다. 클라이언트가 이미 내보낸 CSS를 서버 번들의 output.viteMetadata.importedCss에서 지웠는데, @vitejs/plugin-rsc는 같은 importedCss를 서버 페이지의 CSS 의존성(serverResources)으로도 읽습니다. 이제 importedCss는 그대로 두고, 실제로 일어나는 중복 emit(FILE_NAME_CONFLICT)만 막습니다.

정확한 원인

@vitejs/plugin-rsc 0.5.35 rsc:virtual:vite-rsc/assets-manifest의 generateBundle(클라이언트 환경, dist/plugin-CLvIS10V.js 1163~1218행)은 한 훅 안에서 같은 importedCss를 두 번 읽습니다.

  1. 1176~1185행: RSC 번들 모든 청크의 importedCss ∪ importedAssets를 모아 클라이언트로 emitFile합니다 (이미 클라이언트에 같은 이름이 있으면 FILE_NAME_CONFLICT).
  2. 11871191행: collectAssetDeps(rscBundle)(18261854행, collectAssetDepsInner가 v.viteMetadata?.importedCss를 모읍니다)로 serverResources[key].css를 만듭니다.

Devup UI는 enforce: 'pre'라서 클라이언트 generateBundle에서 plugin-rsc보다 먼저 실행되고, 거기서 겹치는 파일을 RSC 번들의 importedCss에서 지웠습니다. 그래서 2번에서 serverResources가 비었습니다.

누락 지점 추적 (재현 앱, 수정 전)

임시 진단 플러그인(order: 'pre', Devup 바로 뒤 enforce: 'pre', order: 'post' 세 지점)으로 클라이언트 generateBundle 전후의 RSC 번들 importedCss와 plugin-rsc가 만든 manifest를 찍었습니다. (원문 로그: 작업 디렉터리 diag-unfixed.log)

[diag] BEFORE devup generateBundle (RSC bundle that plugin-rsc reads for serverResources) env=rsc
    _next/static/PageTitle-CXsytdWL.js importedCss=[_next/static/css/devup-ui.C5x4Y-g5.css, _next/static/css/devup-ui-6.CQL5X73Z.css]
[diag] AFTER devup generateBundle / BEFORE plugin-rsc manifest (RSC bundle ...) env=rsc
    (no chunk has importedCss)
[diag] AFTER plugin-rsc manifest built (RSC bundle ...) env=rsc
    (no chunk has importedCss)
[diag] serverResources (assets manifest built by plugin-rsc)
    src/components/PageTitle.tsx => css=[]

수정 후에는 같은 지점에서 다음처럼 의존성이 남습니다.

[diag] serverResources (assets manifest built by plugin-rsc)
    src/components/PageTitle.tsx => css=[/_next/static/css/devup-ui.C5x4Y-g5.css, /_next/static/css/devup-ui-6.CQL5X73Z.css]

사용자가 확인한 현상과도 맞습니다. 클라이언트 페이지('use client' 편집 페이지)의 clientReferenceDeps에는 CSS가 있어서 그 페이지를 prefetch하는 목록만 정상이고, 나머지 서버 페이지의 <link rel="stylesheet">는 아예 만들어지지 않았습니다 (수정 전 notice/index.html에는 stylesheet 링크가 0개).

동작 (변경 내용)

packages/vite-plugin/src/plugin.ts

  • 서버 번들 importedCss를 더 이상 수정하지 않습니다. 서버·클라이언트가 공유하는 CSS 의존성은 서버/클라이언트 모두 그대로입니다.
  • 클라이언트 generateBundle(plugin-rsc보다 앞)에서, 클라이언트가 이미 가진 CSS를 서버 번들이 참조하면 plugin-rsc가 들고 있는 서버 번들의 그 asset fileName을 임시 이름(<file>.devup-forwarded)으로 바꿉니다. plugin-rsc는 emitFile에 그 fileName을 쓰므로 실제 파일은 한 번만 emit되고 충돌이 없습니다.
  • 새로 추가된 형제 플러그인 devup-ui:restore-forwarded-css(generateBundle, order: 'post')가 plugin-rsc 이후 fileName을 되돌리고 임시 파일을 출력에서 지웁니다. DevupUI()는 이제 플러그인 두 개의 배열을 반환합니다 (PluginOption 타입은 그대로이고 plugins: [DevupUI()]에서는 그대로 쓸 수 있습니다).
  • plugin-rsc 번들을 rsc:minimal 플러그인의 api.manager.bundles에서 가져옵니다. 훅에 전달되는 bundle은 플러그인마다 별개의 뷰라서, Devup UI가 받은 서버 번들의 asset을 고쳐도 plugin-rsc에는 반영되지 않습니다 (직접 확인했습니다). importedCss Set은 공유 객체라 이전 방식이 작동했을 뿐입니다.
  • 이전에는 Devup 자체 CSS(devup-ui(-N).css)만 다뤘습니다. 이제 클라이언트에 이미 있는 어떤 CSS든 같은 방식으로 한 번만 emit됩니다. 이름에 해시가 들어 있어 같은 이름이면 내용이 같습니다.

대안으로 검토했다가 버린 것: 겹침을 importedCss에서 지우지 않고 중복 emit을 그냥 두는 방법은 실제로 manifest가 달라집니다 (plugin-rsc의 emit이 클라이언트 asset을 덮어써서 .vite/manifest.json에서 _devup-ui*.css 항목이 사라집니다). 그래서 이름만 바꿔 한 번만 emit하게 했습니다.

새로 생기는 오류

없습니다.

호환성

  • RSC가 없는 다른 Vite 설정: rsc:minimal 플러그인이 없으면 아무것도 하지 않습니다 (getRscServerBundles가 빈 배열). 일반 Vite 빌드, Storybook, 라이브러리 모드는 동작이 같습니다.
  • singleCss: 파일이 하나(devup-ui.css)인 경우에도 같은 경로를 탑니다. 재현 앱에서 per-file/singleCss 둘 다 검증했습니다.
  • dev 서버: 새 플러그인은 apply: 'build'이고 변경된 훅은 빌드의 generateBundle뿐이라 dev 동작은 같습니다.
  • 플러그인 순서: Devup UI(enforce: 'pre')는 plugin-rsc 앞, 복원 플러그인은 order: 'post'로 plugin-rsc 뒤, Vite의 manifest 플러그인 앞에서 실행됩니다. 따라서 .vite/manifest.json의 CSS 항목도 유지됩니다.
  • plugin-rsc 내부가 바뀌어 이 가정이 깨지면: importedCss는 항상 온전하므로 CSS가 사라지는 일은 없고, 최악의 경우 이전의 FILE_NAME_CONFLICT 경고로 돌아갑니다.

남는 한계

  • rsc:minimal 플러그인의 api.manager.bundles(@experimental)에 기대고 있습니다. plugin-rsc 업스트림이 emitFile 전에 클라이언트 번들을 확인하게 되면 이 우회는 필요 없어집니다.
  • 재현 앱 CI 잡은 Playwright를 추가하지만 기존 landing 시각 스냅샷에는 영향이 없습니다.

검증

재현 앱 apps/vinext-rsc-css-repro (vite 8.3.1, vinext 1.0.0, @vitejs/plugin-rsc 0.5.35 고정. 리포트와 같은 버전이며 워크스페이스에서 허용됩니다. apps/landing은 vinext 1.0.0-beta.12 / vite 8.3.0). 공통 PageTitle(Flex/Box/Text, align-items, justify-content: space-between, gap, margin-bottom, 단순 <button>)을 서버 목록 페이지 3개(/notice/, /admins/, /users/)와 'use client' 편집 페이지(/users/edit/)가 공유하고, /users/만 편집 페이지로 링크합니다. output: 'export', trailingSlash: true, 기본 CSS 분리와 DEVUP_SINGLE_CSS=1.

Playwright(bun run test:e2e:rsc-css)는 정적 빌드에서 새 브라우저 컨텍스트로 각 경로 직접 진입 + 새로고침(4건), 4개 경로의 모든 진입 순서 24가지, 편집 페이지(1건)를 검사합니다. 편집 페이지로의 prefetch 요청은 차단해서 서버가 렌더한 정보만으로 스타일이 있어야 하게 했습니다. 헤더는 data-page-title로 찾고, getComputedStyle로 display: flex, justify-content: space-between, align-items: center, margin-bottom: 20px, 안쪽 그룹 gap: 12px를, getBoundingClientRect로 제목이 시작에, 오른쪽 슬롯이 끝에 붙는지를 확인합니다. 또 헤더가 쓰는 모든 클래스에 대해 실제 로드된 <link> 스타일시트에 규칙이 있는지 확인합니다. 파일 번호·해시·클래스명은 어디에도 쓰지 않습니다.

수정 전 플러그인(origin/main)에서:

unfixed-percss    : 27 failed | 2 passed (6.7s)
unfixed-singlecss : 27 failed | 2 passed (6.7s)
    Error: /notice/ loaded stylesheets
    Expected: > 0
    Received:   0

(통과한 2건은 클라이언트 페이지인 편집 페이지 자신의 두 테스트입니다.)

수정 후:

fixed-percss    : 29 passed (4.2s)
fixed-singlecss : 29 passed (4.8s)

수정 후 빌드에는 FILE_NAME_CONFLICT가 없고, dist에 .devup-forwarded 임시 파일이 남지 않으며, .vite/manifest.json에 _devup-ui*.css 항목이 유지됩니다.

단위 테스트(packages/vite-plugin/src/__tests__/plugin.test.ts): 서버 importedCss가 그대로이고 plugin-rsc가 emit하는 이름이 클라이언트와 겹치지 않으며(per-file, singleCss), 복원 후 임시 파일이 사라지고 fileName이 돌아오는 것, 클라이언트가 내보내지 않는 CSS는 건드리지 않는 것, asset이 아닌 항목·클라이언트 번들·plugin-rsc 정보 없음·no-write 번들을 건드리지 않는 것. 기존의 importedCss를 지우는 전제의 테스트 3건(서버 CSS 전달 안 함, no-write 번들, 다른 출력 이름)은 이 변경과 모순이라 새 동작 기준으로 바꿨고, 나머지 기존 테스트는 그대로입니다.

게이트: cargo fmt, cargo +1.99 clippy --workspace --all-targets -- -D warnings, cargo test --workspace, bun run build, bun test(100% 커버리지, 5482 pass, 0 fail), bun run lint(기존 경고 2건뿐), landing 빌드 후 기존 landing e2e(build-integrity, computed-styles, routes 20건) 통과. main에 있는 Rust 1.99 clippy 대응(assert!(x.is_empty()) 계열)도 테스트 코드에서만 함께 고쳤습니다. 패키지 동작에는 영향이 없어 changepack은 vite-plugin Patch만 추가했습니다.

CI: publish.yml에 vinext-rsc-css-e2e 잡(캐시, timeout-minutes: 30)을 추가해 per-file과 singleCss 두 빌드에서 위 테스트를 실행합니다.

패치 릴리스: .changepacks/changepack_log_vite_rsc_shared_css.json (packages/vite-plugin/package.json: Patch). 병합하면 CI가 배포합니다.

owjs3901 and others added 2 commits October 3, 2026 22:53
…css once

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
@github-actions

github-actions Bot commented Oct 3, 2026

Copy link
Copy Markdown
Contributor

Changepacks

@devup-ui/wasm@1.0.82 → 1.0.83 - bindings/devup-ui-wasm/package.json

Patch

  • globalCss: theme tokens ($text) resolve to var(--text) in global rules instead of being written as invalid $text; imports given as { url, query } objects with unquoted keys are emitted instead of dropped; an '@layer name' key puts the rules it holds in that cascade layer (layers nest as base.reset) instead of flattening them into invalid declarations, and a layer name that is not one identifier is a build error
  • Theme CSS: a color only one variant defines is defined in that variant; a variant renders dark only when it is named dark or listed as dark in the new theme.colorScheme, others light, and light-dark() only encodes a light default with one dark variant; theme names that are not CSS identifiers are quoted in [data-theme]; typography emits fontStyle and textTransform; color variables come out in a fixed order. $tokens with dashes (text-primary) resolve instead of breaking into var(--text)-primary, and dotted length and shadow names are declared with dashes like their references. registerTheme reports, with the theme path, a token name the $token syntax cannot reach and two tokens of a variant that become the same CSS variable (a-b and a.b)

@devup-ui/bun-plugin@1.0.21 → 1.0.22 - packages/bun-plugin/package.json

Patch

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

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

Patch

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

@devup-ui/eslint-plugin@1.0.21 → 1.0.22 - packages/eslint-plugin/package.json

Patch

  • no-duplicate-value, no-useless-responsive, no-useless-tailing-nulls, no-typography-token-prefix and prefer-media-shorthand only report and fix values the build reads as styles: style props of Box, Flex and the other style components and the arguments of css, globalCss and keyframes, through style objects, responsive arrays, conditions and spreads. Arrays and keys in props the component passes through (data-, aria-, event handlers, HTML attributes, props, styleVars), in arguments of other functions and under imports/fontFaces/params are left alone, where autofix used to rewrite them; styles of a component nested in another's prop are checked too. css-utils-literal-only reads a css() or keyframes() result held in a const of any scope as static, as the build does

@devup-ui/next-plugin@1.0.89 → 1.0.90 - packages/next-plugin/package.json

Patch

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

@devup-ui/plugin-utils@1.0.16 → 1.0.17 - packages/plugin-utils/package.json

Patch

  • Theme CSS: a color only one variant defines is defined in that variant; a variant renders dark only when it is named dark or listed as dark in the new theme.colorScheme, others light, and light-dark() only encodes a light default with one dark variant; theme names that are not CSS identifiers are quoted in [data-theme]; typography emits fontStyle and textTransform; color variables come out in a fixed order. $tokens with dashes (text-primary) resolve instead of breaking into var(--text)-primary, and dotted length and shadow names are declared with dashes like their references. registerTheme reports, with the theme path, a token name the $token syntax cannot reach and two tokens of a variant that become the same CSS variable (a-b and a.b)

@devup-ui/rsbuild-plugin@1.0.66 → 1.0.67 - packages/rsbuild-plugin/package.json

Patch

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

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

Patch

  • Vinext/RSC production builds keep the CSS a server page needs. The plugin no longer removes the CSS files the client already emits from the server bundle's importedCss, which @vitejs/plugin-rsc also reads as the server pages' CSS dependencies (so a page that rendered a shared Devup UI component lost its stylesheet unless a client page sharing it was prefetched first). The duplicate emit (FILE_NAME_CONFLICT) is now avoided without touching the dependency metadata. DevupUI() now returns two plugins. No new build errors

@devup-ui/webpack-plugin@1.0.70 → 1.0.71 - packages/webpack-plugin/package.json

Patch

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

@codecov

codecov Bot commented Oct 3, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.

Files with missing lines Coverage Δ
bindings/devup-ui-wasm/src/lib.rs 100.00% <ø> (ø)
libs/css/src/theme_tokens.rs 100.00% <ø> (ø)
libs/extractor/src/lib.rs 100.00% <ø> (ø)
libs/extractor/src/tailwind.rs 100.00% <100.00%> (ø)
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.

@owjs3901
owjs3901 merged commit 9788c95 into main Oct 3, 2026
5 checks passed
@owjs3901
owjs3901 deleted the fix/vite-rsc-shared-css branch October 3, 2026 14:39
owjs3901 added a commit that referenced this pull request Oct 8, 2026
Restore pinned main 11790be's parked-forwarded CSS and post-restoration plugin pair, full vinext RSC fixture, both-mode CI job, root script and resolver lock after the unchanged-tree ours merge4473674c skipped #747 content. Preserve all #763 shorthand normalization, JSX-source, StyleX, type and lint intent. Local restored RSC e2e29/29 passed under6GiB; all packages built serially and Bun6071/0fail passed.

Refs #763

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
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