Skip to content

fix(native): read a runtime shadow the way the compiler does - #489

Open
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/runtime-shadow-grammar
Open

YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/runtime-shadow-grammar

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

On native, a box-shadow, text-shadow or drop-shadow() whose value is only known at runtime - a variable defined more than once, one with only an @property initial value, or any variable under inlineVariables: false - is matched against a fixed list of token orders. CSS lets the colour and inset sit on either side of the lengths, so most spellings miss the list:

Held in a variable Compiled At runtime
2px 2px a shadow in currentcolor []
#123456 2px 2px 4px a shadow []
inset 2px 2px an inset shadow color: "inset", which React Native rejects, dropping every shadow in the list
2px 2px #123456 inset an inset shadow []
2px 2px #123456, 4px 4px two shadows the first only
2px 2px 4px 1px a shadow in currentcolor no colour, so React Native's black
text-shadow: 2px 2px 4px a shadow nothing
drop-shadow(2px 2px) a shadow [{}], which processFilter throws on

The runtime shadow also leaves out an omitted blur and spread where the compiler writes 0, an omitted text-shadow colour is black where the compiler writes currentcolor, and a lone length (--shadow: 2px) reaches React Native as boxShadow: 2, which processBoxShadow throws on. Tailwind's text-shadow-md text-shadow-red-500 draws no text shadow on native, because the colour utility moves the list to runtime.

Solution

The three properties read their tokens through one reader, shorthands/_shadow.ts: the lengths adjacent, a colour and inset on either side of them, in the counts lightningcss accepts for each property - four lengths and inset for box-shadow, four lengths for text-shadow, three for drop-shadow(). An omitted blur and spread are 0 and an omitted colour is currentcolor, so the runtime shadow is the object the compiler writes. Every shadow of a list is read, a variable that resolved to part of a shadow is spliced into it, and one invalid shadow makes the whole value invalid, which sets nothing: browsers ignore a written invalid shadow, and a variable holding one leaves the declaration invalid at computed-value time, which the reader cannot tell apart. text-shadow keeps the first shadow, as the compiler does. Nothing uses shorthandHandler's "object" return any more, so it goes.

Tests

src/__tests__/native/shadow-grammar.test.tsx runs every order of colour, lengths and inset, a list, a list held in another variable, a variable holding part of a shadow, and the invalid spellings - held in a variable for box-shadow and drop-shadow(), which leave no shadow, and written for text-shadow, which leaves the earlier rule's - each with variables inlined and resolved at runtime, and with the sheet injected as compiled and as the JSON a device receives. Every expected value is what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same CSS. On main 122 of the 269 fail. The four runtime cases in box-shadow.test.tsx that pinned the old shape, the "known limitation" among them, now expect the compiler's, and effects.test.tsx adds Tailwind's inset-shadow-sm and text-shadow-md with a text-shadow colour, both failing on main.

Verification

On Ubuntu with the workflow's steps - build, lint, typecheck, yarn test, node --test node/__tests__/*.mjs, both library builds, the web export, the unstaged-changes checks and commitlint - everything passes merged with #500. Without it, yarn test fails a random Tailwind case in a full run, the harness flake #500 removes.

Device evidence, before and after: #489 (comment)

Not run: the iOS and Android example builds.

Unchanged: a negative blur, which lightningcss accepts, still passes through on both paths, and text-shadow: none still sets nothing. A shadow split across two variables that each hold lengths (var(--offsets) var(--blur-spread)) still reads as two shadows, because the compiler stores it as the same list as var(--a), var(--b).

Also unchanged, outside the reader: a variable holding what is not a text shadow leaves an earlier rule's text shadow in place, where browsers make the declaration unset (css-variables-1 §3.1) - #391 does that for every property. And a written invalid box-shadow or drop-shadow() clears an earlier rule's value where browsers ignore the declaration, because the runtime holds the key with a placeholder until the value resolves and drops it when the value is invalid, and a filter list leaves out an entry its function rejects.

Merge order

It shares lines with #391 (native/styles/functions/filters.ts), #392 and #413 (native/styles/shorthands/box-shadow.ts); whichever lands second rebases. #413 moves the transparent-colour check into utilities/transparent-color.ts, which this file then uses, and #391 resets a cleared shorthand inside shorthandHandler, which text-shadow no longer goes through, so whichever of the two lands second carries that reset into text-shadow.ts.

A box-shadow, text-shadow or drop-shadow() whose value is only known at
runtime was matched against a fixed list of token orders. A spelling the
list missed was dropped, `inset` without a colour was taken for the
colour, and an omitted blur, spread or colour was left out where the
compiler writes 0 and currentcolor. A colourless drop-shadow() became an
empty filter entry, which React Native's processFilter throws on, and a
lone length reached React Native as a number, which processBoxShadow
throws on.

All three now read their tokens through one reader: the lengths adjacent,
a colour and `inset` on either side, in the counts lightningcss accepts
for each property, with every shadow of a list read and an invalid one
rejecting the whole value.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — Only the control and the first shadow of the list paint: every other box casts nothing, and neither text shadow is drawn.

FIXED — Every box casts its shadow — the ones with no colour in the text colour, the inset ones inside the border — the list paints both shadows, and both texts carry a shadow, the list its first.

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, each composed from scrolled slices because the scene is taller than the screen. Every shadow in the scene is held in a variable the stylesheet declares twice, so it resolves at runtime. 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. A colourless drop-shadow() is not in the scene: on main it becomes an empty filter entry, which React Native's processFilter throws on.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 11:47
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 16, 2026 21:13
…r drop shadow does

A written value that is not a shadow is invalid, and browsers ignore the
declaration. A variable holding one leaves the declaration invalid at
computed-value time, which browsers make unset (css-variables-1 §3.1).
The reader can tell neither from the other, so every shadow it rejects
sets nothing, and each test row asserts what browsers compute.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 23:02

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