Repository navigation
feat(NitroInput): lay out at the real size in the commit that mounts it - #69
Merged
Merged
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
A field now has its real size in the first frame. Its size used to come from the native view, through
onSizeChangeand a React commit, so the first layout had no measured size.Before
autoWidthfield 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.23456and the field stayed at "$6" until JS was free again.Now
NitroInputMeasurehybrid object.TextInputdoes 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:
InputFonts(was the view'sFontSet)InputMetrics;FontSetbuilds on itkeepPlaceholder's tailInputTextInputTextfeedEnginehands the reflow engine (summed = its width at rest)InputGlyphsInputGlyphsformatOf/sizingTypographyOfInputMappingEach platform's walk mirrors its own
feedEngineexactly, including where the two platforms differ, so rendering is unchanged.In
NitroInputsizingobject feeds both the native props and the measure.transformworklets (a UI-thread worklet), and styles that set both dimensions.Cost
About 0.1 ms per field, once at mount, measured in debug builds:
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.useLayoutEffect+getBoundingClientRect) and compared with the settled layout.onLayoutcan't be used for this: Fabric coalesces layout events, so a size that lasted one commit is often never delivered.NumberFormatand a negative value;maxLength;Everything else passes.
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.Notes
PropNameCollisions.test.tsxnow checks only interfaces that extendHybridViewProps. Before, it flagged the size struct'swidth/heightas view props.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 ofmainand this PR are pixel-identical, apart from the fixes below.0c6bbc2). SettingonSizeChangere-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.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'sbatchdoes.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 throughInputFrame/InputFrameMetrics.7d6c835, there before this PR).TextInputdoes.All Harness suites: iOS 143/143, Android 139/139. Jest 183/183.
🤖 Generated with Claude Code