Skip to content

KeyboardAvoidingView: automaticOffset silently falls back to the parent-relative y when viewPositionInWindow rejects at mount #1594

Description

@giacomo-parkbee

Describe the bug

KeyboardAvoidingView with automaticOffset silently falls back to the parent-relative y whenever the native measurement rejects, and it only measures once per layout. On a screen that never lays out again, the wrong value sticks for the lifetime of the screen and the view avoids the keyboard by absoluteY - relativeY too little.

The chain:

  1. automaticOffset resolves the window position inside onLayout, via KeyboardControllerNative.viewPositionInWindow(tag) (src/components/KeyboardAvoidingView/index.tsx).
  2. On the New Architecture the native side looks the view up with [UIApplication.sharedApplication.activeWindow viewWithTag:tag] and rejects with E_VIEW_NOT_FOUND / "Could not find view for tag" when !view || !view.superview (ios/KeyboardControllerModule.mm). That is exactly the case right after mount, while the view is not yet in the active window's hierarchy — e.g. during a navigation transition.
  3. The JS .catch then calls runOnUI(onLayoutWorklet)(layout) with the raw layout, whose y is relative to the parent. The padding is computed as if the view sat at the top of its parent.
  4. Screens that receive a later layout pass re-run the measurement and self-correct, so this is invisible most of the time. Screens that do not — typically one that opens with an autoFocus input, so the keyboard is already animating in at mount — keep the wrong offset forever.

Nothing is logged, so the failure mode is a plausible-looking layout that is short by a fixed amount.

Code snippet

// Inside a container that is offset from the top of the window (a card with a top
// margin is enough). The keyboard opens during the entrance transition because of
// autoFocus, and the layout never changes afterwards.
<KeyboardAvoidingView behavior="padding" automaticOffset style={{ flex: 1 }}>
  <ScrollView keyboardShouldPersistTaps="handled">
    <TextInput autoFocus />
    <TextInput />
    <Button title="Save" />
    <Button title="Cancel" />
  </ScrollView>
</KeyboardAvoidingView>

Repo for reproducing

I do not have a standalone reproduction repo to attach, sorry — this comes from a production app, where two screens sharing the same container geometry differ only in what opens the keyboard. I am happy to test a patch against those screens if that helps.

To Reproduce

Steps to reproduce the behavior:

  1. Render a KeyboardAvoidingView (behavior="padding", automaticOffset) inside a container that is offset from the top of the window.
  2. Navigate to that screen, with a TextInput that has autoFocus, so the keyboard starts opening while the view is not yet in the active window.
  3. Leave the layout unchanged afterwards.
  4. See the bottom of the content stay behind the keyboard: the applied padding is short by exactly absoluteY - relativeY, permanently.

Expected behavior

automaticOffset should end up with the window position whenever it is available, rather than degrading silently to a different coordinate space on the first attempt.

Possible directions, in increasing order of effort:

  1. Retry the native call on the next frame(s) before falling back.
  2. Re-measure when the keyboard is about to appear (KeyboardEvents.willShow) and not only on layout — that is the moment the value is actually needed, and by then the view is reliably in the hierarchy.
  3. On Fabric, resolve the view through the component-view registry / surface presenter instead of viewWithTag: on the active window, which cannot see views that are not currently in that window.
  4. At the very least, warn in __DEV__ when the fallback kicks in, so the failure is not silent.

Screenshots

None on purpose: any on-screen instrumentation is itself part of the layout it is measuring, so a screenshot of it proves less than the numbers. These come from the KeyboardAvoidingView's own values, read on the native side.

Two screens, same container geometry, identical inputs (window height 844, keyboard 335, layout y 64 relative / 175 in window, height 669):

screen keyboard opened by resulting scroll viewport applied padding uses
A user tapping the input after mount 310 330 window y (correct)
B autoFocus at mount 421 219 relative y (wrong, 111pt short)

219 is exactly 64 + 669 - (844 - 335), i.e. the formula fed with the relative y. The viewport is a function of the padding alone, so it does not depend on how tall the content is.

Instrumenting viewPositionInWindow in the same component shows why:

  • called once at mount: rejects with Could not find view for tag on both screens;
  • called again ~1s later: resolves with y: 175 on both.

So the mount-time call rejects everywhere. The only difference is that screen A gets another layout pass, which repeats the measurement and corrects it, and screen B never does — so on B the last CTA stays behind the keyboard for as long as the screen is open.

Smartphone (please complete the following information):

  • Desktop OS: macOS 26.5
  • Device: iPhone 17e (simulator)
  • OS: iOS 26.5
  • RN version: 0.85.3
  • RN architecture: new / fabric
  • JS engine: Hermes
  • Library version: 1.22.3

Additional context

Also on Expo SDK 56 with react-native-reanimated 4.5.3, release build.

Workaround, for anyone hitting this: measuring from JS is reliable and self-correcting; pass the delta as keyboardVerticalOffset and leave automaticOffset off.

const ref = useRef<View>(null);
const relativeY = useRef(0);
const [offset, setOffset] = useState(0);

const measure = useCallback(() => {
  ref.current?.measureInWindow((_x, y) => {
    const next = Math.max(0, Math.round(y - relativeY.current));
    setOffset(prev => (prev === next ? prev : next));
  });
}, []);

useEffect(() => {
  // "will" fires on iOS, "did" is the Android counterpart
  const subs = [
    Keyboard.addListener('keyboardWillShow', measure),
    Keyboard.addListener('keyboardDidShow', measure),
  ];
  return () => subs.forEach(s => s.remove());
}, [measure]);

<KeyboardAvoidingView
  ref={ref}
  onLayout={e => {
    relativeY.current = e.nativeEvent.layout.y;
    measure();
  }}
  keyboardVerticalOffset={offset}
  behavior="padding"
/>

Two open issues look like the same root cause:

(Reopened from #1592, which I had created through the CLI and which therefore skipped the issue template.)

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions