Skip to content

fix(extractor): compile Emotion's css prop at build time - #727

Open
owjs3901 wants to merge 13 commits into
mainfrom
fix/emotion-css-prop
Open

owjs3901 wants to merge 13 commits into
mainfrom
fix/emotion-css-prop

Conversation

@owjs3901

@owjs3901 owjs3901 commented Oct 2, 2026

Copy link
Copy Markdown
Contributor

Refs #690 (D 필수 — EMO-01, EMO-05 일부). #722 위에 쌓은 브랜치입니다(#706→#707→#710→#711→#722가 병합되면 차이는 커밋 하나).

문제

  • EMO-01: Emotion의 css prop이 어떤 형태로도 컴파일되지 않았습니다. <div css={{ color: 'red' }} />는 그대로 남고 CSS가 나오지 않았습니다. @emotion/react/jsx-runtime으로 컴파일된 코드의 jsx('div', { css })와 css() 결과를 넘긴 css={style}도 마찬가지였습니다.
  • EMO-05(일부): @emotion/react의 jsx가 런타임 의존성으로 남았습니다.

수정

  • 컴파일 대상 (@emotion/react alias가 켜져 있을 때)
    • 태그와 Devup UI 컴포넌트의 css prop은 항상 컴파일합니다.
    • 파일이 @emotion/react나 @emotion/styled(하위 경로 포함)를 import하거나 @jsxImportSource @emotion/react pragma가 있으면 모든 요소의 css prop을 컴파일합니다.
    • @emotion/react/jsx-runtime·jsx-dev-runtime의 jsx/jsxs/jsxDEV와 @emotion/react의 jsx/createElement 호출도 같은 규칙을 따릅니다. include 라이브러리의 컴파일된 코드도 포함됩니다.
  • 값 해석 (Emotion과 같은 의미)
    • 배열, 조건, &&/||/??는 합성입니다. 뒤의 부분이 같은 속성·선택자·breakpoint·layer를 덮어씁니다(D1 정적 합성).
    • 파일이 아는 css() 클래스는 스타일로 합성합니다. 그 밖의 식별자와 멤버는 런타임 클래스로 붙입니다.
    • 문자열과 템플릿은 CSS 텍스트입니다. css 태그 템플릿의 mixin(${base};)은 그 자리에서 텍스트를 나눠 합성하고, 인라인 css(...) 호출은 인자를 합성 부분으로 펼칩니다.
    • 테마 함수 theme => ({ ... })는 값 위치의 theme.a.b를 var(--a-b)로 읽습니다. 구조 분해(({ colors }) => ...)와 theme.space[2]도 읽습니다.
    • 단위 없는 숫자는 Emotion처럼 px입니다. 최상위 상수에서 읽은 숫자도 같고, lineHeight 같은 단위 없는 속성은 숫자로 둡니다.
    • 런타임 값은 CSS 변수로 style에 넣습니다.
  • 출력
    • 결과 클래스는 요소의 className에 붙고, 변수는 기존 style 아래에 병합됩니다. 스프레드 위치에 따른 className/style 최종값(JSX-01 규칙)을 따릅니다.
    • className에 파일이 아는 css() 클래스가 있으면 Emotion의 registered class처럼 css prop을 덮어씁니다.
    • Devup UI 컴포넌트는 자신의 스타일 prop 위에 css prop을 합성합니다.
    • 파일이 정의한 styled 컴포넌트는 그 태그를 요소 자리에 렌더링하고 자신의 스타일 위에 css prop을 합성합니다. 조건은 태그를 렌더링하고 attrs·props 읽기·런타임 값이 없으며, 요소에 스프레드·as·forwardedAs가 없는 것입니다. 그 밖에는 스타일이 겹치지 않을 때 className으로 넘깁니다.
  • 런타임 제거
    • @emotion/react/jsx-runtime(dev 포함) import는 react/jsx-runtime으로, @jsxImportSource @emotion/react pragma는 react로 바뀝니다.
    • @emotion/react의 jsx/createElement는 @devup-ui/react/compat의 jsx(React createElement)로 바뀝니다.
  • 타입
    • @devup-ui/react/compat/css-prop이 React.Attributes에 css를 추가하고, compat/emotion이 이를 참조합니다.
    • @emotion/react 선언에 jsx와 createElement를 추가했습니다.
  • 함께 고친 버그: 합성 부분을 복제할 때 oxc의 clone_in이 바인딩 정보를 잃었습니다. 그래서 css(a, b)의 부분이 읽는 바인딩(예: keyframes 이름)이 읽히지 않았습니다. clone_in_with_semantic_ids로 고쳤습니다.

동작 변화

  • <div css={...}>가 이제 클래스로 컴파일됩니다. Emotion 신호가 없는 파일의 사용자 컴포넌트(<Custom css={...}>)는 이전처럼 그대로 둡니다.
  • <Box css>는 이전에 객체만 우연히 중첩 스타일로 처리되었습니다. 이제 위 규칙(합성, px, CSS 텍스트)을 따릅니다.
  • css(yellow, fade)처럼 합성 부분이 아는 바인딩을 읽으면 그 값이 클래스 문자열에 들어갑니다. 런타임 결과는 같으며, 기존 테스트 1건의 기대값을 갱신했습니다.

새로 생기는 오류

모두 파일:줄:열, 코드, 원인, 해결 방법을 함께 보여 줍니다.

  • 빌드가 읽을 수 없는 css prop 부분(호출, JSX 요소, 스프레드 등).
  • 함수 안이나 let/var로 선언한 스타일 객체·배열·함수·텍스트를 부분으로 사용. 인라인으로 쓰거나 최상위 const로 선언하도록 안내합니다.
  • 모듈을 실행해야만 아는 바인딩이나 코드가 변경하는 객체를 사용.
  • 테마 함수가 규칙을 바로 반환하지 않거나, 값 위치가 아닌 곳에서 theme을 읽음(theme.spacing(2), theme.colors[key], 조건 등).
  • CSS 텍스트에서 배치할 수 없는 보간, 중첩 규칙 안의 mixin.
  • styled 컴포넌트의 스타일을 덮어쓰지만 태그를 요소 자리에 렌더링할 수 없음. styled(Component)(...)로 옮기도록 안내합니다.

남는 한계

  • 런타임에만 정해지는 클래스(props.className 등)와의 우선순위는 CSS 특성을 따릅니다(K 문서화).
  • 사용자 컴포넌트가 동적 값을 받으려면 className과 style을 모두 전달해야 합니다(D8, K 문서화).
  • tsconfig jsxImportSource만으로 Emotion을 쓰는 파일은 신호가 없어 사용자 컴포넌트를 컴파일하지 않고, 번들러가 Emotion JSX 런타임을 import합니다. tsconfig를 읽는 후속 PR에서 처리합니다.
  • bun 플러그인의 hasDevupUI 게이트는 alias를 모릅니다(H 범위).
  • fix(extractor): read property arrays in aliased library rules as fallbacks #717(배열 fallback)과 함께 병합되면 css prop 객체의 속성 배열 처리를 확인해야 합니다.

확인

  • cargo clippy -D warnings(Rust 1.99), cargo test --workspace(extractor 1463건) 통과.
  • WASM 재빌드 후 bun test 5475건 통과, 커버리지 100%. bun lint 통과.
  • 조사 fixture css-prop-* 12개를 probe로 확인했습니다.
  • 소비자 설치 형태(단일 @types/react)에서 css prop 타입 검사를 확인했습니다.

owjs3901 and others added 13 commits October 1, 2026 21:16
…rations

css(a, b) composing classes whose styles the build knows, bound to css() in the file or exported by another module, merges their atoms per property, selector, breakpoint and layer, conditions included, instead of joining classes whose winner the stylesheet order picked. vanilla-extract style([...]) passes each composed style as its own argument, keeping a style composed again later.

Refs #688

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Refs #688

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Refs #688

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Refs #688

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…asurable

Refs #688

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…JSX spreads win

Refs #688

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…led order and JSX element className

Refs #688

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Refs #689

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…uate shouldForwardProp at build time

Refs #689

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
…ring

Refs #689

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Refs #691

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
Refs #690

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
… and conditional styles

Refs #690

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 2, 2026

Copy link
Copy Markdown
Contributor

Changepacks

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

Patch

  • css(a, b) composing classes whose styles the build knows (a const bound to css() in the file, or a css() result another module exports with a rule object every value of which is known) merges their styles: a later part's declaration replaces an earlier one's for the same property, selector, breakpoint and layer, also under conditions (css(base, cond && danger)) and for ||/?? parts, where the classes used to be joined and the stylesheet order picked the winner. vanilla-extract style([a, b]) passes each composed style as its own argument, so the later one wins, and a style composed again later (style([a, b, a])) is kept. Classes the build does not know (strings, props, CSS Modules) are kept as they are
  • styled(Base) extending a styled component the file binds to a const renders Base's tag directly with Base's styles composed under its own, so the extension's declarations replace Base's for the same property, selector, breakpoint and layer instead of losing to them by stylesheet order; Base's attrs apply before the extension's. attrs merge as styled-components merges them: className is joined with the caller's, style is merged and other props replace earlier ones. On an element, a className or style spread after the explicit prop now wins, as React merges props in the order they are written
  • Emotion's css prop compiles at build time while @emotion/react is aliased: on tags and Devup UI components always, and on every element once the file imports @emotion/react or @emotion/styled or names @emotion/react in a @jsxImportSource pragma; jsx, jsxs and jsxDEV from @emotion/react/jsx-runtime (or jsx-dev-runtime) and jsx from @emotion/react compile it too, so libraries built with Emotion's JSX runtime do as well. The prop becomes the element's className, with CSS variables in its style for values only the runtime gives: arrays and conditions compose with a later part replacing what an earlier one sets, css() classes the file knows compose by their styles, strings and templates are CSS text, css`` mixins split the text around them, a function of the theme reads theme.a.b as var(--a-b), unitless numbers are px as in Emotion (constants included), and a className holding known css() classes overrides the prop as Emotion's registered classes do. A styled component the file defines renders its tag in the element's place when the prop overrides its styles and it renders a tag with no attrs or props read and the element has no spread, as or forwardedAs. Emotion's JSX runtime imports become react/jsx-runtime, the pragma names react, and jsx comes from @devup-ui/react/compat (React's createElement); @devup-ui/react/compat/css-prop types the prop on React.Attributes. New build errors, each with file:line:column and the code: a css prop part the build cannot read (a call, an element), a style object declared inside a function or with let, a binding only running the module gives or code changes, a theme function that does more than return rules or reads the theme other than as theme.a.b in a value, an interpolation CSS text cannot place or a mixin inside a nested rule, and a css prop overriding a styled component's styles where its tag cannot be rendered in place. Composing css() with a part that reads a known binding (such as a keyframes name) now reads its value
  • 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
  • Styled components the build generates are wrapped in React's forwardRef, so a ref passed to them reaches the element they render on React 18 as well as React 19; the file imports forwardRef from react when it defines one
  • Styled components no longer pass every prop to the tag they render: $ props, theme and the props their style functions or attrs read are kept away from a tag unless it takes them as attributes, and shouldForwardProp (Emotion options or styled-components withConfig) is evaluated at build time. A shouldForwardProp the build cannot evaluate is a build error naming the file, line and code, with the forms it accepts. An element using a styled component the file defines drops props the component neither reads nor passes on; spread props are passed as written
  • Styled components render what their as prop names, defaulting to the tag or component they were defined with, and pass forwardedAs on as as, as styled-components and Emotion do; as used to reach the rendered tag as an attribute. Component.withComponent(target) on a styled component the file binds to a const builds a component rendering the same styles and attrs as target (a tag name or a component JSX can name), where it used to call a method the generated function does not have and throw
  • 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/react' 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/react@1.0.44 → 1.0.45 - packages/react/package.json

Patch

  • Emotion's css prop compiles at build time while @emotion/react is aliased: on tags and Devup UI components always, and on every element once the file imports @emotion/react or @emotion/styled or names @emotion/react in a @jsxImportSource pragma; jsx, jsxs and jsxDEV from @emotion/react/jsx-runtime (or jsx-dev-runtime) and jsx from @emotion/react compile it too, so libraries built with Emotion's JSX runtime do as well. The prop becomes the element's className, with CSS variables in its style for values only the runtime gives: arrays and conditions compose with a later part replacing what an earlier one sets, css() classes the file knows compose by their styles, strings and templates are CSS text, css`` mixins split the text around them, a function of the theme reads theme.a.b as var(--a-b), unitless numbers are px as in Emotion (constants included), and a className holding known css() classes overrides the prop as Emotion's registered classes do. A styled component the file defines renders its tag in the element's place when the prop overrides its styles and it renders a tag with no attrs or props read and the element has no spread, as or forwardedAs. Emotion's JSX runtime imports become react/jsx-runtime, the pragma names react, and jsx comes from @devup-ui/react/compat (React's createElement); @devup-ui/react/compat/css-prop types the prop on React.Attributes. New build errors, each with file:line:column and the code: a css prop part the build cannot read (a call, an element), a style object declared inside a function or with let, a binding only running the module gives or code changes, a theme function that does more than return rules or reads the theme other than as theme.a.b in a value, an interpolation CSS text cannot place or a mixin inside a nested rule, and a css prop overriding a styled component's styles where its tag cannot be rendered in place. Composing css() with a part that reads a known binding (such as a keyframes name) now reads its value

@devup-ui/reset-css@1.0.31 → 1.0.32 - packages/reset-css/package.json

Patch

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

@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

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

@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 2, 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/build_time_values.rs 100.00% <100.00%> (ø)
libs/extractor/src/composition.rs 100.00% <100.00%> (ø)
libs/extractor/src/css_prop.rs 100.00% <100.00%> (ø)
libs/extractor/src/css_utils.rs 100.00% <100.00%> (ø)
...tractor/src/extractor/extract_style_from_styled.rs 100.00% <100.00%> (ø)
libs/extractor/src/import_alias_visit.rs 100.00% <100.00%> (ø)
libs/extractor/src/imported_constants.rs 100.00% <100.00%> (ø)
libs/extractor/src/lib.rs 100.00% <100.00%> (ø)
... and 10 more
🚀 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.

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