fix(compiler): an unresolved alpha corrupts the colour beside it - #421
Open
YevheniiKotyrlo wants to merge 5 commits into
Open
fix(compiler): an unresolved alpha corrupts the colour beside it#421YevheniiKotyrlo wants to merge 5 commits into
YevheniiKotyrlo wants to merge 5 commits into
Conversation
lightningcss resolves the rgb channels of an `UnresolvedColor` to integers in the 0-255 range before handing them over, the percentage syntax included, so multiplying by 255 again produces impossible channels: `rgb(255 0 0 / var(--a))` compiled to `rgba(65025, 0, 0)`. React Native clamps to 255, which hides the defect while every channel is already saturated, but destroys any other colour: `rgb(50% 25% 10% / var(--a))` reached the view as `rgba(32640, 16320, 6630)` and painted white instead of brown. `parseColor` reads the same 0-255 channels and divides by 255 for colorjs.io's 0-1 sRGB space. The unresolved path emits a CSS `rgba()` string, so its channels pass straight through.
`parseUnresolvedColor` emitted the hue, saturation and lightness as bare numbers, so `hsl(0 84.2% 60.2% / var(--a))` reached the view as `hsl(0, 84.19999694824219, 60.20000076293945, 0.5)`. React Native reads no percentage units on the saturation and lightness there, normalizes the whole declaration to null, and leaves the property unset. An `hsla()` spelling carrying those units is accepted, so a valid hsl form does exist, but it degrades badly under the one thing an `UnresolvedColor` guarantees: the alpha is a `var()`. An unset variable with no fallback drops the argument, which leaves `hsla()` three-argument and rejected, while `rgba()` stays valid and renders opaque. lightningcss resolves every channel of an `UnresolvedColor` and leaves only the alpha open, so the hue, saturation and lightness convert to the sRGB channels `parseColor` writes for the resolved spelling. Both spellings then compile to one colour, and a dropped alpha degrades the same way in each.
lightningcss clamps saturation, lightness and every rgb channel to their range, which leaves the hue as the one unbounded channel: it serializes a non-finite `calc()` hue as a float that reparses to `Infinity`. colorjs.io reduces a hue modulo 360, so such a hue spreads `NaN` across all three sRGB coordinates and `hsl(calc(NaN) 100% 50% / var(--a, 0.5))` reaches the view as `rgba(NaN, NaN, NaN, 0.5)`, which normalizes to null and leaves the property unset. The resolved spelling of that declaration renders red, because lightningcss folds it to a colour before `parseColor` ever runs. colorjs.io serializes a non-finite hue as `#NaNNaNNaN` rather than coercing it, so reading `coords` directly is what exposes the divergence at exactly this input. A hue that is not a real number now takes the `0` CSS Color 4 gives a missing component, which is also the hue lightningcss resolves the same declaration to. `Number.isNaN` does not cover this, so the guard tests for a finite value: the hue arrives as `Infinity`, not as `NaN`. The added parity cases assert that each unresolved spelling and its resolved twin reach React Native as one colour, which is the property both fixes establish.
YevheniiKotyrlo
marked this pull request as draft
August 15, 2026 14:35
The hue guard tested `Number.isFinite`, which only catches a hue that arrives as `Infinity`. A `calc(infinity)` hue does not: the compiler runs lightningcss twice and the second pass reparses the first pass's serialized output, where the hue saturates rather than overflowing. Measured through the compiler, `hsl(calc(infinity) 100% 50% / var(--a, 1))` arrives as `9223372036854776000`, and `hsl(1e20 ...)` and `hsl(1e38 ...)` both arrive as `9223369837831520000` — the same value, because past the ceiling the declaration's hue is not carried at all. colorjs.io reduces those modulo 360 and hands back a colour, so the compiler emitted `rgba(255, 34, 0, ...)` for the first and `rgba(255, 0, 102, ...)` for the other two, none of which the declaration asked for and none of which agrees with the resolved spelling of the same colour. The guard's subject was wrong rather than its threshold. A hue arrives as a 32-bit float, and reducing one modulo a turn only says something about the author's angle while that float still resolves finer than the turn. A float32 holds a 24-bit significand, so its ULP at `2 ** exponent` is `2 ** (exponent - 23)` and first covers a whole turn at `2 ** 32`; past there every representable neighbour lands on a different angle. `Infinity` is the same condition at the top of the range, which is why one test covers both. Such a hue now takes the `0` CSS Color 4 gives a missing component. Measured against the resolved spelling, that is also what lightningcss itself resolves `2 ** 32`, `1e10`, `1e15`, `1e20`, `1e30` and `1e38` to, so six more rows join the parity property this branch establishes, and `hsl(-600 ...)`, `hsl(720 ...)` and `hsl(1e7 ...)` are reduced as before rather than clamped. `calc(infinity)` is the one input left out. lightningcss resolves a hue that large in 32-bit floats and its answer is not a function of the hue — `1.40e38` resolves red, `1.42e38` black, `1.46e38` red again — so there is nothing to match. The compiler produces the answer that IS a function of the hue, and a test pins the divergence so it goes red if lightningcss ever stabilises.
The runtime behaviour is unchanged. Three claims that shipped with the guard were refutable, and the residual's justification was the weakest of them. `calc(infinity)` was described as the one input left out. It is a family: sampling f32 hues above `2 ** 32`, about one in seven resolves to something other than the red the compiler emits — `5e10`, `1e12`, `1e18`, `1.44e38` and `calc(-infinity)` among them. The commit that added the guard cited three of these as evidence without noticing they were the same divergence class. The reason given for leaving that residual open — that lightningcss's answer is not a function of the hue — is refutable in one command. It is deterministic. The real reason is stronger. Seventeen authored hues from `1e19` to `9223372036854775807` all reach the compiler as the single value `9223369837831520000`, and lightningcss's resolved path splits that one arriving value twelve red to five black. The information separating them is destroyed before the compiler sees it, so no function of the arriving hue can reproduce the split — not because lightningcss is erratic, but because the compiler is handed one number for seventeen inputs. The pass attribution was also wrong. A visitor is what materialises the AST into JavaScript and back, and the hue saturates to i64 on that round trip. Pass one's declaration visitor saturates `1e19` through `1e38`, serializing all of them as `9223370000000000000`; pass two's rule visitor saturates `calc(infinity)`, which pass one leaves at the float32 maximum `3.40282e38`. Removing the rule visitor leaves `calc(infinity)` arriving unsaturated, which is how that split was measured. Below the threshold the binding wall is lightningcss's six-significant-digit serializer, not the float32 ULP grid, and it bites from about `1e6` — far below `2 ** 32`. `1234567` arrives as `1234570`, `12345678` as `12345700`, `123456789` as `123457000`. Sampling forty full-precision hues per decade, the arriving hue differs from the authored one in 38/40 of `[1e6, 1e7)` and 40/40 of every decade above. So the claim that the arriving float still resolves finer than the turn below the threshold was false over three decades of that range. The guard's own effect was miscounted too. It turns twelve assertions across the two files from red to green, three of them parity rows — `hsl(4294967296 …)`, `hsl(1e20 …)` and `hsl(1e38 …)`. The earlier "six more rows" counted hues measured to resolve red, half of which are not rows in the parity list at all. Two test gaps close with it. The tests bounded the constant from above and barely from below: every value from `10000001` to `4294969856` left all 55 assertions green, a 429-fold interval that `2 ** 31` sat inside. The `720` row was the reason — 720 reduces to 0, the same answer the clamp gives, so it survived a mutation that coerced every hue while both its siblings went red. Replacing it with `3e9` closes both gaps: `3e9` reduces to 120°, which the clamp does not produce, and it sits between `2 ** 31` and `2 ** 32` where the ULP is 256 and so still finer than a turn. The green window is now `[3000000001, 4294969856]`, a factor of 1.43, and `2 ** 31` is red. `3e9` also arrives exactly — one significant digit survives any serializer — so the pin does not depend on the loss described above. `Number.isFinite(hue) &&` was dead. `Math.abs(NaN) < x` and `Math.abs(±Infinity) < x` are both `false`, so the clause could not change a result; removing it leaves all 55 assertions green. The comment now says why no finiteness test is needed rather than carrying one that does nothing. Full suite unchanged: `2 failed, 4 skipped, 53 passed, 55 of 59 total` and `3 failed, 21 skipped, 1084 passed, 1108 total`, the 3 being the two `src/__tests__/babel/*` suites that fail at every ref on Windows. `yarn typecheck` and `yarn lint` exit 0.
YevheniiKotyrlo
marked this pull request as ready for review
August 15, 2026 19:13
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.
Problem
lightningcss hands the compiler an
UnresolvedColorwhen every channel of a colour is known but its alpha is avar()— the shapergb(239 68 68 / var(--tw-bg-opacity))takes. Four defects live in the one function that consumes it,parseUnresolvedColor, and each produces a different wrong outcome.Measured by rendering each declaration through
@testing-library/react-nativeon the base commit (f70c402):processColorrgb(255 0 0 / var(--a, 0.5))rgba(65025, 0, 0, 0.5)0x80ff0000rgb(50% 25% 10% / var(--a, 0.5))rgba(32640, 16320, 6630, 0.5)0x80ffffff0x8080401ahsl(0 84.2% 60.2% / var(--a, 0.5))hsl(0, 84.19999694824219, 60.20000076293945, 0.5)undefinedhsl(calc(NaN) 100% 50% / var(--a, 0.5))hsl(Infinity, 100, 50, 0.5)undefinedhsl(1e20 100% 50% / var(--a, 0.5))hsl(9223369837831520000, 100, 50, 0.5)undefined(
processColormeasured against the real@react-native/normalize-colorsat react-native 0.81.4, the version this repo installs.)Row one is why row two survived: a saturated channel is indistinguishable from a channel that overflowed and clamped, so the defect is invisible on exactly the colours a probe reaches for first.
Root cause
1 — the rgb channels are scaled twice, and only one multiplication is in this repository
lightningcss converts to 0-255 before any JavaScript runs.
rgb(50% 25% 10% / var(--a))arrives as{ r: 128, g: 64, b: 26 }— fifty percent of 255 is 127.5, which rounds to 128.parseUnresolvedColorthen multiplied again (round(color.r * 255)and its two siblings).The proof that 0-255 is the field's range is in the same file and unchanged by this branch:
parseColorreads the same shaped channel and divides by 255, because colorjs.io wants 0-1. The unresolved path emits a CSSrgba()string, which takes 0-255 — so it needed no conversion in either direction.React Native then hides the arithmetic:
parse255clamps anything above 255, so the string is accepted and the colour is wrong rather than absent.2 — an unresolved
hsl()is emitted in a spelling React Native's parser rejectslightningcss resolves the hue, saturation and lightness to plain numbers with no unit attached, and the base code passed those through under
color.type— producing a four-argumenthsl(…)with no percent signs.@react-native/normalize-colorsbuildshslwith three capture groups andhslawith four; neither matches, control reachesreturn null, andprocessColorturns that intoundefined.The arity requirements of
hslandhslaare inverted, and that is what decides the fix. Measured:processColorhsl(0, 84.19999694824219, 60.20000076293945, 0.5)— what the base emitsundefinedhsla(0, 84.2%, 60.2%, 0.5)— the working hsl-family spelling0x80ef4444hsla(0, 84.2%, 60.2%)— the same, alphavar()resolved to nothingundefined—hslarequires fourrgba(239, 68, 68, 0.5)0x80ef4444rgba(239, 68, 68)— alphavar()resolved to nothing0xffef4444— opaque, and still rendersAn
UnresolvedColorguarantees the alpha is avar(), and avar()that resolves to nothing is dropped from the argument list at runtime (src/native/styles/resolve.tsfiltersundefinedout of the array before joining). So no hsl-family spelling survives both states:hslais rejected three-argument,hslis rejected four-argument.rgbasurvives both, which is why the fix converts the spelling rather than correcting it.3 — a non-finite hue arrives as
InfinityThe compiler runs lightningcss twice, the second pass over the first's serialized output, and that round trip is the mechanism: a single pass over
hsl(calc(NaN) …)givesh: NaN, pass one serializes it past the float range, and pass two reparses it asInfinity. SoNumber.isNaNis the wrong test andNumber.isFiniteis the right one — for this input.That the hue is the only channel needing a guard is measured rather than assumed. lightningcss clamps every other channel before it arrives, so the same
calc(NaN)in any other position is harmless — rendered on the base commit:hsl(calc(NaN) 100% 50% / var(--a, 0.5))hsl(Infinity, 100, 50, 0.5)— the hue surviveshsl(0 calc(NaN * 1%) 50% / var(--a, 0.5))hsl(0, 100, 50, 0.5)— saturation clampedhsl(0 100% calc(NaN * 1%) / var(--a, 0.5))hsl(0, 100, 100, 0.5)— lightness clampedrgb(calc(NaN) 0 0 / var(--a, 0.5))rgba(65025, 0, 0, 0.5)— channel clamped to 255, then scaled by defect 14 —
Infinityis not the only way to arrive without a hueA finiteness test catches only the input that overflows. The same round trip saturates other inputs, and those arrive finite. Measured at the point
parseUnresolvedColorreads them:color.hhsl(calc(NaN) …)Infinityhsl(calc(infinity) …)9223372036854776000hsl(1e20 …)9223369837831520000hsl(1e38 …)9223369837831520000— the same valuehsl(4294967296 …)4294969856hsl(1e7 …)10000000— carried exactly1e20and1e38landing on one number is the whole finding: past the ceiling the declaration's hue is not carried at all, so reducing what arrives modulo 360 reports the serializer rather than the stylesheet.Both passes saturate, and they saturate different inputs. A visitor is what materialises the AST into JavaScript and back, and the hue saturates to i64 on that round trip. Measured by toggling each visitor independently:
Rulevisitor1e20922337000000000000092233698378315200001e3892233700000000000009223369837831520000calc(infinity)3.40282e389223372036854776000Removing pass one's declaration visitor leaves
1e20serialized verbatim as100000000000000000000; removing pass two's rule visitor leavescalc(infinity)arriving unsaturated at3.402820018375656e+38. So the headlinecalc(infinity)case is pass two's, and the1e20/1e38collision is pass one's.That six-significant-digit serialization of the saturated value is also the collision mechanism: pass one saturates to i64 max, writes it back as
9223370000000000000, and pass two reparses that single string as an f32. colorjs.io does exactly that and hands back a plausible colour —rgba(255, 34, 0, …)forcalc(infinity),rgba(255, 0, 102, …)for the other two.The guard's subject was wrong, not its threshold. A hue reaches this function as a 32-bit float, and past a point that float stops naming an angle at all — not merely naming one imprecisely. A float32 holds a 24-bit significand, so its ULP at
2 ** exponentis2 ** (exponent - 23)and first covers a whole turn at2 ** 32; past there every representable neighbour lands on a different angle.Infinityis the same condition at the top of the range.The distinction matters because imprecision starts much earlier and is a different defect, with a different cause and no fix in this function — see KNOWN LIMITS.
Fix
Five commits, one source file, in dependency order.
7d69ff1— stop scaling. The threeround(color.* * 255)expressions become the channels themselves, with a comment naming lightningcss's own conversion.e160a8c— emitrgbafor hsl. Thehslarm routes through colorjs.io and atoRgbChannelhelper converts 0-1 sRGB back to 0-255, so both spellings of one colour compile to one descriptor.69e1ff9— guard a non-finite hue.Number.isFinite(color.h) ? color.h : 0.287cf8e— guard every hue the float grid cannot name. That expression becomes a namedtoHueDegrees, testingNumber.isFinite(hue) && Math.abs(hue) < 2 ** 32.8c5c581— correct what the comments assert, and close two test gaps. The only runtime change is deleting a dead clause:Number.isFinite(hue) &&cannot alter a result, becauseMath.abs(NaN) < xandMath.abs(±Infinity) < xare bothfalse. Removing it leaves all 55 cases in the two files green. The guard is nowMath.abs(hue) < 2 ** 32.Zero is the value CSS Color 4 gives a missing component. It is also, measured, the hue lightningcss's own resolved path lands on for
2 ** 32,1e10,1e15,1e20,1e30and1e38— all six resolve tored— so the guard makes the two spellings agree rather than picking a house answer. A hue that is large but still nameable is reduced exactly as before:hsl(-600 …)compiles to0, 255, 0andhsl(1e7 …)to170, 0, 255, both matching their resolved twin, andhsl(3e9 …)to0, 255, 0.Of the six hues above, three are rows in the parity list —
2 ** 32,1e20and1e38— and those three are what the guard flips from disagreeing to agreeing.1e10,1e15and1e30resolve red too but are not rows.The four are independent defects in one function sharing one parity test, so they can be taken separately — though 3 and 4 are meaningless without 2, and 4 subsumes 3.
Which plane
Compiler (
src/compiler/declarations.ts, alone), reaching only the native runtime. Web needs no mirror: the CSS is served to the browser, which resolvesrgb()/hsl()with avar()alpha itself.parseUnresolvedColorhas no counterpart undersrc/web— there is nohsl, noUnresolvedColor, and no colour parsing in those four files.Tests
36 new, in two existing files. Running both files against the base commit: 34 fail, 21 pass of 55. The two files held 19 cases before this branch, all 19 still pass on the base, so the 21 is those 19 plus two of the additions — and 34 of the 36 additions are red on the base.
(The 34/21 split coincides exactly with the two files' own totals,
colors.test.tsxholding 34 cases anddeclarations.test.tsx21. That reads like a pass/fail split transcribed from per-file counts and is not one; both figures were re-measured for this revision.)src/__tests__/compiler/declarations.test.tsx— 11 rows in the existing table, asserting the emitted descriptor.src/__tests__/native/colors.test.tsx,describe("unresolved alpha")— rgb with number channels, rgb with percentage channels, the hsl conversion, atest.eachover six hues the float grid cannot name, atest.eachover three large hues that must still be reduced rather than clamped, each chosen to land on a colour the clamp does not also produce, and alight-dark()carrying an unresolved alpha into both schemes, which is what proves the recursive call carries both new shapes.src/__tests__/native/colors.test.tsx,describe("unresolved alpha matches the resolved spelling")— atest.eachover eleven colours renders each twice, once resolved and once with/ var(--a, 1))spliced in, and assertsprocessColorreturns the same number for both.The two additions that pass on the base are the finding, not a gap: they are the parity rows
rgb(255 0 0)andrgb(100% 0% 0%), the two whose channels were already saturated, so React Native's clamp lands them on the right colour anyway. They are the reason this shipped.That parity test first asserts
typeof expected === "number", so the comparison cannot pass vacuously by both sides beingundefined— which is exactly what a rejected colour produces.The guard is pinned in both directions, and the constant itself is bracketed. Reverting
toHueDegreesto the finiteness test alone turns 12 of the 55 cases in these two files red, three of them parity rows (hsl(4294967296 …),hsl(1e20 …),hsl(1e38 …)). Widening it —2 ** 32replaced with360— turns 6 red instead, all large-nameable-hue rows.The constant is bracketed by rewriting it and re-running both files. The green window is
[3000000001, 4294969856]— a factor of 1.43, with2 ** 31,2 ** 33,1e10and10000001all red. The upper edge is2 ** 32's own arriving value, 2560 above the constant; the lower edge is the3e9row, which sits between2 ** 31and2 ** 32where the float32 ULP is 256 and so still finer than a turn, and which arrives exactly because one significant digit survives any serializer.That lower edge is new in
8c5c581. Before it the window was[10000001, 4294969856], a 429-fold interval that2 ** 31sat inside, because the widest nameable row was1e7. The row replaced to close it washsl(720 …), which could not fail: 720 reduces to 0, the same answer the clamp gives, so it survived a mutation setting the threshold to 1 while both its siblings went red.Full suite, typecheck and lint measured on the same machine, same worktree layout. Base
f70c402is 1048 passed / 3 failed / 1072 total; this branch is 1084 passed / 3 failed / 1108 total, unchanged by8c5c581. The 3 failures are the twosrc/__tests__/babel/*suites, which fail identically at every ref on Windows — the suite line is2 failed, 4 skipped, 53 passed, 55 of 59 totalat both refs.yarn typecheckandyarn lintexit 0 on both.KNOWN LIMITS
Unnameable hues are now deterministic, and a family of them still disagrees with its resolved twin. They compile to the same colour as every other unnameable hue,
rgba(255, 0, 0, …), while lightningcss folds a resolved twin such ashsl(calc(infinity) 100% 50%)to#000.calc(infinity)is not alone in this and is not a special case. Sampling f32 hues above2 ** 32, 100 of 672 — about one in seven — resolve to something other than red, and not always black:6.135668e9resolves#0f8. Named members include5e10,1e11,1e12,1e18,1.41e38,1.44e38,3.4e38andcalc(-infinity).That
#000is not a target worth matching, and the reason is stronger than lightningcss being erratic — it is deterministic, and it is still unmatchable here, because the information that decides it never reaches this compiler.Seventeen authored hues —
1e19,2e19,5e19,1e20,1e25,1e30,1e35,1e38,1.40e38,1.41e38,1.42e38,1.44e38,1.46e38,2e38,3e38,3.4e38,9223372036854775807— all arrive atparseUnresolvedColoras the single value9223369837831520000. lightningcss's own resolved path splits that one arriving value twelve red to five black. No function of the hue this compiler receives can separate seventeen inputs it receives as one number, so matching the resolved path is not a harder problem here; it is an impossible one, and it stays impossible however lightningcss behaves.(An earlier revision of this section argued the resolved answer "is not a function of the hue". That is refutable in one command, and it is worth recording the refutation rather than quietly dropping it:
h - f32(f32(h / 360) * 360) !== 0predicts#000, else red, and it fits 576 of 576 sampled f32 hues above2 ** 32. lightningcss is fully deterministic here. The refutation does not rescue the residual, because that predicate is a function of the authored hue — which is exactly the value the compiler never receives.)So the branch emits the answer that IS a function of the arriving hue, and
src/__tests__/native/colors.test.tsxpins the divergence with a test that goes red if lightningcss ever stabilises — which is when the row belongs in the parity list instead. Compared with the base commit this is a strict improvement: the base paints nothing at all for these hues and disagrees just as much. Measured, the guard turns 12 assertions across the two files from red to green.A hue below
2 ** 32can still be carried imprecisely, and this branch does not change that. The threshold is about where a hue stops naming an angle, not about where it stops being exact.The binding wall below the threshold is lightningcss's six-significant-digit serializer, not the float32 ULP grid, and it bites from about
1e6— three decades below2 ** 32. Measured:1234567arrives as1234570,12345678as12345700,123456789as123457000, and2147483648serializes to2147480000and arrives as2147480064.Sampling forty full-precision hues per decade, the arriving hue differs from the authored one in 0/40 below
1e6(an integer under1e6has six digits and survives), then 38/40 in[1e6, 1e7)and 40/40 in every decade above. Parity is already broken across that range on the base commit and after —hsl(12345678 …)gives(0, 85, 255)against the resolved(0, 178, 255),hsl(1e9 …)(170, 0, 255)against(68, 0, 255),hsl(3e9 …)(0, 255, 0)against(255, 0, 0).The information is gone before this function runs; closing it means a serializer change upstream. A second, smaller divergence lives beside it and is also untouched: lightningcss reduces the hue in f32 while colorjs.io reduces in f64, so even a losslessly carried hue can differ by a unit —
hsl(198 …)gives(0, 179, 255)against the resolved(0, 178, 255).Tailwind v4's opacity modifier never reaches this path. v4 emits
color-mix(in oklab, var(--c) 10%, transparent), which routes throughparseColorMix— measured,color-mix(in oklab, red 10%, transparent)compiles to#ff00001aon both refs. What this fix does make correct is v3-shaped output (background-color: rgb(239 68 68 / var(--tw-bg-opacity))), which on the base clamps to white.nonechannels and every colour space outside rgb/hsl are still broken, and they never reach this function.UnresolvedColorhas exactly three members, which is whyparseUnresolvedColorcloses oncolor satisfies never. Measured on both refs:oklch(0.7 0.1 30 / var(--a, 0.5))hwb(120 30% 40% / var(--a, 0.5))rgb(none 0 0 / var(--a, 0.5))rgb(none, 0, 0, 0.5)— rejected, property unsethsl(none 100% 50% / var(--a, 0.5))hsl(none, 100%, 50%, 0.5)— rejected, property unsetThe last two are a live sibling of defect 2: they route through
unparsedFunction, which still emits the four-argumenthsl(...)spelling with the same rejection shape and none of the fix. It is one function away; I left it out to keep this diff to the one function, and would rather do it as its own change than widen this one.Related. #351 — which is this branch's base commit — fixed the same class of defect in the same file: an invalid colour string React Native discards to
null, leaving the property unset. #317 is the report behind it.Overlaps with open PRs. Measured with
git merge-treeagainst every open PR head:color: inheritto the inherited-color variable #391 (fix/color-inherit) — hard conflict insrc/__tests__/native/colors.test.tsx.src/compiler/declarations.tsauto-merges between the two.feat/cursor-property) — hard conflict insrc/__tests__/compiler/declarations.test.tsx; both append rows to the same table.src/compiler/declarations.tsauto-merges.src/compiler/declarations.tsand auto-merge clean today.Whichever of #391 / #346 lands second needs a rebase, but only in the test files.