Skip to content

fix: ignore a filter list with a function it cannot take, as written or from a var() - #501

Open
YevheniiKotyrlo wants to merge 29 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/filter-list-validity
Open

YevheniiKotyrlo wants to merge 29 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/filter-list-validity

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Browsers ignore a filter declaration with a function its grammar rejects, so an earlier rule's filter stands, and a var() holding one leaves no filter. React Native's processFilter drops the whole list when one entry is invalid. With #391 and #474, which this builds on:

  1. A function the property cannot take is dropped alone, or reaches React Native. blur(2px) drop-shadow(2px) and blur(2px 3px) draw blur(2px). blur(foo) brightness(2), brightness(-1), blur(-1px) and hue-rotate(90) reach processFilter, which then draws no filter at all.
  2. A keyword among other filters resets the property. none blur(2px) clears an earlier rule's filter.
  3. A drop-shadow() its handler cannot read is kept. drop-shadow(1px 2px 3px 4px red) renders [{}] as written and [{ dropShadow: undefined }] through a variable.
  4. A gradian or a turn never reaches React Native as an angle it reads. The compiler drops the angle with a warning and keeps the entry, so the stylesheet a device loads holds filter: hue-rotate(1turn) as an empty entry, [{}], which processFilter throws on, and transform: rotate(100grad) as { rotate: null }, which React Native's transform validation rejects in development. 1turn supplied to hue-rotate() at render reaches React Native as written.

Solution

  • A list is invalid when any entry fails to resolve. A written declaration whose list the runtime cannot read is then ignored, and a var() that leaves one invalid makes it unset.
  • Each filter function reads its one argument by the Filter Effects 1 grammar: blur() a non-negative length, hue-rotate() an angle or unitless zero, and every other function a non-negative number or percentage. Several arguments, a keyword among other filters, or a drop-shadow() its handler cannot read leave the list invalid.
  • The compiler and the runtime read each argument through the same functions in src/utilities (readNonNegativeLength, readNonNegativeAmount, readAngle), so a written filter and one a var() substitutes take the same decision.
  • readAngle gives a gradian or a turn in degrees, since React Native takes degrees or radians alone, and the compiler reads every angle through it.
  • A url() entry is still left out with the rest applied, since React Native has no SVG filter to reference.

Tests

  • src/__tests__/native/filter-list-runtime.test.tsx: each rejected function as written over an earlier rule's filter and through a variable, a keyword among filters, a rejected written filter after a variable one, and a gradian or a turn supplied at render and held in a stylesheet variable. Each runs on the inlined and the kept route.
  • src/__tests__/compiler/compiler.test.tsx: a negative argument or a rejected function inside a list emits no declaration, a url() entry is left out, and an angle in gradians or turns compiles to degrees without a warning.
  • src/__tests__/native/transform.test.tsx: rotate(), rotate: and skewX() in gradians or turns.
  • src/__tests__/utilities/angle.test.ts and filter-arguments.test.ts: each reader's accepted and rejected arguments.
  • With fix: resolve CSS-wide keywords and the inherited colour as CSS defines them #391 and fix(native): resolve declarations in cascade order once an element's variables are set #474 below it, 52 of the 215 cases in the first three files fail, and the readers' unit tests arrive with the readers. Each of 23 mutations fails its own cases.
  • Every expectation is what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same CSS.

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, alone and with #500 merged.

Device evidence, before and after, the written turn's crash among it: #501 (comment)

Known limits

  • A gradian or a turn supplied at render to rotate() or skew() reaches React Native as written, since the runtime's transform functions pass their argument unread.

Merge order

This builds on #391 and #474. With both, a declaration a var() leaves invalid resolves to unset and one the runtime cannot read is ignored, so an earlier rule's filter stands. Two of the commits here are that combination, and they go once both land: #474's cascade order on top of #391, and the reset it needs, where a CSS-wide keyword or a var() without a value removes the property whatever its parser answers.

It shares lines with other open pull requests only through #391's commits, which #391 names, and through deferred-declarations.test.tsx, which #393 carries from #474.

Base

Branched off #391's head, on main (a5002c5).

`color: inherit`, `unset` and `currentcolor` compile to a read of the
colour every `color` rule publishes, and a colour that reads that
channel is not published back into it. While an element computes its
own `color`, the read resolves in the scope it inherited, because
css-color-4 makes `currentcolor` the inherited colour there; on every
other property it stays the element's own colour. nativewind#451 ordered the
element's own variables before inherited ones, so the inherited read is
now explicit rather than a consequence of lookup order.
The compiler and the resolver each asked whether a var() reads the
inherited colour, under one name with two meanings. The shallow question
is now `isInheritedColorLookup` beside the variable it names, and the
compiler's walk calls it for each var() it meets. Each constraint is stated
in one line, citing the sections that state it.
No caller passes parseUnparsed a string or a number: with either removed from the parameter
type, tsc reports no call site, and the test suite never takes either branch. The string
branch's `currentcolor` arm was a third spelling of the inherited-colour lookup that nothing
reached.
Each expected value is what Chromium, Firefox and WebKit compute for the same CSS. The cases
that pinned a divergence are gone: a light-dark() descendant in dark mode, a custom property
holding `inherit` that is not inlined, and the string relative colour syntax resolves to.
`::selection` is left to the pseudo-element mapping, and `::placeholder` also reads the
element's own colour.
A CSS-wide keyword is a value only on its own (css-values-4 §2.1), so the compiler resolves
one where it is a declaration's whole value or a var() fallback's, and rejects one inside a
longer value. `revert` and `revert-layer` roll back to `unset`, as React Native has no
user-agent or user origin. `unset` is `inherit` on `color` and on a custom property and
`initial` elsewhere, and `initial` clears the property to React Native's default. A custom
property left to inherit publishes nothing, so a read finds its ancestor's value, and its
warnings now name it.

A shorthand cleared by a keyword resets every longhand it sets, and a cleared filter is none.

An element's inherited colour comes from its last rule that publishes one, so a later `color`
that inherits no longer leaves an earlier rule's colour for its subtree, and a pseudo-element's
colour stays its own.
The first pass prints the stylesheet before the second parses it, so no white space from a
comment reaches the keyword, and the filter for it was unreachable.
A shorthand parsed at runtime was registered with an empty argument when its value failed to
parse, which cleared an earlier rule's value; browsers ignore an invalid declaration, so it is
left out. The CSS-wide keyword cases join the existing keyword suite.
… none

A filter or transform list that a var() fills with `none`, a cleared value or `initial` is
`none` alone or invalid at computed-value time, and either way the property is `none`; the
resolvers returned the keyword inside the list.
…rive cleared

Only a shorthand that returns its longhands as an object receives a cleared argument: animation
wraps its argument in a list, and box-shadow and drop-shadow handle it before the handler.
…he declaration

A keyword is a value only on its own, so one inside a function, a colour or a list makes the
whole declaration invalid; rejecting the token alone left `box-shadow: 1px 1px inherit` as a
shadow and `blur(inherit)` as an empty blur. A var() or env() defers its value, and a custom
property's value is any tokens, so neither is checked.
…CSS-wide keyword whole

These three parse their own value, so a keyword inside it was dropped as a token and the rest
applied: `object-position: left inherit` set `left`. They now share the declaration's keyword
decision, so the declaration is invalid and an earlier rule's value stands, as it does in
Chromium 153, Firefox 155 and WebKit 26.6.
… the function is

A fallback replaces its var() or env(), so a CSS-wide keyword in it applies only when the
function is the declaration's whole value (css-variables-1 §3). Inside a longer value the keyword
leaves the declaration invalid at computed-value time, yet it was resolved everywhere:
`color-mix(in srgb, var(--missing, inherit), blue)` mixed the inherited colour with blue, and
`translateX(var(--missing, initial))` compiled `unset` into the function. Inside a longer value
the fallback is now dropped with a warning.

An env() fallback is parsed for the declaring property, as a var() fallback is, rather than
under the env() name.
A keyword on its own was reported lowercased, while a nested one and every other value warning
quote the value as the stylesheet spells it.
A custom property set to inherit, unset, revert or revert-layer was
dropped, so an earlier rule's value on the same element won, and one
set to initial was dropped with a warning and read the ancestor's
value. Each now compiles to a value the runtime resolves where the
property is declared: inherit reads the parent's value over every
declaration on the element, and initial is the guaranteed-invalid
value, which takes a var() fallback and hides an ancestor's
declaration (css-variables-1 §2).

A declaration a var() leaves without a value is invalid at
computed-value time and behaves as unset (css-variables-1 §3.1): color
inherits, a runtime-parsed shorthand resets its longhands, and any
other property is cleared. A filter list with such an entry has no
filter, an empty fallback substitutes nothing, and the inliner never
folds an inheriting keyword and compiles a read of an inlined initial
the same way. A context lookup reads only a variable's own entries, so
var(--constructor) takes its fallback.
…it sits

A var() with no fallback for a variable that has no value left its
declaration a value when it sat inside a list, a function or another
custom property: `--reader: var(--source) 1px` read `1px`, and a
custom property whose value failed fell back to its ancestor's
declaration instead of the reader's fallback. The lookup now records
the failed substitution itself, so the declaration is invalid at
computed-value time and a custom property holding one is the
guaranteed-invalid value (css-variables-1 §3.1). A channel rncss
publishes for a computed value, such as the em, is a default when
nothing publishes it, never a failure.

The inliner flattens every variable a value reads, inside functions
and fallbacks too, before substituting it, so the result no longer
depends on declaration order, and leaves each member of a cycle to the
runtime. A custom property reading the guaranteed-invalid value is
guaranteed-invalid itself, a declaration whose value substitutes to
nothing is unset, and an empty custom property keeps its empty value,
which substitutes nothing inside a list at runtime as well.
…me too

The compiler made a declaration whose value substitutes to nothing
unset, while the runtime handed React Native the empty list it left:
`margin: var(--a,) var(--b,)` with neither declared rendered
`margin: []` over an earlier rule's value. The list resolver records
when every entry substituted nothing, which leaves a property's value
invalid at computed-value time and a custom property's value empty, as
css-variables-1 §3.1 defines.
The animation shorthand answered `unset` with nothing, so `animation: initial`,
`animation: unset` and `animation: var(--missing)` left an earlier rule's
animation running. It now resets every longhand it sets, as the other
shorthands do.
…ent's variables are assigned

nativewind#474's cascade order over nativewind#391's computed-value-time handling: every rule's
variables are assigned before any declaration resolves, and every declaration
resolves in cascade order. A declaration a var() leaves invalid resolves to
`unset`, and one the runtime cannot read is ignored, so the earlier rule's
value stands: `transform: none rotate(10deg)` after `translateX(10px)` keeps
the translation.

transform, line-height and animation-name now answer `unset` with a reset,
since nothing now means ignored.
…or from a var()

CSS ignores a filter declaration with a function its grammar rejects, and
React Native's processFilter drops the whole list when one entry is invalid,
but rncss passed the entry through or dropped it alone: `blur(2px)
drop-shadow(2px)` drew the blur, and `brightness(-1)` or `hue-rotate(90)`
reached React Native, which then drew no filter at all.

A list is now invalid when any entry fails to resolve, so an ignored written
filter leaves the earlier rule's value and one from a var() leaves none. Each
filter function reads its single argument by the Filter Effects 1 grammar
through readers the compiler and the runtime share. The compiler reads every
angle through the same reader, so a gradian or a turn reaches React Native in
degrees in `hue-rotate()`, `rotate()` and `skew()` alike. A url() entry is
still left out with the rest applied, since React Native has no SVG filter to
reference.
…ets the property

With every declaration resolved in cascade order, a declaration that resolves
to nothing is ignored, so a reset whose parser has no answer for `unset` left
the earlier rule's value in place. A CSS-wide keyword the compiler hands a
runtime parser, and a declaration a var() leaves invalid at computed-value
time, now remove the property whatever the parser answers, which the
transform, line-height and animation-name parsers no longer need to say each.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — The three tiles under the grayscale keep their red, two of them blurred, and the gradian tile stays red: a filter function the property cannot take is dropped alone or handed on, and a gradian held in a variable reaches React Native as written.

FIXED — The two written tiles stay gray as the earlier rule left them, the var() tile is red and sharp with no filter, and the gradian tile is the same teal as its control.

before — 3.1.0-rc.0 after — with this PR

A written turn stops the screen, so it has a pair of its own: filter: hue-rotate(0.5turn) reaches the device as an empty filter entry, [{}], and React Native's processFilter throws on it. The after renders every tile, the turn's among them.

before — 3.1.0-rc.0 after — with this PR

Android 16 emulator (API 36, GLES on the host GPU), 1140×2400 @ 480dpi, each pair from one run; every tile is red under its filter, and the first and fifth tiles of the first pair are the controls. 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.

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