Skip to content

fix: resolve light-dark() at runtime from the used colour scheme - #420

Open
YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/light-dark-extra-rule
Open

YevheniiKotyrlo wants to merge 4 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/light-dark-extra-rule

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Aug 15, 2026 •

Copy link
Copy Markdown
Contributor

Problem

light-dark() compiles to two rules: the current rule with the light arm, and an extra rule under prefers-color-scheme: dark with the dark arm. Wherever that second rule cannot reproduce the declaration it came from, dark mode renders wrong:

  • .p { color: light-dark(red, blue) } publishes the light colour, so currentcolor below it stays red; a second light-dark() on the same rule re-asserts the first one's light value.
  • box-shadow: 0 0 1px light-dark(#123456, #654321) becomes boxShadow: "#654321" in dark mode, and border / text-shadow gain a stray key: the dark arm is written under the shorthand's name.
  • :root { --x: light-dark(#123456, #654321) } read through var(--x) stays light in dark mode unless the inliner folds it, and so does the same property declared on an element or an ancestor.
  • inside ::placeholder / ::selection the dark arm lands on the element.
  • color-scheme is dropped, so light-dark() follows the preferred scheme everywhere: dark with no color-scheme declared, where browsers render the light arm, and light under color-scheme: dark.

Root cause

CSS Color 5 §7 makes light-dark() one value with two arms, resolved against the element's used colour scheme, which CSS Color Adjust 1 §2.3 derives from color-scheme. A second rule gated on a media query copies the declaration's effect rather than its value, and the copy is wrong for every declaration that does more than write one property. The media query also reads the preferred scheme, never the element's.

Separately, the inliner walks var and function tokens but not lightningcss's unresolved-color, so a variable declared once lost its declaration while a read inside hsl(… / var(--a)), rgb(…) or light-dark(…, var(--b)) kept naming it.

Solution

  • light-dark() compiles to a lightDark value holding both arms. A shorthand, a custom property on :root, * or an element, the colour a rule publishes and a pseudo-element's prop all carry it unchanged.
  • color-scheme compiles to --__rn-css-color-scheme, published to the subtree the way direction is. The runtime picks the arm for the used colour scheme: the preferred scheme when the element supports it, the one it supports otherwise, and light when it supports none. A light-dark() declaration is marked as reading variables, so it resolves with the inherited ones and subscribes to the preference like a prefers-color-scheme query.
  • The extra-rule mechanism (addExtraRule, extendRule, createRuleFromPartial, addUnnamedDescriptor, descriptorProperty) had no other user and is removed.
  • The inliner folds a variable inside an unresolved colour's alpha and inside light-dark()'s arms.

Breaking: with no color-scheme, light-dark() renders its light colour in dark mode, as Chromium, Firefox and WebKit do. :root { color-scheme: light dark }, Tailwind's scheme-light-dark, makes it follow the preferred scheme. The BREAKING CHANGE: footer is on the last commit.

Tests

  • src/__tests__/native/light-dark.test.tsx derives every expectation. Each scene, with inline variables on and off, renders exactly as the same stylesheet with every light-dark(L, D) replaced by the arm it should take.
    • 26 scenes follow the preferred scheme under color-scheme: light dark. They cover color, background-color, the border shorthand, four border colours, the inline border colours, one border side, the block border, the inline-start border, outline-color, text-decoration-color and its shorthand, box-shadow and text-shadow. They also cover a nested light-dark(), a var() fallback, a custom property on :root, *, an ancestor and the element, currentcolor from an ancestor, two declarations on one rule, one and two variable arms, !important and a media query.
    • 22 scenes take the arm Chromium 153, Firefox 155 and WebKit 26.6 render for the same stylesheet under each preference. They cover no color-scheme, normal, light dark, dark light, only light dark, only light, only dark, an element's light or dark, inheritance and normal under a dark ancestor. They also cover a custom ident alone and beside dark, cascade order, !important, a media query, var() with and without a fallback, initial, a universal color-scheme, and a custom property resolved where it is used.
    • It also covers no preference at all, ::placeholder, and a stylesheet whose lightDark lacks an arm.
  • src/__tests__/compiler/light-dark.test.ts checks one rule holding both arms for each declaration kind, the published colour, a :root variable, a variable arm's dv, a media query's own condition, a pseudo-element's prop, and that a light-dark() declaration reads variables. It also checks what color-scheme publishes for each keyword form, a var() and :root.
  • src/__tests__/compiler/compiler.test.tsx adds folding cases inside an hsl() / rgb() alpha and a light-dark() arm, and its existing light-dark() case pins the new shape.

On main, 96 of the 127 cases in the first two 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 1461 passed, 4 failed — 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.

Known limits

  • lightningcss 1.30.1 parses color-scheme: inherit, unset and revert the same as normal, so they take the light arm where browsers keep the parent's scheme.
  • A colour inherited from an ancestor is resolved with the inheriting element's scheme. With .p { color-scheme: dark; color: light-dark(L, D) } .c { color-scheme: light; background-color: currentcolor }, .c paints L where browsers keep the parent's D.
  • border-block-color: light-dark() writes the edge pair, while a plain colour collapses onto borderBlockColor; fix: map every logical border property onto a prop React Native reads #393 gives every value the edge pair.

Related

#411 scopes the extra rule light-dark() opens inside a pseudo-element; with no extra rule, that part of #411 falls away in whichever lands second.

Merge order

This overlaps #389, #391, #411 and #413; I'll re-cut whichever lands after the others.

It also shares lines with #393 (compiler/declarations.ts, compiler/stylesheet.ts), #417 (compiler/declarations.ts); whichever lands second rebases.

Base

Re-written on main (a5002c5). It replaces the extra-rule rework this PR carried, which fixed the first and fourth bullets above but not the shorthands, the custom properties or color-scheme.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft August 15, 2026 14:35
@YevheniiKotyrlo YevheniiKotyrlo changed the title fix(compiler): give a light-dark() extra rule its own content fix(compiler): make a light-dark() extra rule a rule in its own right Aug 15, 2026
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review August 15, 2026 17:15
YevheniiKotyrlo added a commit to YevheniiKotyrlo/react-native-css that referenced this pull request Aug 15, 2026
The pinned divergence is that PR's defect 2, not a new finding, and the double
parse this branch fixes is its defect 6 — so the comment names the owner and the
merge order rather than implying either is unclaimed.
@YevheniiKotyrlo

YevheniiKotyrlo commented Aug 18, 2026 •

Copy link
Copy Markdown
Contributor Author

No current device pair

A pair on main would show #461's difference rather than this one: without #461 the bundler lowers light-dark() for browsers before the compiler sees it, so no row with one paints on the before. The tests in the description are the evidence.

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.
CSS Color 5 §7 makes `light-dark()` one value with two arms, resolved
against the used colour scheme. It compiled to two rules instead: the
current rule with the light arm and an extra rule under
`prefers-color-scheme: dark` with the dark one. Wherever the dark rule
could not reproduce the declaration it came from, dark mode broke:

- a second `light-dark()` re-asserted the first one's light value, and
  the dark rule published the light colour to `currentcolor`;
- a shorthand wrote its dark colour under the shorthand's name, so
  `box-shadow` became a bare colour and `border` gained a `border` key;
- a custom property holding `light-dark()` kept its light value in dark
  mode wherever the inliner did not fold it;
- inside a pseudo-element, the dark arm landed on the element.

`light-dark()` now compiles to a `lightDark` value the runtime resolves
against the colour scheme where it stands, so a shorthand, a custom
property, a pseudo-element and the colour a rule publishes all carry it
unchanged. The extra-rule mechanism had no other user and is removed.
CSS Color 5 §7 resolves light-dark() against the element's used colour
scheme, which CSS Color Adjust 1 §2.3 derives from color-scheme: the
preferred scheme when the element supports it, the one it supports
otherwise, and light when it supports none. Chromium, Firefox and
WebKit render it that way.

color-scheme now compiles to a custom property the element publishes
to its subtree, and the lightDark resolver reads it beside the
preferred scheme, so a light-dark() declaration resolves with the
variables in scope.

BREAKING CHANGE: light-dark() renders its light colour unless
color-scheme supports dark. Declare `color-scheme: light dark` on
:root for light-dark() to follow the preferred scheme, as browsers
require.

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