fix: wrong selection coordinates on focus#1234
Merged
kirillzyusko merged 7 commits intomainfrom Feb 9, 2026
Merged
Conversation
Contributor
📊 Package size report
|
ab8a334 to
9bb0cd2
Compare
3208d44 to
dbbbceb
Compare
dbbbceb to
2de07ea
Compare
2 tasks
kirillzyusko
added a commit
that referenced
this pull request
Mar 27, 2026
…ssal (#1403) ## 📜 Description Fixed a problem, when re-focusing field (after dismiss via system button) pushes the field significantly higher. ## 💡 Motivation and Context The `onEnd` handler nulls `lastSelection.value` when the keyboard hides (`e.height === 0`). This was added intentionally in #1234 to solve an iOS 15 problem: without nulling, `onStart` couldn't distinguish a fresh selection (that arrived before `onStart` in the current session) from a stale selection (leftover from the previous session). On iOS 15, selection sometimes arrives after `onStart`, so the `pendingSelectionForFocus` mechanism was introduced to defer layout setup until the selection arrives. However, nulling `lastSelection` causes two regressions: ### 1️⃣ Android — refocus same input Android doesn't re-emit `onSelectionChange` when refocusing the same input at the same cursor position. After the `null`, `onStart` sees l`astSelection.value?.target !== e.target` (because `null?.target` is `undefined`), falls back to `layout.value = input.value` (full input height, e.g. `180px` instead of caret height `43px`), and sets `pendingSelectionForFocus = true`. But the selection event never arrives - so `onMove` runs the entire animation with the **wrong** height. ### 2️⃣ iOS 15 — refocus same input with new cursor Similar to Android - `lastSelection` is `null`, so `onStart` can't use the stale selection as a reasonable fallback. It uses the full input height instead. The fix consist of several coordinated changes ### 1️⃣ Replace lastSelection.value = null with a flag Instead of destroying selection data, introduce `selectionUpdatedSinceHide`: - Set to `false` in `onEnd` when keyboard **hides** - Set to `true` in `onSelectionChange` when any selection arrives - In `onStart`, the "fresh selection" check becomes: ```ts lastSelection.value?.target === e.target && selectionUpdatedSinceHide.value ``` This preserves the iOS 15 detection (stale selection has `selectionUpdatedSinceHide = false`, so `onStart` correctly enters the pending path) while keeping the data available as a fallback. ### 2️⃣ Use stale selection as best-effort fallback in `onStart` When the selection is stale (not fresh) but targets the same input, use `updateLayoutFromSelection()` instead of falling back to `input.value`. The stale caret position (e.g. `y=43`) is much closer to correct than the full input height (e.g. `180`). If a fresh selection arrives later (iOS 15), it will overwrite this — but on Android where it never arrives, the stale value is already correct. ```ts if (lastSelection.value?.target === e.target) { updateLayoutFromSelection(); // stale but same target — use as fallback } else if (input.value) { layout.value = input.value; // different target or no selection at all } pendingSelectionForFocus.value = true; ``` ### 3️⃣ Handle same-target refocus in onSelectionChange Since `lastSelection` is no longer nulled, when iOS 15 refocuses the same input, `onSelectionChange` arrives with `e.target === lastTarget`. The existing check if (`e.target !== lastTarget`) won't enter the deferred-setup block. Fix by also checking the pending flag: ```ts if (e.target !== lastTarget || pendingSelectionForFocus.value) { ``` This ensures the deferred selection setup runs regardless of whether the target changed, as long as `onStart` flagged it as pending. ### 4️⃣ Conditional cleanup of pendingSelectionForFocus in onEnd To prevent the pending flag from leaking into the next focus session (Android case where selection never arrives), clear it in `onEnd` - but only when the keyboard was actually appearing (`keyboardWillAppear.value`), not during a focus switch with the same keyboard height. Otherwise, toolbar focus switching breaks: `onEnd` fires immediately (no animation), clearing the flag before `onSelectionChange` has a chance to process the deferred scroll. ```ts if (e.height === 0) { selectionUpdatedSinceHide.value = false; } else if (keyboardWillAppear.value) { pendingSelectionForFocus.value = false; } ``` Closes #1394 ## 📢 Changelog <!-- High level overview of important changes --> <!-- For example: fixed status bar manipulation; added new types declarations; --> <!-- If your changes don't affect one of platform/language below - then remove this platform/language --> ### JS - create separate `types.ts` file; - created `KeyboardAwareScrollView` tests (70% test coverage, texting 5 most critical/hard to catch bugs); - fixed a problem with re-focus on Android; ## 🤔 How Has This Been Tested? Tested manually and via this PR. ## 📸 Screenshots (if appropriate): |Before|After| |-------|-----| |<video src="https://github.com/user-attachments/assets/c6eb29cf-6756-4bda-8f4f-ba7d83e15d4e">|<video src="https://github.com/user-attachments/assets/7ecc2b46-77cc-4203-8264-63138d325ad5">| |iOS 15|iOS 26|Android| |------|-------|--------| |<video src="https://github.com/user-attachments/assets/7f867783-ab5d-4a8e-9232-a7a7ad68cdaf">|<video src="https://github.com/user-attachments/assets/f054efa7-9774-450c-93ab-7a4d90e6c66b">|<video src="https://github.com/user-attachments/assets/b49bbb15-63f7-4143-ba5c-dcd159159723">| ## 📝 Checklist - [x] CI successfully passed - [x] I added new mocks and corresponding unit-tests if library API was changed
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.
📜 Description
Fixed non-working
KeyboardAwareScrollViewon iOS.💡 Motivation and Context
Turns out that selection is not available straight after input focus. To fix this problem I decided to query selection in next frame:
And it works, and works pretty well, but it introduces a regression on iOS < 16. The thing is that starting from iOS 16 Apple started to use TextKit 2. And in TextKit 2 all operations are async, so for iOS 16+ we'll get events like layout updated -> selection updated -> onStart -> onMove, but on iOS 15 it will be layout updated -> onStart -> selection updated -> onMove.
The original JS code assumed
selectionalways arrives before onStart. On iOS < 16,onStartnow fires first, soupdateLayoutFromSelection()reads stalelastSelection.valuefrom the previous focus session - producing a wrongayout.valueused bymaybeScrollthroughout the keyboard animation.So I added
pendingSelectionForFocusflag - a shared value that tracks whetheronStartfired for a focus change but the corresponding selection event hasn't arrived yet.In
onStart- when focus changes, check iflastSelection.value?.targetmatches the new target:updateLayoutFromSelection()as beforelayout.value = input.valueas a safe fallback (full input height instead of cursor-precise height), set the pending flagThe immediate scroll for focus-change-without-keyboard-appearing (
focusWasChanged && !keyboardWillAppear) is skipped when pending, since we don't have accurate layout data yet.In
onSelectionChange- when the target changes and the pending flag is set:updateLayoutFromSelection()with the now-correct selection dataonMoveexpected), perform the deferred scrollThis ensures
layout.valueis correct beforeonMovestarts using it for scroll interpolation.In onEnd —
lastSelection.valueis cleared tonullwhen the keyboard fully hides (e.height === 0). This prevents a subtle bug: when re-focusing the same input at a different cursor position,lastSelection.value?.targetwould still matche.targetfrom the previous session, makingonStartincorrectly think fresh selection data was available.Closes #1218
📢 Changelog
JS
pendingSelectionForFocusflag;iOS
E2E
🤔 How Has This Been Tested?
Tested manually on:
📸 Screenshots (if appropriate):
Simulator.Screen.Recording.-.iPhone.13.Pro.-.2026-02-09.at.11.51.59.mov
Simulator.Screen.Recording.-.iPhone.17.Pro.-.2026-02-09.at.11.32.28.mov
📝 Checklist