Skip to content

fix(compiler): an unresolved alpha corrupts the colour beside it - #421

Open
YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/unresolved-color-channel-scale
Open

YevheniiKotyrlo wants to merge 3 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/unresolved-color-channel-scale

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

lightningcss hands over rgb(… / var(--a)) and hsl(… / var(--a)) as an unresolved colour: the channels resolved, the alpha a token list. On main both spellings render the wrong colour or none:

  • the rgb channels are multiplied by 255 a second time, so rgb(51 102 153 / var(--a)) renders rgba(13005, 26010, 39015, 0.5), which React Native clamps to white. Only a saturated channel such as rgb(255 0 0 …) comes out right.
  • the hsl channels are emitted as hsl(120, 50, 50, 0.5), which React Native's parser rejects, so the property is unset. So is every infinite or NaN hue.
  • a variable declared once and read only inside such an alpha loses its declaration to the inliner, so the alpha comes out empty and the colour renders opaque.

Root cause

  • lightningcss converts rgb channels to 0-255 before the compiler sees them, which is why parseColor divides the same channels by 255 for colorjs.io. parseUnresolvedColor multiplied them again.
  • The hsl branch forwarded lightningcss's unitless channels under hsl, while React Native's hsl() takes three arguments with percentage saturation and lightness.
  • The inliner walks var and function tokens but not lightningcss's unresolved-color.

Solution

  • The rgb channels pass through.
  • The hsl channels convert to sRGB with colorjs.io, as parseColor converts the resolved spelling, and emit rgba(r, g, b, alpha).
  • A hue that is infinite, NaN or at least 2 ** 32 renders as hue 0. Chromium, Firefox and WebKit render an infinite or NaN hue as 0. The second lightningcss pass reparses a hue as a float32, and from 2 ** 32 one float32 step spans more than a turn, so the value no longer names an angle.
  • The inliner folds a variable inside an unresolved colour's alpha and inside light-dark()'s arms. fix: resolve light-dark() at runtime from the used colour scheme #420 carries the same commit; whichever lands second drops it.

Tests

  • src/__tests__/native/colors.test.tsx
    • 21 colours whose expected value is what Chromium 153, Firefox 155 and WebKit 26.6 compute with an alpha of 0.5:
      • rgb with integer, fractional, percentage and out-of-range channels
      • hsl in deg, turn, rad and grad, with wrapped and negative hues and clamped saturation and lightness
    • Each colour renders four ways, so the unresolved and resolved paths are held to one answer: alpha read at runtime, alpha read from a fallback, alpha folded at compile time, and the resolved spelling.
    • Infinite and NaN hues, rendered at runtime and from a fallback.
    • A variable redeclared on the element.
    • The float32 boundary from both sides: 3e9 reduces; 2 ** 32, 1e20 and 1e38 render as hue 0, as their resolved spelling does.
    • light-dark() carrying an unresolved alpha into both schemes.
  • src/__tests__/compiler/declarations.test.tsx: the descriptor for each spelling.
  • src/__tests__/compiler/compiler.test.tsx: folding inside an alpha and inside a light-dark() arm.

On main, 83 of the 144 cases in the three files fail; all pass here. Each mechanism was reverted on its own to confirm its cases fail without it.

Verification

On Windows with Node 26:

  • yarn lint clean
  • yarn typecheck clean
  • yarn test --maxWorkers=2 --coverage: 1442 passed, 4 failed. The 4 are the babel cases that also fail on main on this machine.
  • yarn build clean
  • yarn example expo export --platform web exported
  • nothing unstaged after either build

Not run: the iOS build.

No open issue covers it. Device evidence, before and after: #421 (comment)

Known limits

  • An unset alpha with no fallback renders the colour opaque. Browsers treat that declaration as invalid at computed-value time: transparent for background-color, inherited for color. The cause is that the runtime drops a var() with no value from the function it sits in, and that reaches every function, not only colours.
  • The runtime stringifies these spellings without parsing them, so React Native rejects each of the following unless the inliner folds it:
    • an alpha given as a percentage (--a: 25% reaches it as rgba(255, 0, 0, 25%))
    • a colour function whose channels are variables
    • a none channel
    • hwb()
    • the four-argument comma form of hsl()
  • The first lightningcss pass serializes numbers to six significant digits, so hsl(12345678 50% 50% / var(--a)) arrives as hsl(12345700 …) and renders 220° where the resolved spelling and browsers render 198°.
  • For a hue of 3e9, Chromium and WebKit reduce it to 120° and Firefox renders 0°; this follows Chromium and WebKit.
  • lightningcss's first pass resolves hsl(calc(infinity) 100% 50%) to #000, while browsers and the unresolved spelling render hue 0.

Related

#420 carries the same inliner commit. #351 fixed the same class of defect in the same file.

Merge order

This overlaps #391; I'll re-cut whichever of the two lands second.

Base

Re-written on main (a5002c5).

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft August 15, 2026 14:35
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review August 15, 2026 19:13
@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The percentage row paints WHITE, both hsl rows are empty outlines and the declared-once row is OPAQUE — channels scaled twice, a spelling React Native rejects, and an alpha the inliner drops.

FIXED — The percentage row paints its brown, both hsl rows paint, the declared-once row is red at 30%, and the calc(infinity) row paints red beside its resolved twin's lightningcss black.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, both frames from one run. before is the published 3.1.0-rc.0 plus what my test app needs from my other patches to run: the native and native-internal subpath exports, PR 459, cascade-layer ranking, and a shim that compiles a class-qualified :root inert. after adds the rest of my open PRs, this one included. One of them shows on every screen: PR 417 narrows a var()-valued font-family to one family, so the after sets its text in the theme's face where the before falls back to the platform's.

lightningcss hands over the channels of `rgb(… / var(--a))` as 0-255
integers, and parseUnresolvedColor multiplied them by 255 again, so
`rgb(255 0 0 / var(--a))` rendered `rgba(65025, 0, 0, 0.5)`. The hsl
spelling emitted `hsl(120, 50, 50, 0.5)`, which React Native rejects.

The rgb channels pass through, and the hsl channels convert to the sRGB
parseColor writes for the resolved spelling. A hue that is infinite,
NaN or past the float32 range of the second lightningcss pass renders
as hue 0, as browsers render an infinite or NaN hue.
lightningcss parses `hsl(0 100% 50% / var(--a))`, `rgb(… / var(--a))`
and `light-dark(nativewind#123, var(--b))` as an unresolved colour whose channels
are token lists, and the inliner walked `var` and `function` tokens only.
A variable declared once therefore lost its declaration while a read
inside a colour function kept naming it, so the alpha, or the arm, came
out empty.
Each colour is the value Chromium 153, Firefox 155 and WebKit 26.6
compute with an alpha of 0.5, and each is rendered four ways: the alpha
read at runtime, read from a fallback, folded at compile time, and the
resolved spelling. A hue that is infinite or NaN renders as hue 0 in
all three browsers; lightningcss's first pass resolves the resolved
spelling of one to an achromatic colour, which stays pinned.

This branch has not been deployed

No deployments
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