Skip to content

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

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/runtime-shadow-grammar
Open

YevheniiKotyrlo wants to merge 1 commit 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, and so does its branch that let an unresolved style function fill any slot: the handler resolves its tokens before it matches them, so no input reached it, and the shadows its comment named no longer use the handler.

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.

effects.test.tsx runs Tailwind's box and text shadow cases with the --tw-* variables inlined and resolved at runtime, through an inlineVariables option the test harness passes to the compiler. On main, inset-shadow-sm and text-shadow-md with a text-shadow colour fail either way, and text-shadow-md fails with its variables resolved at runtime. border.test.tsx resolves border, the one shorthandHandler user left on its "shorthandObject" return, at runtime.

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, on the head and merged into main with #477, without which a Tailwind case can fail at random. The iOS dev build passes on Xcode 27.1 with the example's expo at 57.0.31; ci.yml builds with Xcode 26.6, and on 27.1 main's expo 57.0.22 fails in its prebuilt ExpoModulesJSI.

Device evidence, before and after, on Android and an iPhone: #489 (comment)

Reading a runtime shadow costs no more than on main: over four spellings both read, the head's median time per resolution is lower for each, within main's run-to-run spread.

Unchanged: a negative blur, which lightningcss accepts, still passes through on both paths, and text-shadow: none still sets nothing, which #495 fixes. 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. A written invalid box-shadow or drop-shadow() clears an earlier rule's value where browsers ignore the declaration, and a filter list leaves out an entry its function rejects; #501 keeps the earlier value in both.

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.

Two others change what this PR's rows expect, and whichever lands second moves them. #392 seeds currentcolor with a concrete colour, where shadow-grammar.test.tsx's currentColor and two box-shadow.test.tsx cases expect main's PlatformColor. #501, on #391 and #474, leaves filter unset for a variable holding an invalid drop shadow, where the rows expect [], and with variables inlined keeps an earlier shadow for a variable holding an invalid one, since the inliner hands it over as a written value, until #503 keeps it a substitution.

#495 is stacked on this.

@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 16, 2026 •

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.

iOS — before / after

before — main after — with this PR

iPhone 12 Pro Max, iOS 27.0.1, the library's example app built Release with Xcode 27.1 against main (2421ea77) and against this PR merged into it, both frames from one session. Both sides carry expo 57.0.31 and the UIKit scene life cycle, which the example needs to build and launch with Xcode 27.1, and the same shadows as the scene above, each held in a variable declared more than once. A shadow with no colour takes the label colour, black in light appearance. The control row matches to within 1 per channel.

@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
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 23:02
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 23, 2026 17:47
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/runtime-shadow-grammar branch from 68dbe5b to 0eb4e34 Compare October 23, 2026 17:52
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. One invalid shadow
makes the whole value invalid, which sets nothing: browsers ignore a
written one, and a variable holding one leaves the declaration invalid
at computed-value time (css-variables-1 §3.1).

shorthandHandler loses its "object" return, which nothing uses any more,
and its branch that let an unresolved style function fill any slot: the
handler resolves its tokens before it matches them, so no input reached
it. The Tailwind shadow tests run with the --tw-* variables inlined and
resolved at runtime, and a border test resolves the handler's remaining
"shorthandObject" return.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/runtime-shadow-grammar branch from 0eb4e34 to c48fef2 Compare October 23, 2026 18:01
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 23, 2026 18:48

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