Skip to content

fix(native): measure em against the font size each element computes - #498

Open
YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/em-font-size-scope
Open

YevheniiKotyrlo wants to merge 1 commit into
nativewind:mainfrom
YevheniiKotyrlo:fix/em-font-size-scope

Conversation

@YevheniiKotyrlo

@YevheniiKotyrlo YevheniiKotyrlo commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Problem

em measures against the wrong font size on native:

  • The child of a font-size: 10px element renders width: 2em as 28, twice the root's 14: an em length never asks for the em its ancestors publish.
  • font-size: 2em under a 10px parent renders 28, and the element's own width: 2em 28 too: the size is evaluated against the em channel it is itself defining, so it falls back to the root's. font-size: 150% reaches the style as the string "150%".
  • A descendant re-resolves an ancestor's 2em in its own scope, so a grandchild's 1em under it renders 14 rather than 20.
  • font: 20px Arial publishes no em at all.

The expected values in the tests are what Chromium 153, Firefox 155 and WebKit 26.6 compute for the same CSS with the root at 14px.

Solution

  • em counts as a read of the em channel, so a rule using it receives the variables its ancestors publish.
  • A font size the runtime computes is wrapped in a fontSize function whose argument is evaluated with the parent's em (css-fonts-4 §2.5), so an em inside it, a percentage written or held in a variable, and a var() holding either all measure against the parent. A font size met inside that scope shares it, so a size relative to itself ends as a cycle instead of recursing.
  • An element publishes the font size it computed, not its declaration, and the variables it publishes follow the cascade, so a descendant inherits the declaration that wins rather than the one whose class name comes last.
  • font-size: 150% compiles to the parent-relative em it is, and the font shorthand publishes its size as font-size does.
  • The jest harness resets the root and universal variables between tests, so a :root declaration in one test no longer reaches the next.

Unchanged from main: a :root font size relative to its parent has no initial size to measure against, and a calc() lightningcss cannot fold, such as calc(1em + 4px), compiles to nothing for every property.

Tests

  • src/__tests__/native/em-scope.test.tsx runs one table four ways: each value written and through a variable the compiler keeps (inlineVariables: false), each injected in memory and after a JSON round trip. It covers em and percentage sizes, inheritance depth, unitless line heights, :root and *, a size that resolves to nothing, a provider's variable, cascade order, viewport and media changes, re-rendering, and the font shorthand.
  • src/__tests__/compiler/font-size.test.ts pins the descriptor each form compiles to and that it survives the round trip.
  • 62 of the 93 cases fail on main, and undoing any one part of the change fails at least one.

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.

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

Merge order

This adds the root-and-universal half of the resetVariableRegistries #412 adds; whichever lands second keeps #412's version.

An em length never declared that it reads the em channel an ancestor
publishes, so the child of a `font-size: 10px` element sized `2em` as 28
rather than 20. A font size relative to the parent (`2em`, `150%`, a
variable holding either) was evaluated against the element's own em
channel, which is the size being computed, so it fell back to the
root's. And descendants re-resolved an ancestor's `2em` in their own
scope.

`em` now reads the inherited channel. A font size the runtime computes
is wrapped in a `fontSize` function whose argument is evaluated with the
parent's em, and the element publishes the number it computed to its
descendants. `font-size: 150%` and the `font` shorthand take the same
path. The variables an element publishes follow the cascade, so a
descendant inherits the declaration that wins rather than the one whose
class name comes last.

The jest harness resets the root and universal variables between tests,
so a `:root` declaration in one test no longer reaches the next.
@YevheniiKotyrlo

Copy link
Copy Markdown
Contributor Author

Device evidence — before / after

UNFIXED — Everything under the 10px parent is sized against the 14px root: the square under it comes out too large, the square under the relative size too small, the relative size and its own square are equal, the 150% size is never applied, and the text renders at 28px.

FIXED — Everything measures against the font size its element computes: twice the parent's 10px under it, a relative size and its square in a 1:2 ratio, the square under the relative size at the 20px it computed, twice the 15px the 150% size computes, and the text at 20px.

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. 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 17:37

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