Skip to content

fix(compiler): recover from a rule lightningcss cannot parse - #493

Open
YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/syntax-error-recovery
Open

YevheniiKotyrlo wants to merge 2 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/syntax-error-recovery

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

The compiler runs lightningcss without errorRecovery, so one rule or declaration lightningcss cannot parse throws, and the whole stylesheet compiles to nothing. lightningcss 1.30.1 throws on @media (foo bar) - a <general-enclosed> condition, which a browser evaluates as unknown - on @container scroll-state(stuck: top), on var() with no argument, and on a value outside a media feature's syntax, such as @media (prefers-color-scheme: 0). A browser keeps every rule beside each of them (css-syntax-3 §5, mediaqueries-5 §3.2).

A Metro build compiles what Expo's CSS worker returns, and that worker already runs lightningcss with errorRecovery: true, so it drops the first three before compile() sees them. The fourth it keeps with a warning, and compile() then throws on it, so the build fails. Called directly, as registerCSS does, compile() throws on all four.

Solution

Both passes run with errorRecovery: true, so lightningcss drops what it cannot parse and the rest compiles. A media query whose feature value it refuses it keeps, with a warning, so the second pass reads that query again and recovers too; what the query compiles to is the media compiler's, and #426 answers such a feature as unknown. Each warning the first pass gives reaches compile().warnings() as syntax, through the channel #430 adds.

Tests

src/__tests__/compiler/error-recovery.test.ts compiles each case beside other rules: those rules survive, a media or container query with a <general-enclosed> condition is dropped and reported, the query on (prefers-color-scheme: 0) is reported, and a declaration that fails to parse goes alone. src/__tests__/native/error-recovery.test.tsx renders the same cases in light and dark: the rules beside each style their elements, and the rule itself styles nothing, (prefers-color-scheme: 0) included. The container case avoids scroll-state(), which lightningcss parses from 1.31.0. Both files fail on #430 without this change, every case, and the (prefers-color-scheme: 0) case in each also fails without the second pass's errorRecovery.

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, and the iOS dev build passes with Xcode 27.1, where the workflow pins 26.6.

Not run: the Android example build.

The snippets, run on main and here, directly and through Expo's CSS worker: #493 (comment)

Merge order

It carries #430's commit, whose warning channel is where a dropped rule is reported, so #430 lands first.

#379 changes lines of metro/metro-transformer.ts that #430's commit changes; whichever lands second rebases.

#461 compiles the stylesheet before Expo's worker sees it, so with it the first three cases reach compile() in a Metro build too; this lands first, or they fail that build.

#426 answers a feature value outside its syntax as unknown, as Chromium, Firefox and WebKit do. Until it lands, a value this lets through compiles as written, as an unknown keyword already does on main: (hover: 0) styles every screen, as (hover: foo) does, (orientation: 1) a portrait one, as (orientation: foo) does, and not (prefers-color-scheme: 0) both schemes.

@YevheniiKotyrlo

YevheniiKotyrlo commented Oct 20, 2026 •

Copy link
Copy Markdown
Contributor Author

Snippet — main against this PR

compile(
  `.before { color: red; } @media (foo bar) { .inside { color: blue; } } .after { color: green; }`,
).stylesheet().s?.map(([name]) => name);
result
main (eda2b286) throws Unexpected token Ident("bar"), so the sheet compiles to nothing
this PR, merged into main ["before", "after"]

Run through the repo's jest on Windows with Node 26.

Through a Metro build — main against this PR

.a { color: red } <rule> { .b { color: blue } } .c { color: green } through Expo's CSS worker (@expo/metro-config 57.0.12, lightningcss 1.30.1), then transform() in src/metro/metro-transformer.ts on what it returns, as a native build runs them, and rendered from the injected stylesheet in light and dark, portrait and landscape:

<rule> main (2421ea77) this PR
@media (foo bar), @container (foo bar), @container scroll-state(stuck: top) builds: Expo's worker drops the rule with a warning, and .a and .c apply the same
@media (prefers-color-scheme: 0) Expo's worker keeps the rule with a warning, then transform() throws SyntaxError: Invalid media query, so the build fails builds: .a and .c apply, and .b never does

Expo's worker run in Node and transform() in the repo's jest, on Windows with Node 26.

The compiler records every declaration it could not translate, but nothing
read compile().warnings() during a build, so a dropped float or
z-index: auto was silent. The Metro transformer now prints one block per CSS
file: capped in summary mode, listed in full in verbose mode, and printed
again only when the file's warnings change. The warnings option sets the
level, and none silences it.

lightningcss also warns about input it cannot parse, such as an unknown
at-rule or pseudo-element. Those warnings reach a new syntax channel, kept
apart from what React Native cannot express, and the block counts them as
syntax warnings; this package's own @nativeMapping is not reported.

Every recorded value is text, so CompilerWarnings.values is
Record<string, string[]>; the one number, a percentage line-height, is
recorded as the ratio it resolved to.
lightningcss ran without errorRecovery, so one rule or declaration it
could not parse threw and the whole stylesheet compiled to nothing:
lightningcss 1.30.1 throws on a media or container query's
<general-enclosed> condition, on scroll-state(), on var() with no
argument and on a media feature value outside its syntax, such as
(prefers-color-scheme: 0), all of which a browser drops or evaluates as
unknown while keeping every sibling. Both passes now recover, the second
because lightningcss keeps a query whose feature value it refuses, with
a warning, and reads it again there. Each warning the first pass gives
reaches compile().warnings() as `syntax`.

The compiler test asserts which rules each case keeps and that it is
reported, and the native test renders each case in light and dark.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/syntax-error-recovery branch from 34704fb to c606c22 Compare October 27, 2026 21:47
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 27, 2026 22:44

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