Skip to content

fix(compiler): make a light-dark() extra rule a rule in its own right - #420

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

fix(compiler): make a light-dark() extra rule a rule in its own right#420
YevheniiKotyrlo wants to merge 3 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 carrying the light branch, and an extra copy of it under prefers-color-scheme: dark carrying the dark branch. The extra rule is never built or applied as a rule in its own right — it is seeded from the current rule mid-parse (extendRule), then patched onto a clone of the rule that has already been applied to the selector (createRuleFromPartial). Five consequences, all silent, all in dark mode.

1. A second light-dark() declaration re-asserts the first one's light value

parseUnresolvedColor opens its extra rule with extendRule({ m: [...] }), which is { ...this.rule, ...rule } — a snapshot of everything already written to the rule. The second declaration's dark rule therefore carries the first declaration's light descriptor, and being applied last, it wins.

Two light-dark() colours on one rule is ordinary in a themed stylesheet, and a var() anywhere inside either branch is what keeps the colour unresolved and routes it through this path:

:root { --o: 1; }
.parent {
  color: light-dark(hsl(0 100% 50% / var(--o)), hsl(240 100% 50% / var(--o)));
  background-color: light-dark(hsl(120 100% 25% / var(--o)), hsl(60 100% 50% / var(--o)));
}

Rendered through @testing-library/react-native with colorScheme.set("dark"):

main (f70c402) this branch
light color: hsl(0, 100, 50, 1), backgroundColor: hsl(120, 100, 25, 1) same
dark color: hsl(0, 100, 50, 1), backgroundColor: hsl(60, 100, 50, 1) color: hsl(240, 100, 50, 1), backgroundColor: hsl(60, 100, 50, 1)

The background colour switches and the text colour does not. Not because color's dark rule is wrong — it is correct and it is applied — but because the background's dark rule then re-applies the light red over it.

2. The dark rule publishes the light --__rn-css-color

createRuleFromPartial copies m and d from the extra rule and nothing else, so v — the variables the rule publishes to its subtree — stays whatever the base rule's clone held. --__rn-css-color is the channel currentcolor and inherited colour resolve through, so on main .a { color: light-dark(red, blue) } renders #00f and tells every descendant #f00.

3. dv does not survive either, so a dark-only var() is not flagged for late resolution

.c { background-color: light-dark(red, var(--d)); } — only the dark branch reads a variable, so dv (resolve this rule's declarations late) is the extra rule's own requirement; the rule it copies has no delayed declaration to inherit the flag from. On main the dark rule comes out dv=undefined; on this branch dv=1.

4. The LAST extra rule on a rule republishes the base rule's variable

Fixing 2 by replacing v from the extra rule leaves a fallback: when the extra rule publishes nothing, the merged rule keeps the base rule's v. That is invisible for a single extra rule and wrong for the last of several. background-color publishes nothing, so its extra rule — opened after color's, and applied after it — carries a copy of the base rule's light colour over the dark one color's extra rule had already published.

.parent { color: light-dark(red, blue); background-color: light-dark(green, yellow); }
.child  { background-color: currentcolor; }
main this branch
dark, parent color: #00f, backgroundColor: #ff0 same
dark, child backgroundColor: #f00 backgroundColor: #00f

5. Inside a pseudo-element, the dark half lands on the element

::selection and ::placeholder map their color onto a prop of the host component. That mapping is applied to the rule on its way to the selector — and the extra rule is composed after it, replacing d wholesale with a partial that never went through it. So the light half is scoped and the dark half is a plain color on the element.

.a::selection { color: light-dark(red, blue); }
main this branch
light d=[{}, ["#f00",["selectionColor"]]] same
dark d=[{"color":"#00f"}] d=[{}, ["#00f",["selectionColor"]]]

Rendered on a TextInput, main in dark mode gives selectionColor: "#f00" — still the light one, because the dark rule never produced a selectionColorand style: { color: "#00f" }, which recolours the input's own text. ::placeholder behaves identically with placeholderTextColor.

This is the defect #411 exists to close, on a path its field policy never sees: #411 scopes the rule, and the extra rule is composed after the scoping ran.

Root cause

One site, two directions. The extra rule is composed onto the rule that has already been applied to the selector, by patching a clone of it — so it inherits content it must not (1, 2, 3, 4) and skips shaping it must have (5).

Two smaller ones sit under that:

  • builder.descriptorProperty is a single property, and addUnnamedDescriptor — the seam a light-dark() dark branch is written through — reaches only that one. A color declaration writes twice (the style property, and the variable it publishes), so one of the two was always going to be missed.
  • parseFontColorDeclaration parses its colour twice, once per write, and parseColor is not pure — a light-dark() value opens an extra rule. So a color: light-dark() emitted two identical dark rules.

Fix

openExtraRule(condition) creates the extra rule empty and hands it back for the caller to write descriptors into through the same addDescriptor seams as the current rule. Both call sites in parseColor / parseUnresolvedColor use it; extendRule and addExtraRule go away.

mergeExtraRule builds from the extra rule rather than patching the applied one. The rule it was opened on supplies the SELECTOR — its specificity, its container query, and the media conditions the extra one is added to. The extra rule supplies the CONTENT, in full, and inherits none of it: the rule it was opened on matches under the extra condition too, so anything the extra rule leaves out still arrives from there, while restating it makes the extra rule a second place that value is written.

Pseudo classes and attribute queries are not in that list because they are never on the rule the merge copies from. applyRuleToSelectors reads them off the SELECTOR and writes them onto every rule it applies, the merged one included, so a copy in the merge would be a second source for a value that already arrives one step later. A tripwire on rule.p / rule.aq inside the merge fired on no test in the suite and on none of 20 targeted probe cases; the same tripwire on rule.m / rule.cq fired on five, so the instrument was doing its job.

applyRuleToSelectors puts every rule a selector receives through one pipeline. The extra rules are merged up front, then each source rule is cloned, scoped to the pseudo-element, and given the selector's specificity and queries by the same code. The parent classes a container query names describe the selector rather than the rule, so they are still registered once however many rules it receives.

descriptorProperty becomes descriptorProperties: readonly string[], and parseFontColorDeclaration / parseUnparsedDeclaration name both color and --__rn-css-color on it. StylesheetBuilder is not re-exported from react-native-css/compiler, so that rename is internal.

parseFontColorDeclaration parses its colour once and uses the value for both writes.

Which plane

Compiler (src/compiler/declarations.ts, src/compiler/stylesheet.ts), and it reaches only the native runtime — the compiled rule set is what src/native consumes. Web needs no mirror: it serves the CSS to the browser, which implements light-dark() itself. There is no light-dark() handling anywhere under src/web.

Tests

42 in two files. 24 fail on main, 18 pass.

  • src/__tests__/compiler/light-dark.test.ts — 28 tests, 17 fail on main. What each dark rule is allowed to contain: its own declaration only, its own dv, its own published colour, nothing restated from the rule it copies, and the pseudo-element mapping its rule was given.
  • src/__tests__/native/light-dark.test.tsx — 14 tests, 7 fail on main. The rendered pairs from the tables above, a dark-branch var() asserted against the same variable read outside light-dark(), inheritance driven across colorScheme.set, and ::selection / ::placeholder on a TextInput in both schemes.

Six of the 28 pass on both refs, deliberately. mergeExtraRule states the selector context as an enumerated field list, so a field dropped from that list is a silent behaviour change rather than a compile error — those six hold the fields it decides. Each was measured by removing the behaviour and confirming which tests redden, over the whole suite:

Removed Reddens
the cq carry in mergeExtraRule 1 — the dark rule of the @container case loses its container query, so it applies outside the container
the m carry in mergeExtraRule 1 — the dark rule of the @media case loses [">=","width",100], so it applies at any width
the once-per-selector container guard 1 — a selector with three light-dark() declarations registers its parent container class 4× instead of 1×
the selector's pseudo class on every rule but the first 1
the selector's attribute query on every rule but the first 1

Nothing else reddens in any of the five, and each of those five reddened nothing at all before these tests existed. Each assertion is stated over the light rule rather than over a literal, so what it measures is the carry and not the encoding.

Every light-mode case is stated beside its dark one and stays green throughout. It is not filler — the light half is the surface an over-broad fix breaks.

Full suite, typecheck and lint measured on the same machine and worktree layout, no new failures. main is 1048 passed / 3 failed (the two src/__tests__/babel/* suites, which fail identically at every ref on Windows); this branch is Test Suites: 2 failed, 4 skipped, 55 passed, 57 of 61 total / Tests: 3 failed, 21 skipped, 1090 passed, 1114 total — the whole +42 is these two files. yarn typecheck and yarn lint exit 0 on both.

KNOWN LIMITS

None outstanding in light-dark() handling. Three things adjacent to it are deliberately left alone:

  • A shorthand emits one dark rule per longhand it expands to. parseColor opens an extra rule as a side effect of parsing, so a caller that parses the same value more than once opens that many — which is defect 6 above, reached by a different caller. Measured identically on main (f70c402) and on this branch: border-color emits 4 identical dark rules, border-inline-color and border-block-color 2 each. Every other colour property probed emits 1: background-color, the four physical border-*-color longhands, the four flow-relative single-side longhands, text-decoration-color, caret-color, outline-color, fill, stroke. Both refs agree on every row, so none of it is a regression this PR introduces; the one property in that census this PR changes is color, which goes from 2 dark rules to 1.
  • A pseudo-element rule still publishes --__rn-css-color. .a::selection { color: red } scopes its declaration and still hands the element's subtree the colour, because the scoping rewrites d and leaves v. That is the same fault on a channel this PR does not touch, and fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element #411's field policy is what closes it — for the base rule and, once both land, for the extra rule too, since they now go through one call site.
  • ::selection's mapping is colorselectionColor. fix(compiler): scope ::selection / ::placeholder declarations to the pseudo-element #411 argues that is the wrong pair and changes it to background-color. This PR only makes the dark half take whichever mapping the light half took.

On the version bump. No BREAKING CHANGE: footer, so the preset recommends patch. Defect 5 does remove behaviour — a dark-mode color that used to reach the element no longer does — but the light half never reached it either, so this makes the two halves of one declaration agree rather than changing a contract anyone could have relied on. Say the word if you would rather it were marked.


Overlaps with open PRs. Measured with git merge-tree against every open PR head, at this branch's head:

`light-dark()` resolves to two values where a declaration parser returns
one, so the dark branch is delivered by an extra copy of the current rule
under `prefers-color-scheme: dark`. The caller built that copy itself and
the merge read only two of its fields, so the copy was never the dark
branch alone. Three consequences, all one contract failure:

- The unresolved-colour path seeded the copy with `extendRule`, a snapshot
  of the current rule taken mid-parse. A second `light-dark()` on the same
  rule therefore re-asserted the first declaration's LIGHT value over the
  dark one already written — `color` and `background-color` together, the
  ordinary case in a themed stylesheet, rendered the light colour in dark
  mode.
- The merge copied the current rule's variables verbatim, so the dark rule
  published the LIGHT colour as `--__rn-css-color`. A descendant's
  `currentcolor` resolved to the light colour in dark mode.
- The merge dropped the copy's own `dv`, so a dark branch that reads a
  variable never resolved: `light-dark(red, var(--d))` rendered nothing in
  dark mode.

The builder now owns the extra rule. `openExtraRule` creates it EMPTY and
hands it back for the caller to write through the same `addDescriptor`
seams, so it cannot be seeded from the current rule; `extendRule`, whose
only caller was the broken one, is gone. The merge carries the extra
rule's declarations, variables and delayed-resolution flag, and takes
everything else from the rule it copies.

Publishing the dark colour needs the dark branch to reach both properties
a `color` declaration writes — the style property and the variable — so
`descriptorProperty` becomes `descriptorProperties`, the list an unnamed
descriptor is written to.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft August 15, 2026 14:35
An extra rule was composed onto the rule that had already been applied to
the selector, by patching a clone of it. That single site let content cross
in both directions.

It inherited content it must not. `mergeExtraRule` fell back to the base
rule's `v` when the extra rule published nothing — right for a single extra
rule, wrong for the last of several. Applied last, its copy of the light
value overwrote the dark one an earlier extra rule had already published,
so `.p { color: light-dark(red, blue); background-color: light-dark(green,
yellow) }` rendered `#00f` and handed `#f00` to every descendant. The extra
rule now supplies its content in full and inherits none: the rule it was
opened on matches under the extra condition too, so anything it leaves out
still arrives from there.

And it skipped shaping it must have. Pseudo-element scoping ran on the
rule on its way to the selector, before the extra rule was composed, so
`.p::selection { color: light-dark(red, blue) }` mapped the light half onto
`selectionColor` and left the dark half painting the host's own text.
`applyRuleToSelectors` now puts every rule a selector receives through one
pipeline, the extra ones included.

Separately, `parseFontColorDeclaration` parses its colour once for both of
its writes. `parseColor` is not pure — a `light-dark()` value opens an extra
rule — so the second parse opened a second, identical dark rule.
@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
`mergeExtraRule` states the selector context as an enumerated field list, so
a field dropped from it is a silent behaviour change rather than a compile
error. Three of the behaviours that list decides were live and unguarded —
each measured by mutation, each reddening nothing across the suite:

- the container query it carries. Dropped, the dark rule of
  `@container box (min-width:100px) { .a { color: light-dark(red,blue) } }`
  applies outside the container.
- the media conditions its rule already matched under. Dropped, the dark rule
  of the `@media` case applies at any width.
- the once-per-selector container registration. Removed, a selector with three
  `light-dark()` declarations registers its parent container class four times
  instead of once.

Each is now stated over the light rule rather than over a literal, so the
carry is what the assertion measures and not the encoding.

`if (rule.p)` and `if (rule.aq)` go: nothing writes either onto the rule an
extra rule is opened on. A tripwire on them fired on no test and no probe
case, while the same tripwire on `m` and `cq` fired on five. Pseudo classes
and attribute queries are read off the selector and written onto every rule
`applyRuleToSelectors` applies, this one included, so the merge copying them
would be a second source for a value that already arrives one step later.
Two tests pin that, and both redden if an extra rule stops receiving them.

`border-color` emits four identical dark rules and `border-inline-color` and
`border-block-color` two each, so a colour declaration is not held to one dark
rule by the property being a colour. The comment claiming otherwise now says
what a shorthand does and why.
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.
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