Skip to content

fix: resolve single box-shadow from runtime CSS variables#295

Open
YevheniiKotyrlo wants to merge 1 commit intonativewind:mainfrom
YevheniiKotyrlo:fix/runtime-box-shadow-variable-resolution
Open

fix: resolve single box-shadow from runtime CSS variables#295
YevheniiKotyrlo wants to merge 1 commit intonativewind:mainfrom
YevheniiKotyrlo:fix/runtime-box-shadow-variable-resolution

Conversation

@YevheniiKotyrlo
Copy link
Contributor

@YevheniiKotyrlo YevheniiKotyrlo commented Mar 9, 2026

Summary

Fix box-shadow shorthand resolution when CSS variables are resolved at runtime (not inlined at compile time). Currently, a single shadow from a runtime variable produces boxShadow: [] instead of the expected shadow object.

Problem

When a CSS variable holding a single box-shadow value is resolved at runtime, the shorthand handler receives a flat array of tokens like [0, 4, 6, -1, "#000"]. The handler incorrectly iterates each primitive individually — passing 0, 4, 6, etc. to the pattern matcher one at a time. No single primitive matches any shadow pattern, so all return undefined and get filtered out.

The runtime path is triggered when variables are:

  • Defined multiple times (e.g., :root + .dark for theme switching)
  • Only from @property (variable has an @property initial value but no class-level definition)
  • Not inlined (inlineVariables: false)

The compile-time path (single-definition variables that get inlined) is not affected — lightningcss parses the full shadow value correctly.

Reproduction

:root { --themed-shadow: 0 4px 6px -1px #000; }
.dark { --themed-shadow: 0 4px 6px -1px #fff; }
.test { box-shadow: var(--themed-shadow); }

Expected: boxShadow: [{ offsetX: 0, offsetY: 4, blurRadius: 6, spreadDistance: -1, color: "#000" }]
Actual: boxShadow: []

Solution

Detect whether the resolved args array is flat (single shadow) or nested (multiple shadows) before entering the multi-shadow handler:

  • Flat [0, 4, 6, -1, "#000"] → first element is a primitive → pass the entire array to the pattern handler as a single shadow
  • Nested [[0, 4, 6, -1, "#000"], [0, 1, 2, 0, "#333"]] → first element is an array → use existing flatMap logic for multiple shadows

The existing multi-shadow path (nested arrays from comma-separated box-shadow values) is unchanged. The flat array path also applies omitTransparentShadows and normalizeInsetValue for correct handling.

Why [[0, 4, 6, -1, "#000"]] (nested length-1) cannot happen

reduceParseUnparsed in declarations.ts (line 1075) always unwraps single groups:

return groups.length === 1 ? groups[0] : groups;

A single shadow always produces a flat array. Multiple shadows produce a nested array. There is no path through the compiler or runtime variable resolver that wraps a single shadow in an extra array layer.

Verification

  • yarn typecheck — pass
  • yarn lint — pass
  • yarn build — pass (ESM + CJS + DTS)
  • yarn test — 1014 passed, 3 failed (pre-existing babel plugin tests on main), 21 skipped (pre-existing)

Test architecture (38 tests: 34 box-shadow + 4 text-shadow)

Tests are structured with compile/runtime parity — each scenario is tested through both the compile-time and runtime variable resolution paths to prove they produce identical results.

Static CSS (10 tests) — single shadow (basic, color-first, negative offsets, no spread, no color), inset shadow (basic, color-first, no color, no spread), mixed inset + outset

Compile-time CSS variables (6 tests) — single nested var, deep nested vars, theme switching (multi-definition), inset via var, inset no-spread via var, transparent via var

Runtime variables (15 tests) — same scenarios as above with { inlineVariables: false }, plus: multi-shadow from separate vars, multi-shadow from single var, multi-shadow with transparent filtering

@property defaults (4 tests) — transparent defaults filtered, currentcolor platform object, Tailwind ring pattern (3 vars, 2 transparent) via both compile-time and runtime

Text-shadow regression (4 tests) — compile-time and runtime parity, proving text-shadow is unaffected (different resolver architecture — no flatMap)

Related

@danstepanov
Copy link
Member

@YevheniiKotyrlo Also please rebase this branch

@YevheniiKotyrlo YevheniiKotyrlo force-pushed the fix/runtime-box-shadow-variable-resolution branch from ffd33dd to c7e3eb3 Compare March 12, 2026 19:20
@YevheniiKotyrlo
Copy link
Contributor Author

Rebased on latest main (b0b35b1, includes merged #277). Single clean commit.

The fix now also applies normalizeInsetValue wrapping on the flat-array path (from the merged #277 inset shadow support), so runtime variable shadows with inset are handled correctly.

All quality gates pass (typecheck, lint, build with no unstaged files, test — 980 passed, 3 pre-existing babel failures same on main).

Copy link
Member

@danstepanov danstepanov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The flat vs nested detection makes sense, and the tests cover the important cases (single, multi, transparent filtering, theme switching).

The !Array.isArray(args[0]) check assumes runtime variables always produce flat arrays for single shadows. Is there a case where a single shadow could come through as [[0, 4, 6, -1, "#000"]] (nested but length 1)? If so it'd hit the old flatMap path, which should still work but worth a quick sanity check. If you've verified that, we're good.

Also the branch needs a rebase.

@YevheniiKotyrlo YevheniiKotyrlo force-pushed the fix/runtime-box-shadow-variable-resolution branch from c7e3eb3 to 0b172b3 Compare March 13, 2026 12:52
@YevheniiKotyrlo
Copy link
Contributor Author

Good question. I traced through the compiler and runtime to verify — [[0, 4, 6, -1, "#000"]] (nested but length 1) cannot happen.

reduceParseUnparsed in declarations.ts line 1075 always unwraps single groups:

return groups.length === 1 ? groups[0] : groups;

So a single shadow always resolves to a flat array [0, 4, 6, -1, "#000"]. Multiple shadows produce [[...], [...]]. There's no path through the compiler or varResolver that wraps a single shadow in an extra array layer.

I've also expanded the test suite significantly — 34 box-shadow tests + 4 text-shadow tests now, structured as compile-time / runtime parity to prove both paths produce identical results. This covers single shadows, multi-shadow, inset, color-first, negative offsets, transparent filtering, @property defaults, and the Tailwind ring pattern (3 vars, 2 transparent).

Force pushing the rebased branch now (based on latest main).

@danstepanov
Copy link
Member

Tests are way more thorough now, nice work on the reorganization.

Two things:

  1. The "inset shadow - without color" test is asserting color: "inset" as expected behavior. I get that it's a pre-existing bug in the runtime path and this PR isn't trying to fix it, but encoding broken behavior into a test assertion makes it look intentional. Can you mark it with something like // TODO: known bug or use a separate test block that's clearly labeled as documenting a limitation? Otherwise a future contributor will see this and think color: "inset" is correct.

  2. Transparent shadow handling is inconsistent between the two paths:

    • Compile-time (inlined vars): 0 0 0 0 #0000 is preserved (your "transparent shadow is preserved" test)
    • Runtime (inlineVariables: false): 0 0 0 0 #0000 is filtered out (your "transparent shadow is filtered" test)

    Is this intentional? If so, a comment explaining why the behavior differs would help. If not, one of them is wrong.

@YevheniiKotyrlo
Copy link
Contributor Author

Thanks for the thorough review.

1. color: "inset" test — Agreed, encoding broken behavior as a passing assertion is misleading even with the comment. I'll add a // TODO: Known bug — prefix and rename the test to make the limitation obvious (e.g., "inset shadow - without color has broken color parsing (known limitation)").

I verified both paths to confirm the discrepancy:

  • Compile-time: inset 0 0 10px 5px{ inset: true, color: { semantic: ["label","labelColor"] } } — CSS parser correctly identifies inset keyword and inherits platform default color
  • Runtime: same value via var(){ color: "inset" } — the string "inset" ends up in the color slot because the runtime token handler doesn't distinguish keywords from color values

2. Transparent shadow filtering — The inconsistency is intentional but I should have documented it better.

I ran both paths to confirm:

  • Compile-time (:root var inlined by lightningcss): 0 0 0 0 #0000[{ color: "#0000", offsetX: 0, ... }] — preserved as shadow object
  • Runtime (inlineVariables: false): same value → [] — filtered out

The difference comes from architecture:

  • Compile-time: lightningcss processes the CSS and produces values. The shorthand handler just converts to native format — no filtering, because the CSS compiler already did its work. The "transparent shadow is preserved" test documents this existing behavior (not changed by this PR).

  • Runtime: omitTransparentShadows is an optimization I added because Tailwind v4 generates @property --tw-inset-shadow { initial-value: 0 0 0 0 #0000 } as placeholder shadows. When these resolve at runtime, they'd produce invisible native shadow objects that the renderer still has to process. Filtering them avoids that overhead.

If you'd prefer strict parity between the two paths, I can remove omitTransparentShadows entirely — transparent shadows would pass through on both paths (zero visual effect either way, just slightly more work for the renderer). Otherwise I'll add comments explaining why the runtime path filters and the compile-time path doesn't.

Let me know which you prefer and I'll update.

@YevheniiKotyrlo YevheniiKotyrlo force-pushed the fix/runtime-box-shadow-variable-resolution branch from 0b172b3 to 4f2b971 Compare March 15, 2026 11:30
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.

2 participants