Skip to content

fix(compiler): read a media query's em and rem against the font size rem inlines - #494

Draft
YevheniiKotyrlo wants to merge 23 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/media-initial-font-size
Draft

YevheniiKotyrlo wants to merge 23 commits into
nativewind:mainfrom
YevheniiKotyrlo:fix/media-initial-font-size

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

A media query has no element, so its em and its rem are both the initial font size (Media Queries 4 §1.3). The compiler keeps a media bound in em as a runtime em descriptor, and with inlineRem: false a bound in rem as a runtime rem descriptor, and the media evaluator decides neither: (min-width: 10em) never matches, and with inlineRem: false no rem breakpoint does - Tailwind 4's sm: to 2xl: among them.

Solution

The compiler reads a media bound in em or rem against the root font size the stylesheet configures, inlineRem or else a :root font size, whether or not rem is inlined, so the bound is a number before the evaluator sees it. main already inlines a media query's rem against that font size; the compiler now resolves it once, and the rem inliner, parseLength's rem and the media bound read it from the builder. Container queries keep their em, which is the container's (CSS Conditional 5 §6.1).

That default font size was a literal 14 in five places - the compiler's fallback, parseLength's default, the runtime's seed of __rn-css-rem, the runtime's default fontSize, and an unread rem field on the builder. It is DEFAULT_REM in src/utilities/rem.ts now, parseLength reads the builder's root font size, and the unread field goes.

Tests

src/__tests__/native/media-query-font-relative.test.tsx renders a min-width and a max-width query at each bound: in em against the default rem and an inlined rem of 16, in em and rem with inlineRem: false, and under a 10px :root font size, inlined, not inlined and with inlineRem: 16; an em range; and a negated em term. All 11 fail on #426. #426's condition-semantics.test.tsx expects not (width: 10em) to stay unknown; that case moves here, decided.

Verification

Every ci.yml job passes on this head merged into main with #477's harness fix: lint, typecheck, the tests (1629 passed), the Node tooling tests, both builds, the web export, nothing unstaged, commitlint, and the iOS dev build on macOS 27.0.1 with Xcode 27.0, where CI pins 26.6.

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

Known limits

  • A stylesheet that declares a :root font size moves its media em and rem with it, as main already moves its inlined media rem; a browser keeps both at its initial font size (MQ4 §1.3).
  • An em inside a calc() in a media query is still a runtime descriptor the evaluator answers as unknown.

Merge order

It carries #426's commits and changes the media operand parsing #426 adds, so #426 lands first.

Both evaluators answered a range pair such as (400px < width < 800px)
with false, though the compiler emits it faithfully. Media and container
queries now share compareMediaFeature, so each operator has one meaning,
and testMediaFeatureInterval answers a pair as its two comparisons.
…alse

MQ5 §3.1 evaluates a media condition in three-valued logic: a term the
runtime cannot decide is unknown, its negation is unknown, and a rule
applies only when the whole condition is true. Both runtimes answered such
a term false, so its negation matched: not (fictional-feature: 3) and
not (pointer: fine) applied everywhere.

A media feature value the compiler cannot resolve compiles to null, and
both runtimes evaluate in Kleene logic, answering unknown for a null
operand, a feature they do not measure, an operand they cannot order and a
container style() query. A container condition holding a size feature the
runtime does not know, or a value the compiler cannot resolve, selects no
container (CSS Conditional 5 §5.4), so the compiler emits no rule for it.

A feature in a boolean context is evaluated (MQ5 §2.4.2). The runtime
answers the features whose value is the same on every React Native screen,
(hover: none) no longer matches, a device with no color scheme preference
matches light, and an <mq-boolean> value compiles to its integer.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — Only the control paints. A bound in em is left for the runtime, which cannot decide it, so neither em query ever matches.

FIXED — All three bars paint green. A media query's em is the initial font size, so 10em is 160 pixels and 1000em is wider than any screen.

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. My test app inlines rem at 16px, so the em bounds read 160px and 16000px. 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.

@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as ready for review October 16, 2026 15:00
…ot one of its own

lightningcss passes any identifier through as a feature's value, so
`(orientation: bogus)` compiled to a comparison that is false, and its
negation matched everywhere. A keyword the feature does not take now
compiles to an unknown term in a media query, and to no rule in a container
query, as an unresolvable value already does, so neither the term nor its
negation matches, as in Chromium, Firefox and WebKit.
…paring it

A keyword is ASCII case-insensitive (css-values-4 §4.1). The keyword
check folded it while the compiled comparison kept it as written, so
`(orientation: PORTRAIT)` never matched and `not (orientation: PORTRAIT)`
matched in portrait. The value is folded where the compiler reads it,
which both the check and the comparison then use.
…ver emits

lightningcss compiles every `min-` and `max-` media feature to a range
on `width` or `height`, so no compiled condition names them.
Its new lines hid a duplicated union and an unused import behind the
file-wide `eslint-disable`, and the compiler's operator default wrote a
`never` into its error message.
…h, and answer hover by input

A container term the compiler cannot encode, such as (width > 10in), is
unknown rather than dropping the whole query with its `or` siblings, and
`=` with an operand the runtime cannot order is unknown, as the other
operators are. A condition no device can make true, such as a style()
query or an unknown keyword alone or negated, compiles to no rule. An
unknown media feature named like an Object.prototype member compiles
again. hover and any-hover answer none on iOS and Android until a
pointer hovers an element, and hover from then on and on other
platforms (MQ5 §7.2, §7.3), so Tailwind's hover: keeps working wherever
a pointer hovers. A held color scheme is compared as held, so a null one
is unknown. The two files that kept a file-wide lint disable lint clean
without it.
…y, as the components read it

The jest helper resolves to the package's source in a consumer's suite
while the components it renders resolve to the build, so a reset through
a relative import missed the signal they read.
…ebkit-device-pixel-ratio

A media query's reader is a module constant that takes the getter, a container's
takes one context object, and the walk folds and and or itself, so evaluating a
term allocates nothing. -webkit-device-pixel-ratio reads PixelRatio.get() as
resolution does. The nested conditions are readonly, the container tests assert
the colour absent before layout, and the condition test's afterEach, which the
Jest preset's reset made dead, goes.
css-values-4 §7.4 excludes a negative <resolution>, and Chromium, Firefox
and WebKit leave a media feature holding one unknown, as WPT's
test_media_queries.html expects of `resolution: -3dpi`. It compiled to a
negative dppx instead, so `(min-resolution: -1dpi)` matched every screen.
A value outside a feature's syntax (env(), a negative resolution, or a
keyword the feature does not take) is now decided in one place.
@YevheniiKotyrlo
YevheniiKotyrlo marked this pull request as draft October 22, 2026 08:15
…rem inlines

A media query has no element, so its em and rem are both the initial
font size (Media Queries 4 §1.3). The compiler kept a media bound in em
as a runtime em descriptor, and with inlineRem: false one in rem as a
runtime rem descriptor; the media evaluator decides neither, so
(min-width: 10em) never matched, and with inlineRem: false no rem
breakpoint did. Both now compile against the rem the compiler inlines,
or React Native's default font size when it inlines none.

The default font size was a literal 14 in four places; it is
DEFAULT_REM in utilities, and the builder's unread rem field goes.
…lined or not

A :root font size sets rem whether or not rem is inlined, but a media
query read it only when rem was inlined, and React Native's default
otherwise, so turning inlining off moved every em and rem breakpoint of
a stylesheet that declares one. The compiler now resolves the root font
size once, and the rem inliner, parseLength's rem and the media reader
all read it from the builder.
@YevheniiKotyrlo
YevheniiKotyrlo force-pushed the fix/media-initial-font-size branch from 2dd7e52 to 567c206 Compare October 22, 2026 09:56

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