Skip to content

feat(NitroInput): lay out at the real size in the commit that mounts it - #69

Merged
ronickg merged 3 commits into
mainfrom
feat/input-first-frame-size
Oct 12, 2026
Merged

ronickg merged 3 commits into
mainfrom
feat/input-first-frame-size

Conversation

@ronickg

@ronickg ronickg commented Oct 12, 2026 •

Copy link
Copy Markdown
Owner

A field now has its real size in the first frame. Its size used to come from the native view, through onSizeChange and a React commit, so the first layout had no measured size.

Before

  • An autoWidth field took its parent's width, or 0, for a commit. That also caused the CI flake that test(harness): two Android first-attempt flakes wait for what they check #68 works around.
  • Every field without a fixed height moved by a pixel or two as its line box arrived, for example 25 → 24 dp at size 20.
  • Busy JS also stalled sizing. On the emulator, while JS was blocked I typed 23456 and the field stayed at "$6" until JS was free again.

Now

  • A single-line field is measured on the JS thread before its view exists, by a new NitroInputMeasure hybrid object.
  • This is the same thing React Native's TextInput does in its shadow node, and what react-native-enriched does on Android. Both measure from props for the first layout, with the platform's thread-safe text measurement.

Shared sizing code

The measure uses the views' own sizing code, moved out of the views so the two can't disagree:

Android iOS
Fonts, letter spacing, affix seams, advance widths InputFonts (was the view's FontSet) InputMetrics; FontSet builds on it
Conforming the text, the placeholder, keepPlaceholder's tail InputText InputText
The glyph walk feedEngine hands the reflow engine (summed = its width at rest) InputGlyphs InputGlyphs
Props → config, shared by the hybrid view and the measure formatOf / sizingTypographyOf InputMapping

Each platform's walk mirrors its own feedEngine exactly, including where the two platforms differ, so rendering is unchanged.

In NitroInput

  • One sizing object feeds both the native props and the measure.
  • Its result seeds the size. When the view's report matches it, no extra commit happens.
  • It's measured again only when what it depends on changes, and only until the view first reports.
  • These cases keep the old path: multiline (its height needs a width), transform worklets (a UI-thread worklet), and styles that set both dimensions.
  • If the native measure is missing or throws (old binary, Jest), it falls back to the old path.

Cost

About 0.1 ms per field, once at mount, measured in debug builds:

Reflow amount Plain mask
iPhone 17 simulator 91 µs 88 µs
Pixel 9 emulator 134 µs 115 µs

In return, each field saves the React commit its native size report used to cost.

Tests

New on-device suite, example/__tests__/nitro-input-sizing.harness.tsx.

  • Each field is mounted in a box that hugs it. The box's layout is read synchronously in the commit that mounted it (useLayoutEffect + getBoundingClientRect) and compared with the settled layout.
  • onLayout can't be used for this: Fabric coalesces layout events, so a size that lasted one commit is often never delivered.
  • Cases:
    • plain text and a placeholder;
    • number with affixes;
    • reflow with letter spacing;
    • reflow with NumberFormat and a negative value;
    • affix sizes and seams;
    • masks with the kept placeholder, plain and reflow;
    • secure entry;
    • a label hiding the placeholder;
    • maxLength;
    • line height and weight;
    • a custom font;
    • a stretched field;
    • a controlled field;
    • follow-up resizing.
  • 16/16 on the iPhone 17 simulator and the Pixel 9 emulator. With the seed switched off, 15 of the 16 fail on both platforms. Only the follow-up-resizing case passes either way, as it should.

Everything else passes.

  • All Harness suites: iOS 139/139, Android 135/135.
  • Jest 183/183, including the new Sizing.test.tsx: seeding, no commit on a matching report, no re-measuring on re-renders, the fallback paths, and that the measure sees exactly the props the view gets.
  • Typecheck and the C++ engine tests.

Notes

  • PropNameCollisions.test.tsx now checks only interfaces that extend HybridViewProps. Before, it flagged the size struct's width/height as view props.
  • Nitrogen also reorders three existing enums at random each time it runs. I left those files as committed.

Also fixed, found by recording fields mounting over and over

I recorded a probe screen of 25 edge-case fields remounting every 2 s on both platforms, comparing each field's box in its first frame with the settled one. On main, every box starts with no width in 6/8 iOS and 7/7 Android mounts. With this PR every box is final in its first frame (iOS 8/8, Android 7/7). The settled screens of main and this PR are pixel-identical, apart from the fixes below.

  • Stray size on mount (0c6bbc2). Setting onSizeChange re-sent the view's last size, which on a fresh view was the defaults' and on a recycled one the previous element's. On iOS a commit also reported half-applied props. The hybrid views now hold sizes back until the props are applied, then send the settled one.
  • A reused view's reflowing amount slid in (0c6bbc2). Recycling reset the engine and then the props, and the props reset fed it an empty text that it took for its first. The engine is now reset last. iOS also feeds the engine once per prop commit now, as Android's batch does.
  • Framed fields without a size (7d6c835, there before this PR). The reported size left out the frame's side insets and the label, so a resting label was drawn outside the field and a filled field's text was cut off. The size now includes the frame, shared through InputFrame / InputFrameMetrics.
  • Secure fields cut off their bullets (7d6c835, there before this PR).
    • Android: the password input type switched the field to monospace. The typeface is now put back after it, as RN's TextInput does.
    • iOS: UITextField draws U+2219 in the system font. I verified this with the caret at four sizes, in bold and in a custom font.

All Harness suites: iOS 143/143, Android 139/139. Jest 183/183.

🤖 Generated with Claude Code

Ronald Goedeke and others added 3 commits October 12, 2026 15:47
A field's size came from its view: the view measured itself and called
onSizeChange, JS set state and React committed it. So the first layout
had no measured size. An autoWidth field took its parent's width (or 0)
for a commit, and every field without a fixed height moved by a pixel or
two as its line box arrived. The size also depended on the JS thread:
with JS busy, a field stopped growing as the user typed.

A single-line field is now measured on the JS thread before its view
exists, the way React Native's TextInput measures from props in its
shadow node: a new NitroInputMeasure hybrid object. It uses the code
the view sizes itself with, pulled out of the views so the two cannot
disagree:

- InputFonts (Android, was the view's FontSet) / InputMetrics (iOS):
  fonts, letter spacing, affix seams, advance widths
- InputText: conforming the text (number, mask, maxLength), the
  placeholder and keepPlaceholder's tail, over the shared C++ engines
- InputGlyphs: the glyph walk feedEngine hands the reflow engine, summed
  for its width at rest
- formatOf / sizingTypographyOf (InputMapping on iOS): props to config,
  shared by the hybrid view and the measure

NitroInput builds one `sizing` object for both the native props and the
measure, and seeds its size with the result. The view's own report then
matches it and no longer costs a commit. Multiline fields (their height
needs a width), transform worklets and styles that set both sides keep
the old path; without a native measure it falls back.

About 0.1 ms per field on the JS thread (debug: ~90 us simulator,
~120 us emulator).

Tests: a Harness suite (nitro-input-sizing) reads each field's layout in
the commit that mounted it and compares it with the settled one. 16/16
on iOS and Android; with the seed switched off, 15 of them fail. Jest
covers the wrapper. All suites pass on both platforms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by recording edge-case fields mounting over and over on both
platforms and comparing each field's box in its first frame with the
settled one.

- A size the view reported before its props were applied reached JS.
  Setting `onSizeChange` re-sent the view's last size, which on a fresh
  view was the defaults' (font 32, no text) and on a recycled one the
  previous element's. On iOS a commit also reported half-applied props
  (the config set, the text not yet). With the field laid out at its
  measured size from the first commit, that stray size made it jump to
  it and back (1 mount in 5 on Android). The hybrid views now hold sizes
  back until the props are applied and while a commit runs, then send
  the settled one.
- A reused view's reflowing amount slid in instead of showing. Recycling
  reset the engine and then the props, and the reset `plain` fed the
  engine an empty text, which it took for its first (it snaps only its
  first). The engine is now reset last, on both platforms; on Android
  views are not recycled by default, so only iOS showed it.
- iOS feeds the engine once per prop commit, as Android's `batch` does,
  so the config and the text reach it together.

All Harness suites pass (iOS 139, Android 135).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found by the edge-case mount recordings; both were there before this
branch (main and the branch settle pixel-identical).

- An outlined or filled field with no size in its style took the size of
  its text line: the side insets and the label were left out, so a
  resting label was drawn outside the field and a filled field's text
  was cut off. The reported size now includes the frame: the side insets
  either side of the text or the resting label (whichever is wider), and
  16 of padding above and below the line plus a filled label's line, as
  a wrapping field already had. The frame's metrics move to InputFrame /
  InputFrameMetrics, shared by the views and the measure (which now takes
  `labelFontSize` and `cornerRadius`).
- A secure autoWidth field cut its bullets off. Android set the password
  input type after the typeface, which switches the field to monospace;
  the typeface is put back after it, as React Native's TextInput does.
  iOS measured bullets in the field's font, but UITextField draws U+2219
  in the system font at the field's size (its weight for a system-font
  field, regular otherwise): measured with the caret at 12, 18, 30 and
  40 pt, bold and a custom font, the totals match to the pixel.

The sizing suite gains a filled field, a label wider than its text, and
secure text in bold and in a custom font. All suites pass (iOS 143,
Android 139); Jest 183.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ronickg
ronickg merged commit e8e5f41 into main Oct 12, 2026
6 checks passed
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