fix(compiler): map color: inherit to the inherited-color variable - #391
Open
YevheniiKotyrlo wants to merge 7 commits into
Open
fix(compiler): map color: inherit to the inherited-color variable#391YevheniiKotyrlo wants to merge 7 commits into
color: inherit to the inherited-color variable#391YevheniiKotyrlo wants to merge 7 commits into
Conversation
Tailwind's `text-inherit` (`color: inherit`) was dropped by the compiler and rendered React Native's default text color (black on Android) instead of the parent's color; web inherited correctly via real CSS. lightningcss emits `color: inherit` as an UnparsedProperty (the keyword is not a CssColor), and parseUnparsed's token/ident branch discarded it with a warning. Per CSS Color 4, `currentcolor` used as the value of `color` is defined as `inherit`, so both resolve to the --__rn-css-color variable every color rule already publishes to its subtree -- no new machinery. - parseUnparsed: resolve the inherited-color keywords -- `inherit`, `unset` (CSS Cascade: `unset` computes to `inherit` on the inherited `color` property), and `currentcolor` -- to the variable, matched case-insensitively (`currentColor`, `INHERIT`), instead of dropping them. - parseUnparsedDeclaration: fix the self-reference guard, which compared against a stale "-css-color" literal that never matched the emitted "__rn-css-color". Without it an inheriting rule publishes a circular --__rn-css-color: var(--__rn-css-color), breaking resolution for its subtree. `inherit` on non-color properties, and `initial`, remain dropped with a warning (no per-property inheritance context exists). Adds compiler + native-render tests, including the parent -> child -> grandchild chain that proves the self-reference fix, and corrects the vendor text-inherit test that had encoded the dropped-declaration behaviour.
YevheniiKotyrlo
marked this pull request as ready for review
July 26, 2026 14:53
4 tasks
mubeess
added a commit
to mubeess/react-native-css
that referenced
this pull request
Jul 31, 2026
- Hoist allowed Set to module scope (no per-call realloc) - Comment lightningcss case normalization and nativewind#391 asymmetry - Add negative test: match-parent drops with warning (no over-match) - Document native View->Text inheritance caveat in README
`color: inherit` is a READER of --__rn-css-color, the variable every colour
rule publishes to its subtree. A rule that reads that variable must not also
publish itself as it: the descriptor is handed to descendants unresolved, so a
descendant resolving it walks straight back into the same name and recursion
runs until the stack is exhausted — `RangeError: Maximum call stack size
exceeded` from a render.
The guard compared the top level of the value only, which misses every value
that buries the read one level down:
color: var(--brand, inherit)
color: color-mix(in srgb, currentcolor, blue)
color: rgb(from currentcolor r g b)
color: light-dark(currentcolor, blue)
so a parent with any of those plus a descendant `text-inherit` took the app
down. `readsInheritedColor` walks the whole descriptor tree instead, and the
publish is one `publishInheritedColor` used by both sites that publish —
`parseUnparsedDeclaration` for the keyword path and `parseFontColorDeclaration`
for the parsed-CssColor path, which had its own narrower `currentcolor` check
and let `light-dark(currentcolor, …)` through.
Withholding the publish leaves the nearest ancestor naming a colour of its own
as the one descendants inherit. That is exactly right for `inherit`, `unset`
and `currentcolor`, and an approximation for a value DERIVED from the inherited
colour, where descendants see the ancestor's colour rather than the derived
one — publishing the derived value needs resolution in the publisher's own
scope, which the compiler cannot do.
Also here, because they sit on the same two lines:
- `revert` / `revert-layer` reached the style as the literal string "revert",
and were published under --__rn-css-color, so a descendant reading the
inherited colour got `color: "revert"`. React Native has no cascade origins
to roll back to, so they drop with a warning like `initial`.
- `parseFontColorDeclaration` parsed the colour twice, once for the declaration
and once for the variable. `light-dark()` pushes an extra
`prefers-color-scheme: dark` rule as a side effect of parsing, so
`color: light-dark(a, b)` emitted that rule twice. It parses once now.
The two compiler tests asserting `currentcolor` behaviour are relabelled as
pins: `color: currentcolor` never reaches the restructured ident branch,
because lightningcss parses it into a CssColor that `parseColor` handles. That
branch's `currentcolor` clause is live for the UNPARSED properties — box-shadow,
filter: drop-shadow(), custom properties — and removing it fails
`src/__tests__/native/{box-shadow,filters}.test.tsx`.
`applyDeclarations` walks the target path into a fresh binding per declaration,
but that binding was the function's `target` PARAMETER, reassigned each time
around the loop. A delayed or transform closure captures it, and those closures
run after every declaration has been walked — so they saw whatever nested
object the LAST declaration ended on.
A rule with a delayed `color` and a `box-shadow` is the shape that shows it: the
shadow declaration walks into `["&", "boxShadow", "[0]", "color"]`, so the
colour's closure reads `getDeepPath(shadowObject, "color")`, never matches the
placeholder it minted against the style root, and leaves the internal
`{ color: true }` in the style. `text-shadow` does the same.
.parent { color: red }
.child { color: inherit; box-shadow: 1px 1px blue }
before: { color: { color: true }, boxShadow: [...] }
after: { color: "#f00", boxShadow: [...] }
The binding is now declared inside the loop, so each declaration's closures keep
the target that declaration resolved. `color: currentcolor` in place of
`color: inherit` reaches the same bug, so this is not specific to the keyword.
The name was spelled as a literal at the publish site and at each of the three places that compile a read of it, so the publish and the reads could drift apart silently. One constant, and one `inheritedColorLookup()` the three read sites call. It returns a fresh tuple per call because a descriptor is owned by the rule it lands in.
This was referenced Aug 15, 2026
Each defect on this branch should be observable from the compiler output AND from a render. Auditing what was here found four places where only one plane watched, plus one guard direction nothing watched at all. Every test below was mutation-proved: the thing it guards was broken, the test was watched go red for the right reason, and the break was reverted. Compiler plane - `revert` / `revert-layer` drop on EVERY property, not just `color`. The drop arm is keyword-first and only the resolving arm is gated on `color`, but the whole census sat on `color`. A property axis pins the arm that actually exists. Reverting the widened drop reddens 6 tests, 4 of them new. - `unset` on a non-color property is deliberately NOT dropped — it means `initial` there and the literal is what clears a colour. That exception was documented in a comment and asserted nowhere, so adding `unset` to the drop list was a silent change. - A value that CONTAINS a `var()` which is not the inherited colour still publishes. The publish census held only values resolving to a plain string, so a walk answering "reads the inherited colour" for ANY `var()` passed it untouched — no crash, just every descendant of a `color: var(--brand)` rule quietly ceasing to inherit. That mutation now reddens 4 tests; before it reddened none. Native plane - `color: inherit` with no coloured ancestor at all, which resolves against the root seed rather than the nearest publisher. - An ancestor whose colour is itself a variable, inlined and uninlined. The uninlined case is asserted as an equality against the ancestor rather than a literal, because the class is that the two agree. - `revert-layer` beside `revert`, and a `light-dark()` ancestor across a colour scheme change. - `color: currentcolor` beside a box-shadow — the stranded-target defect with no `inherit` in the input, so the runtime fix stays pinned if it is split out. - A transform key before a nested declaration. The two existing tests covered the delayed closure; the transform closure had nothing, and it fails worse: the translate values land INSIDE the shadow object and the transform array keeps its boolean placeholders. Two planes measured, not assumed - The double-parse of `light-dark()` is compiler-only. Restoring it puts three rules on the element instead of two and changes no rendered style; across all 1160 tests only the two compiler assertions redden. - The `calculate-props` target scoping is native-only. Reverting it reddens five render tests and not one compiler test. A divergence found while covering `light-dark()` is pinned rather than fixed: under dark, a descendant inherits the ancestor's LIGHT colour, because `light-dark()` publishes --__rn-css-color from its light branch while the extra dark rule carries the dark declaration. It reproduces with the double-parse restored, so it predates this branch. The expectation records it so a fix has to come back and change it.
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.
…split
`PIN: currentColor (camelCase) resolves like currentcolor` compared
`stylesheetFor("currentColor")` against `stylesheetFor("currentcolor")`,
and lightningcss folds both spellings into the same CssColor before this
package sees either — so both sides of the equality moved together and
the assertion could not fail. Returning "#000" from `parseColor`'s
currentcolor case turns its sibling pin red and leaves that one green.
It now asserts the compiled output, so the same mutation fails it.
The spelling this package folds itself is the one that reaches the ident
branch, and a custom property is where that happens. That surface splits
by whether `inlineVariables` folded the name, which it does only for a
name declared once:
- declared once, `--brand: inherit; color: var(--brand)` compiles as
`color: inherit` and resolves to the inherited-color variable;
- declared twice, the fold is defeated and the keyword is met on a
custom property, which carries no property context, so it drops and
the lookup resolves to nothing.
Both routes dropped before `color: inherit` mapped to the variable, so
the divergence between them is new even though the unfolded route's
defect is not. Both are pinned, along with the keyword rows for a custom
property: `inherit` / `initial` / `revert` / `revert-layer` drop there
while `currentcolor` resolves, because that arm is keyword-only rather
than gated on the property. The `currentcolor` rows double as the
vacuity guard for the drop rows — same construction, so an empty result
there would mean the inliner had eaten the declaration.
Contributor
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.


Summary
Two independent defects, both in how the inherited-color variable is handled. The first is the title's:
text-inherit(color: inherit) renders correctly on web but falls back to React Native's default text color on native, so a dark surface with an inheriting label becomes black-on-black:The second I found while fixing the first, and it is worse:
maintoday crashes a render withRangeError: Maximum call stack size exceededfor several ordinary colors. That one is independent ofinheritand reproduces on stock3.0.7— details below.A third, smaller divergence rides along on the same lines and is worth naming up front, because it is the one place the
currentColorspelling React authors write reaches the compiler's own keyword handling: through a custom property.--brand: currentColorpublishes the literal string"currentColor"as the variable's value onmain, and every consumer renders that string as a color. The case fold added here resolves it. See The same keywords on a custom property, which also records — rather than hides — a route split this PR opens oninherit.Cause — web keeps the CSS, native drops it
The two runtimes handle
classNamedifferently, and only native breaks:web/api.tsx'suseCssElementassigns{ $$css: true, className }tostyle, so react-native-web applies the class name as a real CSS class.color: inheritis then resolved by the browser's own cascade — nothing is compiled.compiler/declarations.tscompiles each value to an RN style object. Its token/ident branch treatsinheritas unsupported and drops it with a warning, so the<Text>gets no color at all and RN's default wins.Fix 1 — map the inherited-color keywords to the variable
The native runtime already carries the mechanism. Because React Native doesn't inherit
coloracross elements, every color rule publishes--__rn-css-colordown its subtree, andparseUnparsedalready resolvescurrentcolorto it.color: inheritjust needs the same path — and it isn't an approximation: per CSS Color 3 §4.4, "if thecurrentColorkeyword is set on thecolorproperty itself, it is treated ascolor: inherit." Same value, same descriptor.unsetis included because it computes toinheriton inherited properties andcoloris inherited.The
.toLowerCase()is load-bearing on two surfaces, not one. On a declared property it servesINHERIT/UNSET/INITIAL, which lightningcss hands through unfolded. It also serves the only route by which thecurrentColorspelling reaches this branch at all: a custom property's value arrives as raw tokens, so--brand: currentColoris met here as a literal string rather than as a parsedCssColor. Written directly,color: currentColornever gets this far — lightningcss parses it andparseColorhandles it, and the fold there is lightningcss's rather than ours.Fix 2 — a color that READS the inherited color must never publish itself
This is the crash, and it is not caused by fix 1 — it is reachable on
mainright now.A rule's color is handed to descendants as an unresolved descriptor. So if a rule both reads
--__rn-css-colorand publishes itself as--__rn-css-color, a descendant resolving it walks straight back into the same name and recursion runs until the stack is exhausted. There were two guards against that and both inspected only the top level of the descriptor, which misses every value that buries the read one level down:A parent with any of those plus a descendant reading the inherited color takes the app down.
readsInheritedColorwalks the whole descriptor tree instead, and onepublishInheritedColorreplaces both guards —parseUnparsedDeclarationfor the keyword path andparseFontColorDeclarationfor the parsed-CssColorpath, which had its own narrowertype !== "currentcolor"test and letlight-dark(currentcolor, …)through.Withholding the publish leaves the nearest ancestor naming a color of its own as the one descendants inherit. That is exactly right for
inherit,unsetandcurrentcolor— see the open question below for the case where it is an approximation.Two smaller things ride along, because they sit on the same lines:
revert/revert-layerreached the style as the literal string"revert"and were published under--__rn-css-color, so a descendant reading the inherited color gotcolor: "revert". React Native has no cascade origins to roll back to, so they now drop with a warning likeinitial.parseFontColorDeclarationparsed the color twice.light-dark()pushes an extraprefers-color-scheme: darkrule as a side effect of parsing, socolor: light-dark(a, b)emitted that rule twice. It parses once now.Fix 3 — a declaration's style target is scoped to its own iteration
applyDeclarationswalked the target path into a fresh binding per declaration, but that binding was the function'stargetparameter, reassigned each time around the loop. The delayed and transform closures capture it and run after every declaration has been walked, so they saw whatever nested object the LAST declaration ended on.A transform key fails worse — the values land inside the shadow object and the transform array keeps its boolean placeholders:
.my-class { translate: 10px 20px; box-shadow: 1px 1px blue } - { transform: [{ translateX: true }, { translateY: true }], - boxShadow: [{ …, transform: [{ translateX: 10 }, { translateY: 20 }] }] } + { transform: [{ translateX: 10 }, { translateY: 20 }], boxShadow: [{ … }] }color: currentcolorin place ofcolor: inheritreaches the same bug, so this is a general runtime defect, not aninheritone — it is the one part of this PR I would happily split out, and its tests are written not to depend oninheritso that a split is clean.Keyword coverage on
colorinherit,unset,currentcolor(any case)var(--__rn-css-color), the inherited colorinitialrevert,revert-layerinherit/initialon a non-color propertyunseton a non-color propertyinitial, and the literal is what clears the colorThe same keywords on a custom property — two routes, and only one of them resolves
A custom property is a non-color property as far as the keyword arm is concerned, so by the table above it drops. What makes it worth its own section is that the same CSS reaches two different outcomes depending on a rule elsewhere in the sheet.
inlineVariableskeys on a custom property's declaration COUNT: a name declared exactly once is folded into its consumer at compile time and its declaration deleted, so the keyword is met oncolorand the property context exists. Declare the same name twice and the fold is defeated,var(--brand)survives as a runtime lookup, and the keyword is met on the custom property, where there is nothing to inherit from.Measured,
.parent { color: red }above.child { --brand: K; color: var(--brand) }, where route B adds a second.other { --brand: K }to defeat the inliner. The cell is the child's renderedprops.style:--brand:main(f70c402)inheritundefined— the whole.childrule compiles away{ color: "#f00" }inherit{}{}currentcolor{ color: "#f00" }{ color: "#f00" }currentcolor{ color: "#f00" }{ color: "#f00" }currentColor{ color: "#f00" }{ color: "#f00" }currentColor{ color: "currentColor" }{ color: "#f00" }Two things follow, and I would rather state both than let a reader infer the flattering one:
--brand: currentColoris a second divergence this PR closes, and it is the camelCase spelling's only non-trivial home. Onmainthe unfolded route publishes the literal string as the variable's value and the element renders it; the.toLowerCase()is what makes the two routes agree.inheritsplit is NEW as of this PR. Onmainboth routes dropinherit, symmetrically and equally wrongly — the folded one by compiling the rule away entirely, the unfolded one by leaving a rule whose lookup resolves to nothing. This change reaches the folded route only, so afterwards A resolves and B still drops. The unfolded defect is old; the disagreement between the two is not. Closing it is not a keyword-table change — a custom property would have to carry the property context of whatever consumes it, which is a resolver change — so B is pinned at its current output, with the CSS-correct answer named in the test.The published-value rows on the compiler plane, same construction (two declaring rules, hence two published entries):
--brand:mainpublishesinherit,initialrevert,revert-layer["revert", "revert"]— the literalINHERIT["INHERIT", "INHERIT"]— the literalcurrentcolorvar(--__rn-css-color)lookupsvar(--__rn-css-color)lookupscurrentColor["currentColor", "currentColor"]— the literalvar(--__rn-css-color)lookupsunset["unset", "unset"]["unset", "unset"]unsetis deliberately absent from the drop rows and stays a literal, for the same reason it is absent from the keyword table: on anything that is notcolorit meansinitial, and the literal is what the runtime clears a value with — the same contractbackground-color: unsetrelies on.Why these rows are not in the keyword table. That table's template is
.child { ${property}: ${keyword}; }, one declaration. For a custom property that compiles to{}because the inliner deleted a once-declared name, so a row there would be vacuously green even if the ident branch resolvedinheriton a custom property. The rows use the two-definition construction in an adjacent block instead, cross-referenced from the table. Thecurrentcolorrows are the vacuity guard for the drop rows: same construction, non-empty expected result, so an inliner change that ate the declarations turns them red rather than silently hollowing out the drops.Test plan
Every defect above is covered on both planes — the compiler output and a real render — and each test was mutation-proved: I broke the thing it guards, watched it go red for the right reason, and reverted. Reverting each fix in turn reddens, measured across the whole suite on the current head:
inheritmapping (fix 1)RangeErrorparseFontColorDeclaration's publish guardRangeErrorvar()as a read (over-eager walk)revert/revert-layerdroplight-dark()The crash is proven red-to-green rather than pinned as a
toThrow: the census renders a parent → mid → child chain for each input and asserts the resolved colors, and on the unfixed code six of those renders die withRangeError: Maximum call stack size exceeded.Two rows are deliberately zero, and both are measurements rather than omissions. The
light-dark()double parse is compiler-only: restoring it puts three rules on the element instead of two and changes no rendered style, so only the two compiler assertions redden. The target scoping is native-only: it lives insrc/native/styles/calculate-props.ts, which the compiler never executes, so reverting it reddens five render tests and not one compiler test.The vendor
text-inherittest (vendor/tailwind/typography.test.tsx) had encoded the dropped-declaration behaviour (props: {}, warnings: { color: "inherit" }) and now asserts the resolved label color, identical to the passingtext-current. Itsdecoration-inheritsibling still drops, which confirms thecolor-only scope.One pin asserts an absolute value, because the comparison it would otherwise make cannot fail
PIN: currentColor (camelCase)asserts the compiled output literally rather than comparingstylesheetFor("currentColor")againststylesheetFor("currentcolor"). Both of those spellings are folded by lightningcss, before this package runs, so the two sides of that equality move together whatever this package then does with the result — the assertion is structurally incapable of failing.The counterfactual, since a claim like that is worth measuring rather than reasoning about. Mutate
parseColor'scase "currentcolor": return inheritedColorLookup()toreturn "#000":Note that a revert-based check cannot detect this class at all, by construction: reverting asks whether an assertion notices the new behaviour disappearing, never whether it notices anything. All four
PIN:tests are green under a full revert of this branch, which is exactly what a pin of pre-existing behaviour should do — the other three say so in their comments, and none of them is evidence that the assertion can fail.Gates
yarn teston the current head:Test Suites: 2 failed, 4 skipped, 53 passed, 55 of 59 total,Tests: 3 failed, 21 skipped, 1151 passed, 1175 total.The 3 are
react-native › plugin › 7.,react-native-web › plugin › 6.and17.— Windows-onlybabel-plugin-testercases that are not this branch's: they fail identically, same three names, with both changed source files reverted tof70c402, which is how every revert measurement below was taken. That is the stable baseline on a warm cache; on a cold one this machine also produces a load-dependent tail of whole-suite run failures (a jest transform-cacheEPERM, which subtracts that suite's tests from the total rather than failing an assertion) that vanish on a re-run. Read theTest Suites:line, not justTests:, before comparing totals.yarn typecheckandyarn lintpass.Known limits
--brand: initialmakes the consuming rule vanish silently on the folded route. Measured on this branch:This is one layer earlier than anything here:
inlineVariablescarries its own "the value isinitial→ substituteundefined" special case, which fires before the compiler's keyword arm ever runs, and the route-dependence is the same declaration-count fold as above. It is adjacent to this PR rather than part of it, and fixing it needs a semantics decision first — what shouldinitialmean for an unregistered custom property, given there is no@propertydescriptor to take an initial value from — so I have left it alone rather than attaching a rider to a keyword change.The unfolded custom-property route still drops
inherit, as the table above records. Pinned, not fixed, with the CSS-correct expectation named in the test comment.Open questions
inherit,unsetandcurrentcolor, and an approximation for a value that DERIVES from the inherited color: withcolor: color-mix(in srgb, currentcolor, blue)on a middle element, descendants see the ancestor's color rather than the mixed one. Publishing the derived value is only possible once resolution happens in the publisher's own scope, which the compiler cannot do. Two census entries pin the approximation rather than hide it — happy to change the call.light-dark()on the render plane turned up a divergence — under dark an ancestor renders#00fwhile a descendant declaringinheritgets#f00— and fix(compiler): make a light-dark() extra rule a rule in its own right #420 already owns it as its defect 2. I have pinned it here as a known divergence rather than fixed it, so whichever of the two lands second updates the expectation. The double parse inparseFontColorDeclarationis fixed on both branches too (fix(compiler): make a light-dark() extra rule a rule in its own right #420's defect 6), so that function will conflict on merge and either copy can be dropped — they are the same change. Happy to rebase onto fix(compiler): make a light-dark() extra rule a rule in its own right #420, or to drop the double-parse hunk here, whichever suits the merge order.rgb(from currentcolor r g b)is pinned at its current non-implemented output ("rgb(from, #f00, r, g, b)"), deliberately — it is in the census for the crash, not for relative-color support. Implementing relative color syntax must update that expectation.currentcolorand I am happy to split it into its own PR.revert/revert-layerchange from emitting a literal to being dropped. Observable, and outside the title's scope — but leaving them was actively harmful, since they were published as the inherited color.inheritroute split acceptable as a landing state? It is strictly better thanmain— one of the two routes now works where neither did — but it is a disagreement that did not exist before, and I would rather you decide that than discover it. The alternative is holding this until a custom property can carry its consumer's property context, which is a much larger change.