From 02ca4c0f3ec76c0dfed6d7559d9830ca68c4d2bb Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 12:46:45 +0100 Subject: [PATCH 1/9] fix: `KeyboardAwareScrollView` re-focus after hardware keyboard dismissal --- .../KeyboardAwareScrollView/index.tsx | 100 ++++++++++++++++-- 1 file changed, 91 insertions(+), 9 deletions(-) diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index 162e1fec57..fb25df4d55 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -151,6 +151,7 @@ const KeyboardAwareScrollView = forwardRef< useSharedValue(null); const ghostViewSpace = useSharedValue(-1); const pendingSelectionForFocus = useSharedValue(false); + const selectionUpdatedSinceHide = useSharedValue(false); const scrollViewPageY = useSharedValue(0); const { height } = useWindowDimensions(); @@ -185,12 +186,19 @@ const KeyboardAwareScrollView = forwardRef< (e: number, animated: boolean = false) => { "worklet"; + console.log(`[KASV::maybeScroll] called with e=${e}, animated=${animated}`); + console.log(`[KASV::maybeScroll] enabled=${enabled}, scrollPosition=${scrollPosition.value}, position=${position.value}`); + console.log(`[KASV::maybeScroll] keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`); + console.log(`[KASV::maybeScroll] layout.parentScrollViewTarget=${layout.value?.parentScrollViewTarget}, scrollViewTarget=${scrollViewTarget.value}`); + if (!enabled) { + console.log("[KASV::maybeScroll] BAIL: not enabled"); return 0; } // input belongs to ScrollView if (layout.value?.parentScrollViewTarget !== scrollViewTarget.value) { + console.log("[KASV::maybeScroll] BAIL: input not in this ScrollView"); return 0; } @@ -199,6 +207,9 @@ const KeyboardAwareScrollView = forwardRef< const inputHeight = layout.value?.layout.height || 0; const point = absoluteY + inputHeight; + console.log(`[KASV::maybeScroll] height=${height}, visibleRect=${visibleRect}, absoluteY=${absoluteY}, inputHeight=${inputHeight}, point=${point}, bottomOffset=${bottomOffset}`); + console.log(`[KASV::maybeScroll] condition: visibleRect - point (${visibleRect - point}) <= bottomOffset (${bottomOffset}) => ${visibleRect - point <= bottomOffset}`); + if (visibleRect - point <= bottomOffset) { const relativeScrollTo = keyboardHeight.value - (height - point) + bottomOffset; @@ -216,6 +227,8 @@ const KeyboardAwareScrollView = forwardRef< const targetScrollY = Math.max(interpolatedScrollTo, 0) + scrollPosition.value; + console.log(`[KASV::maybeScroll] SCROLL DOWN: relativeScrollTo=${relativeScrollTo}, interpolatedScrollTo=${interpolatedScrollTo}, targetScrollY=${targetScrollY}`); + scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated); return interpolatedScrollTo; @@ -225,12 +238,16 @@ const KeyboardAwareScrollView = forwardRef< const positionOnScreen = visibleRect - bottomOffset; const topOfScreen = scrollPosition.value + point; + console.log(`[KASV::maybeScroll] SCROLL UP: point=${point} < scrollViewPageY=${scrollViewPageY.value}, scrollTo=${topOfScreen - positionOnScreen}`); + scrollTo( scrollViewAnimatedRef, 0, topOfScreen - positionOnScreen, animated, ); + } else { + console.log("[KASV::maybeScroll] NO SCROLL needed"); } return 0; @@ -267,6 +284,8 @@ const KeyboardAwareScrollView = forwardRef< const prevScroll = scrollPosition.value; + console.log(`[KASV::performScrollWithPositionRestoration] newPosition=${newPosition}, prevScroll=${prevScroll}, keyboardHeight=${keyboardHeight.value}`); + // eslint-disable-next-line react-compiler/react-compiler scrollPosition.value = newPosition; maybeScroll(keyboardHeight.value, true); @@ -338,18 +357,31 @@ const KeyboardAwareScrollView = forwardRef< const lastTarget = lastSelection.value?.target; const latestSelection = lastSelection.value?.selection; + console.log(`[KASV::onSelectionChange] target=${e.target}, lastTarget=${lastTarget}, pendingSelectionForFocus=${pendingSelectionForFocus.value}`); + console.log(`[KASV::onSelectionChange] selection.end.y=${e.selection.end.y}, selection.end.position=${e.selection.end.position}`); + lastSelection.value = e; + selectionUpdatedSinceHide.value = true; + + if (e.target !== lastTarget || pendingSelectionForFocus.value) { + console.log(`[KASV::onSelectionChange] target changed or pending focus! (targetChanged=${e.target !== lastTarget}, pending=${pendingSelectionForFocus.value})`); - if (e.target !== lastTarget) { if (pendingSelectionForFocus.value) { // selection arrived after onStart - complete the deferred setup pendingSelectionForFocus.value = false; updateLayoutFromSelection(); + console.log(`[KASV::onSelectionChange] deferred setup complete. layout=${JSON.stringify(layout.value?.layout)}`); + console.log(`[KASV::onSelectionChange] keyboardWillAppear=${keyboardWillAppear.value}, keyboardHeight=${keyboardHeight.value}`); + // if keyboard was already visible (focus change, no onMove expected), // perform the deferred scroll now if (!keyboardWillAppear.value && keyboardHeight.value > 0) { - position.value += maybeScroll(keyboardHeight.value, true); + const scrollDelta = maybeScroll(keyboardHeight.value, true); + + console.log(`[KASV::onSelectionChange] deferred scroll: scrollDelta=${scrollDelta}, position before=${position.value}`); + position.value += scrollDelta; + console.log(`[KASV::onSelectionChange] position after=${position.value}`); } } @@ -361,10 +393,14 @@ const KeyboardAwareScrollView = forwardRef< e.selection.end.position === e.selection.start.position && latestSelection?.end.y !== e.selection.end.y ) { + console.log("[KASV::onSelectionChange] new line detected, scrollFromCurrentPosition"); + return scrollFromCurrentPosition(); } // selection has been changed if (e.selection.start.position !== e.selection.end.position) { + console.log("[KASV::onSelectionChange] selection range changed, scrollFromCurrentPosition"); + return scrollFromCurrentPosition(); } @@ -390,6 +426,12 @@ const KeyboardAwareScrollView = forwardRef< onStart: (e) => { "worklet"; + console.log("=== [KASV::onStart] ==="); + console.log(`[KASV::onStart] e.height=${e.height}, e.target=${e.target}, e.duration=${e.duration}`); + console.log(`[KASV::onStart] BEFORE: keyboardHeight=${keyboardHeight.value}, tag=${tag.value}, position=${position.value}, scrollPosition=${scrollPosition.value}`); + console.log(`[KASV::onStart] BEFORE: initialKeyboardSize=${initialKeyboardSize.value}, scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`); + console.log(`[KASV::onStart] input.value?.layout=${JSON.stringify(input.value?.layout)}`); + const keyboardWillChangeSize = keyboardHeight.value !== e.height && e.height > 0; @@ -400,8 +442,11 @@ const KeyboardAwareScrollView = forwardRef< (tag.value !== e.target && e.target !== -1) || keyboardWillChangeSize; + console.log(`[KASV::onStart] keyboardWillAppear=${keyboardWillAppear.value}, keyboardWillHide=${keyboardWillHide}, keyboardWillChangeSize=${keyboardWillChangeSize}, focusWasChanged=${focusWasChanged}`); + if (keyboardWillChangeSize) { initialKeyboardSize.value = keyboardHeight.value; + console.log(`[KASV::onStart] keyboardWillChangeSize -> initialKeyboardSize set to ${keyboardHeight.value}`); } if (keyboardWillHide) { @@ -409,6 +454,7 @@ const KeyboardAwareScrollView = forwardRef< initialKeyboardSize.value = 0; scrollPosition.value = scrollBeforeKeyboardMovement.value; pendingSelectionForFocus.value = false; + console.log(`[KASV::onStart] keyboardWillHide -> scrollPosition reset to scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`); } if ( @@ -422,21 +468,30 @@ const KeyboardAwareScrollView = forwardRef< keyboardHeight.value = e.height; // and update keyboard spacer size syncKeyboardFrame(e); + console.log(`[KASV::onStart] persisted: scrollPosition=${position.value}, keyboardHeight=${e.height}`); } // focus was changed if (focusWasChanged) { tag.value = e.target; - if (lastSelection.value?.target === e.target) { - // selection arrived before onStart - use it to update layout + if (lastSelection.value?.target === e.target && selectionUpdatedSinceHide.value) { + // fresh selection arrived before onStart - use it to update layout + console.log("[KASV::onStart] focusChanged: FRESH selection arrived BEFORE onStart, updating layout from selection"); updateLayoutFromSelection(); pendingSelectionForFocus.value = false; } else { - // selection hasn't arrived yet for the new target. - // use input layout as-is; will be refined when selection arrives. - if (input.value) { + // selection hasn't arrived yet for the new target (iOS 15), + // or it's stale from previous session (Android refocus same input). + // Use stale selection as best-effort fallback if available for same target, + // otherwise fall back to full input layout. + // Will be corrected if a fresh onSelectionChange arrives. + if (lastSelection.value?.target === e.target) { + console.log(`[KASV::onStart] focusChanged: using STALE selection as fallback, selection.end.y=${lastSelection.value?.selection.end.y}`); + updateLayoutFromSelection(); + } else if (input.value) { layout.value = input.value; + console.log(`[KASV::onStart] focusChanged: no selection for target, using input layout: absoluteY=${input.value?.layout.absoluteY}, height=${input.value?.layout.height}`); } pendingSelectionForFocus.value = true; } @@ -444,13 +499,20 @@ const KeyboardAwareScrollView = forwardRef< // save current scroll position - when keyboard will hide we'll reuse // this value to achieve smooth hide effect scrollBeforeKeyboardMovement.value = position.value; + console.log(`[KASV::onStart] focusChanged: scrollBeforeKeyboardMovement=${position.value}`); } if (focusWasChanged && !keyboardWillAppear.value) { + console.log(`[KASV::onStart] focusChanged + keyboard already visible, pendingSelection=${pendingSelectionForFocus.value}`); + if (!pendingSelectionForFocus.value) { // update position on scroll value, so `onEnd` handler // will pick up correct values - position.value += maybeScroll(e.height, true); + const scrollDelta = maybeScroll(e.height, true); + + console.log(`[KASV::onStart] maybeScroll returned scrollDelta=${scrollDelta}, position before=${position.value}`); + position.value += scrollDelta; + console.log(`[KASV::onStart] position after=${position.value}`); } } @@ -459,14 +521,24 @@ const KeyboardAwareScrollView = forwardRef< scrollViewLayout.value.height - scrollViewContentSize.value.height; + console.log(`[KASV::onStart] ghostViewSpace=${ghostViewSpace.value}, scrollViewLayout.height=${scrollViewLayout.value.height}, contentSize.height=${scrollViewContentSize.value.height}`); + if (ghostViewSpace.value > 0) { scrollPosition.value = position.value; + console.log(`[KASV::onStart] ghostViewSpace > 0 -> scrollPosition=${position.value}`); } + + console.log(`[KASV::onStart] FINAL: scrollPosition=${scrollPosition.value}, position=${position.value}, keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`); + console.log(`[KASV::onStart] FINAL: layout=${JSON.stringify(layout.value?.layout)}`); }, onMove: (e) => { "worklet"; + console.log(`[KASV::onMove] e.height=${e.height}, e.progress=${e.progress}, position=${position.value}`); + if (removeGhostPadding(e.height)) { + console.log("[KASV::onMove] BAIL: removeGhostPadding handled it"); + return; } @@ -478,16 +550,26 @@ const KeyboardAwareScrollView = forwardRef< onEnd: (e) => { "worklet"; + console.log("=== [KASV::onEnd] ==="); + console.log(`[KASV::onEnd] e.height=${e.height}, e.target=${e.target}`); + console.log(`[KASV::onEnd] BEFORE: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`); + removeGhostPadding(e.height); keyboardHeight.value = e.height; scrollPosition.value = position.value; if (e.height === 0) { - lastSelection.value = null; + selectionUpdatedSinceHide.value = false; + } else { + // keyboard fully shown — clear pending flag to prevent leaking + // into next focus-change session + pendingSelectionForFocus.value = false; } syncKeyboardFrame(e); + + console.log(`[KASV::onEnd] AFTER: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`); }, }, [ From aa73e238b1a408f5f9ba311a404ef0f412bb4064 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 12:47:32 +0100 Subject: [PATCH 2/9] chore: add unit tests to avoid future regressions --- .../__tests__/scrollBehavior.spec.tsx | 325 ++++++++++++++++++ 1 file changed, 325 insertions(+) create mode 100644 src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx diff --git a/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx b/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx new file mode 100644 index 0000000000..5ede1871ff --- /dev/null +++ b/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx @@ -0,0 +1,325 @@ +import React from "react"; +import { View } from "react-native"; +import { render, act } from "@testing-library/react-native"; + +// --------------------------------------------------------------------------- +// Constants (derived from real device logs) +// --------------------------------------------------------------------------- +const SCREEN_HEIGHT = 844; +const KEYBOARD_HEIGHT = 291; +const SV_TARGET = 769; // scrollView native handle +const INPUT_TARGET = 765; // TextInput native handle + +const INPUT_LAYOUT = { + absoluteY: 541, + height: 360, + y: 450, + x: 0, + absoluteX: 0, + width: 390, +}; + +// --------------------------------------------------------------------------- +// Mocks – state +// --------------------------------------------------------------------------- +const mockScrollTo = jest.fn(); +const mockInput: { value: any } = { value: null }; +const mockOffset: { value: number } = { value: 0 }; +const mockLayout: { value: { width: number; height: number } } = { + value: { width: 390, height: 753 }, +}; +const mockSize: { value: { width: number; height: number } } = { + value: { width: 390, height: 2000 }, +}; + +let capturedOnLayout: ((e: any) => void) | null = null; + +// --------------------------------------------------------------------------- +// Mocks – modules +// --------------------------------------------------------------------------- + +function mockInterpolate( + value: number, + input: number[], + output: number[], +): number { + "worklet"; + + if (input[1] === input[0]) { + return output[0]; + } + + const progress = (value - input[0]) / (input[1] - input[0]); + + return output[0] + progress * (output[1] - output[0]); +} + +jest.mock("react-native-reanimated", () => ({ + ...require("react-native-reanimated/mock"), + scrollTo: (...args: any[]) => mockScrollTo(...args), + interpolate: mockInterpolate, + clamp: (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max), +})); + +let keyboardHandlers: { + onStart: (e: any) => void; + onMove: (e: any) => void; + onEnd: (e: any) => void; +}; +jest.mock("../useSmoothKeyboardHandler", () => ({ + useSmoothKeyboardHandler: jest.fn((h: any) => { + keyboardHandlers = h; + }), +})); + +let selectionHandler: (e: any) => void; +jest.mock("../../../hooks", () => ({ + useFocusedInputHandler: jest.fn((h: any) => { + selectionHandler = h.onSelectionChange; + }), + useReanimatedFocusedInput: jest.fn(() => ({ + input: mockInput, + update: jest.fn().mockResolvedValue(undefined), + })), + useWindowDimensions: jest.fn(() => ({ height: SCREEN_HEIGHT })), +})); + +jest.mock("../../hooks/useScrollState", () => ({ + __esModule: true, + default: jest.fn(() => ({ + offset: mockOffset, + layout: mockLayout, + size: mockSize, + })), +})); + +jest.mock("../../hooks/useCombinedRef", () => ({ + __esModule: true, + default: jest.fn(() => jest.fn()), +})); + +jest.mock("../../../utils/findNodeHandle", () => ({ + findNodeHandle: jest.fn(() => SV_TARGET), +})); + +jest.mock("../../../bindings", () => ({ + KeyboardControllerNative: { + viewPositionInWindow: jest.fn().mockResolvedValue({ y: 0 }), + }, +})); + +jest.mock("../../ScrollViewWithBottomPadding", () => { + const { forwardRef } = require("react"); + const RN = require("react-native"); + + return { + __esModule: true, + default: forwardRef((props: any, ref: any) => { + capturedOnLayout = props.onLayout; + + return {props.children}; + }), + }; +}); + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function inputEvent(overrides: Record = {}) { + return { + target: INPUT_TARGET, + parentScrollViewTarget: SV_TARGET, + layout: { ...INPUT_LAYOUT }, + ...overrides, + }; +} + +function selectionEvent( + target = INPUT_TARGET, + endY = 123, + endPosition = 338, +) { + return { + target, + selection: { + start: { x: 0, y: endY, position: endPosition }, + end: { x: 0, y: endY, position: endPosition }, + }, + }; +} + +function kbEvent(height: number, target = INPUT_TARGET) { + return { + height, + target, + duration: 250, + progress: KEYBOARD_HEIGHT > 0 ? height / KEYBOARD_HEIGHT : 0, + }; +} + +function resetMocks() { + mockScrollTo.mockClear(); + mockOffset.value = 0; + mockLayout.value = { width: 390, height: 753 }; + mockSize.value = { width: 390, height: 2000 }; + mockInput.value = null; + capturedOnLayout = null; +} + +/** + * Render the component and trigger onLayout so that `scrollViewTarget` is set. + */ +async function renderKASV() { + const KeyboardAwareScrollView = require("../index").default; + + render( + + + , + ); + + // Trigger onLayout to set scrollViewTarget via findNodeHandle + await act(async () => { + capturedOnLayout?.({ + nativeEvent: { layout: { x: 0, y: 0, width: 390, height: 753 } }, + }); + }); +} + +/** Return the `y` argument of the most recent `scrollTo` call, or undefined. */ +function lastScrollToY(): number | undefined { + const calls = mockScrollTo.mock.calls; + + if (calls.length === 0) { + return undefined; + } + + // scrollTo(ref, x, y, animated) + return calls[calls.length - 1][2]; +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +beforeEach(() => { + resetMocks(); +}); + +describe("KeyboardAwareScrollView scroll behavior", () => { + describe("iOS 16+: onSelectionChange → onStart → onMove → onEnd", () => { + it("should scroll to correct position using selection-corrected height", async () => { + await renderKASV(); + mockInput.value = inputEvent(); + + // 1. Selection arrives BEFORE onStart (iOS 16+ behavior) + selectionHandler(selectionEvent()); + + // 2. Keyboard starts showing + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); + + // 3. Simulate final onMove frame at full keyboard height + mockScrollTo.mockClear(); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + + // point = 541 + 123 = 664 + // relativeScrollTo = 291 - (844 - 664) + 0 = 111 + // targetScrollY = 111 + 0 = 111 + expect(lastScrollToY()).toBeCloseTo(111, 0); + + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + }); + }); + + describe("iOS 15: onStart → onSelectionChange → onMove → onEnd", () => { + it("should use deferred selection and scroll correctly", async () => { + await renderKASV(); + mockInput.value = inputEvent(); + + // 1. Keyboard starts BEFORE selection (iOS 15 behavior) + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); + + // 2. Selection arrives after onStart + selectionHandler(selectionEvent()); + + // 3. Final onMove frame + mockScrollTo.mockClear(); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + + // Must use selection-corrected height (123), not full input (360) + expect(lastScrollToY()).toBeCloseTo(111, 0); + + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + }); + }); + + describe("iOS 15: refocus same input after keyboard hide", () => { + it("should use fresh selection data on refocus", async () => { + await renderKASV(); + mockInput.value = inputEvent(); + + // ---- First focus (cursor at y=123) ---- + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); + selectionHandler(selectionEvent(INPUT_TARGET, 123, 338)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + mockOffset.value = 111; + + // ---- Keyboard hide ---- + keyboardHandlers.onStart(kbEvent(0)); + keyboardHandlers.onEnd(kbEvent(0)); + mockOffset.value = 0; + + // ---- Second focus (cursor moved to y=273) ---- + mockScrollTo.mockClear(); + + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); + // Fresh selection arrives with a different cursor position + selectionHandler(selectionEvent(INPUT_TARGET, 273, 736)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + + // Should use fresh selection height (273), not stale (123) + // point = 541 + 273 = 814 + // relativeScrollTo = 291 - (844 - 814) + 0 = 261 + // targetScrollY = 261 + 0 = 261 + expect(lastScrollToY()).toBeCloseTo(261, 0); + + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + }); + }); + + describe("Android: refocus same input, selection not re-emitted", () => { + it("should use stale selection as fallback when no new selection arrives", async () => { + await renderKASV(); + mockInput.value = inputEvent(); + + // ---- First focus (selection arrives) ---- + selectionHandler(selectionEvent(INPUT_TARGET, 123, 338)); + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + mockOffset.value = 111; + + // ---- Keyboard hide ---- + keyboardHandlers.onStart(kbEvent(0)); + keyboardHandlers.onEnd(kbEvent(0)); + mockOffset.value = 0; + + // ---- Second focus (NO selection event — Android behavior) ---- + mockScrollTo.mockClear(); + + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); + // No selectionHandler call — Android doesn't re-emit for same cursor + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + + // Must use stale selection (height=123), NOT full input height (360) + // With full input: point = 541+360 = 901, scroll = 348 (WRONG) + // With stale selection: point = 541+123 = 664, scroll = 111 (CORRECT) + expect(lastScrollToY()).toBeCloseTo(111, 0); + + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + }); + }); +}); From bbd621c623c6a901671bd9f2a4b230e51ff37b35 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 16:38:33 +0100 Subject: [PATCH 3/9] fix: Android + toolbar behavior --- src/components/KeyboardAwareScrollView/index.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index fb25df4d55..13fb8e0e39 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -561,9 +561,12 @@ const KeyboardAwareScrollView = forwardRef< if (e.height === 0) { selectionUpdatedSinceHide.value = false; - } else { - // keyboard fully shown — clear pending flag to prevent leaking - // into next focus-change session + } else if (keyboardWillAppear.value) { + // keyboard fully shown after appearing from hidden state — clear + // pending flag to prevent leaking into next focus-change session. + // Only when the keyboard was actually appearing (not a focus switch + // with same keyboard height), otherwise we'd clear the flag before + // onSelectionChange has a chance to process it. pendingSelectionForFocus.value = false; } From efd18389086864b438b9ce93c5ec2067b8c284ef Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 16:40:48 +0100 Subject: [PATCH 4/9] fix: update unit tests --- .../__tests__/scrollBehavior.spec.tsx | 218 ++++++++++-------- 1 file changed, 126 insertions(+), 92 deletions(-) diff --git a/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx b/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx index 5ede1871ff..e101834539 100644 --- a/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx +++ b/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx @@ -5,18 +5,29 @@ import { render, act } from "@testing-library/react-native"; // --------------------------------------------------------------------------- // Constants (derived from real device logs) // --------------------------------------------------------------------------- -const SCREEN_HEIGHT = 844; -const KEYBOARD_HEIGHT = 291; -const SV_TARGET = 769; // scrollView native handle -const INPUT_TARGET = 765; // TextInput native handle - -const INPUT_LAYOUT = { - absoluteY: 541, - height: 360, - y: 450, +const SCREEN_HEIGHT = 928; +const KEYBOARD_HEIGHT = 312; +const BOTTOM_OFFSET = 62; +const SV_TARGET = 1469; // scrollView native handle +const INPUT_TARGET_A = 1373; // first TextInput native handle +const INPUT_TARGET_B = 1395; // second TextInput native handle + +const INPUT_LAYOUT_A = { + absoluteY: 281.67, + height: 60.33, + y: 165.67, x: 0, - absoluteX: 0, - width: 390, + absoluteX: 16, + width: 394.67, +}; + +const INPUT_LAYOUT_B = { + absoluteY: 695.67, + height: 60.33, + y: 579.67, + x: 0, + absoluteX: 16, + width: 121, }; // --------------------------------------------------------------------------- @@ -26,7 +37,7 @@ const mockScrollTo = jest.fn(); const mockInput: { value: any } = { value: null }; const mockOffset: { value: number } = { value: 0 }; const mockLayout: { value: { width: number; height: number } } = { - value: { width: 390, height: 753 }, + value: { width: 390, height: 812 }, }; const mockSize: { value: { width: number; height: number } } = { value: { width: 390, height: 2000 }, @@ -127,19 +138,21 @@ jest.mock("../../ScrollViewWithBottomPadding", () => { // Helpers // --------------------------------------------------------------------------- -function inputEvent(overrides: Record = {}) { +function inputEvent( + target: number, + layout: typeof INPUT_LAYOUT_A, +) { return { - target: INPUT_TARGET, + target, parentScrollViewTarget: SV_TARGET, - layout: { ...INPUT_LAYOUT }, - ...overrides, + layout: { ...layout }, }; } function selectionEvent( - target = INPUT_TARGET, - endY = 123, - endPosition = 338, + target: number, + endY = 47, + endPosition = 0, ) { return { target, @@ -150,11 +163,11 @@ function selectionEvent( }; } -function kbEvent(height: number, target = INPUT_TARGET) { +function kbEvent(height: number, target: number) { return { height, target, - duration: 250, + duration: height > 0 ? 285 : 285, progress: KEYBOARD_HEIGHT > 0 ? height / KEYBOARD_HEIGHT : 0, }; } @@ -162,33 +175,28 @@ function kbEvent(height: number, target = INPUT_TARGET) { function resetMocks() { mockScrollTo.mockClear(); mockOffset.value = 0; - mockLayout.value = { width: 390, height: 753 }; + mockLayout.value = { width: 390, height: 812 }; mockSize.value = { width: 390, height: 2000 }; mockInput.value = null; capturedOnLayout = null; } -/** - * Render the component and trigger onLayout so that `scrollViewTarget` is set. - */ -async function renderKASV() { +async function renderKASV(bottomOffset = BOTTOM_OFFSET) { const KeyboardAwareScrollView = require("../index").default; render( - + , ); - // Trigger onLayout to set scrollViewTarget via findNodeHandle await act(async () => { capturedOnLayout?.({ - nativeEvent: { layout: { x: 0, y: 0, width: 390, height: 753 } }, + nativeEvent: { layout: { x: 0, y: 0, width: 390, height: 812 } }, }); }); } -/** Return the `y` argument of the most recent `scrollTo` call, or undefined. */ function lastScrollToY(): number | undefined { const calls = mockScrollTo.mock.calls; @@ -196,7 +204,6 @@ function lastScrollToY(): number | undefined { return undefined; } - // scrollTo(ref, x, y, animated) return calls[calls.length - 1][2]; } @@ -209,117 +216,144 @@ beforeEach(() => { }); describe("KeyboardAwareScrollView scroll behavior", () => { + // First input (A) is above the keyboard — no scroll needed. + // Second input (B) is below the keyboard — scroll IS needed. + // + // visibleRect = 928 - 312 = 616 + // + // Input A: point = 281.67 + 47 = 328.67 + // visibleRect - point = 287.33 > bottomOffset(62) → NO scroll + // + // Input B: point = 695.67 + 47 = 742.67 + // visibleRect - point = -126.67 <= bottomOffset(62) → SCROLL + // relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67 + // targetScrollY = 188.67 + scrollPosition + describe("iOS 16+: onSelectionChange → onStart → onMove → onEnd", () => { - it("should scroll to correct position using selection-corrected height", async () => { + it("should not scroll when input is already visible", async () => { await renderKASV(); - mockInput.value = inputEvent(); + mockInput.value = inputEvent(INPUT_TARGET_A, INPUT_LAYOUT_A); - // 1. Selection arrives BEFORE onStart (iOS 16+ behavior) - selectionHandler(selectionEvent()); + selectionHandler(selectionEvent(INPUT_TARGET_A)); + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - // 2. Keyboard starts showing - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); - - // 3. Simulate final onMove frame at full keyboard height mockScrollTo.mockClear(); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - // point = 541 + 123 = 664 - // relativeScrollTo = 291 - (844 - 664) + 0 = 111 - // targetScrollY = 111 + 0 = 111 - expect(lastScrollToY()).toBeCloseTo(111, 0); + // Input A is visible — no scroll needed + expect(mockScrollTo).not.toHaveBeenCalled(); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); }); }); describe("iOS 15: onStart → onSelectionChange → onMove → onEnd", () => { it("should use deferred selection and scroll correctly", async () => { await renderKASV(); - mockInput.value = inputEvent(); - - // 1. Keyboard starts BEFORE selection (iOS 15 behavior) - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); - // 2. Selection arrives after onStart - selectionHandler(selectionEvent()); + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + selectionHandler(selectionEvent(INPUT_TARGET_B)); - // 3. Final onMove frame mockScrollTo.mockClear(); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - // Must use selection-corrected height (123), not full input (360) - expect(lastScrollToY()).toBeCloseTo(111, 0); + // Must use selection height (47), not full input (60.33) + expect(lastScrollToY()).toBeCloseTo(188.67, 0); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); }); }); describe("iOS 15: refocus same input after keyboard hide", () => { it("should use fresh selection data on refocus", async () => { await renderKASV(); - mockInput.value = inputEvent(); + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); - // ---- First focus (cursor at y=123) ---- - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); - selectionHandler(selectionEvent(INPUT_TARGET, 123, 338)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); - mockOffset.value = 111; + // ---- First focus (cursor at y=47) ---- + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + selectionHandler(selectionEvent(INPUT_TARGET_B, 47, 0)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + mockOffset.value = 189; // ---- Keyboard hide ---- - keyboardHandlers.onStart(kbEvent(0)); - keyboardHandlers.onEnd(kbEvent(0)); + keyboardHandlers.onStart(kbEvent(0, INPUT_TARGET_B)); + keyboardHandlers.onEnd(kbEvent(0, INPUT_TARGET_B)); mockOffset.value = 0; - // ---- Second focus (cursor moved to y=273) ---- + // ---- Second focus (cursor at y=20 — different position) ---- mockScrollTo.mockClear(); - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); - // Fresh selection arrives with a different cursor position - selectionHandler(selectionEvent(INPUT_TARGET, 273, 736)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + selectionHandler(selectionEvent(INPUT_TARGET_B, 20, 5)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - // Should use fresh selection height (273), not stale (123) - // point = 541 + 273 = 814 - // relativeScrollTo = 291 - (844 - 814) + 0 = 261 - // targetScrollY = 261 + 0 = 261 - expect(lastScrollToY()).toBeCloseTo(261, 0); + // point = 695.67 + 20 = 715.67 + // relativeScrollTo = 312 - (928 - 715.67) + 62 = 161.67 + expect(lastScrollToY()).toBeCloseTo(161.67, 0); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); }); }); describe("Android: refocus same input, selection not re-emitted", () => { it("should use stale selection as fallback when no new selection arrives", async () => { await renderKASV(); - mockInput.value = inputEvent(); + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); // ---- First focus (selection arrives) ---- - selectionHandler(selectionEvent(INPUT_TARGET, 123, 338)); - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); - mockOffset.value = 111; + selectionHandler(selectionEvent(INPUT_TARGET_B, 47, 0)); + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + mockOffset.value = 189; // ---- Keyboard hide ---- - keyboardHandlers.onStart(kbEvent(0)); - keyboardHandlers.onEnd(kbEvent(0)); + keyboardHandlers.onStart(kbEvent(0, INPUT_TARGET_B)); + keyboardHandlers.onEnd(kbEvent(0, INPUT_TARGET_B)); mockOffset.value = 0; // ---- Second focus (NO selection event — Android behavior) ---- mockScrollTo.mockClear(); - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT)); - // No selectionHandler call — Android doesn't re-emit for same cursor - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT)); + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + + // Must use stale selection (height=47), NOT full input height (60.33) + expect(lastScrollToY()).toBeCloseTo(188.67, 0); + + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + }); + }); + + describe("Toolbar: focus switch while keyboard is visible", () => { + it("should scroll to newly focused input via deferred selection", async () => { + await renderKASV(); + mockInput.value = inputEvent(INPUT_TARGET_A, INPUT_LAYOUT_A); + + // ---- Focus input A — keyboard opens, no scroll needed ---- + selectionHandler(selectionEvent(INPUT_TARGET_A)); + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); + keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); + + // ---- Toolbar moves focus to input B (keyboard stays visible) ---- + // Native sends onStart → onEnd (no animation, duration=0) + // then onSelectionChange arrives + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); + mockScrollTo.mockClear(); + + keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); + keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - // Must use stale selection (height=123), NOT full input height (360) - // With full input: point = 541+360 = 901, scroll = 348 (WRONG) - // With stale selection: point = 541+123 = 664, scroll = 111 (CORRECT) - expect(lastScrollToY()).toBeCloseTo(111, 0); + // Selection arrives AFTER onEnd — the pendingSelectionForFocus flag + // must survive onEnd so the deferred scroll runs here + selectionHandler(selectionEvent(INPUT_TARGET_B, 47, 0)); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT)); + // point = 695.67 + 47 = 742.67 + // relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67 + expect(lastScrollToY()).toBeCloseTo(188.67, 0); }); }); }); From f175be9fa31e7006021d7e23255ef8998ecc28e5 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 17:59:58 +0100 Subject: [PATCH 5/9] refactor: move types into separate file --- .../KeyboardAwareScrollView/index.tsx | 206 ++++++++++++------ .../KeyboardAwareScrollView/types.ts | 18 ++ src/components/index.ts | 2 +- 3 files changed, 162 insertions(+), 64 deletions(-) create mode 100644 src/components/KeyboardAwareScrollView/types.ts diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index 13fb8e0e39..8c74bba7f6 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -30,34 +30,15 @@ import ScrollViewWithBottomPadding from "../ScrollViewWithBottomPadding"; import { useSmoothKeyboardHandler } from "./useSmoothKeyboardHandler"; import { debounce, scrollDistanceWithRespectToSnapPoints } from "./utils"; -import type { AnimatedScrollViewComponent } from "../ScrollViewWithBottomPadding"; -import type { - LayoutChangeEvent, - ScrollView, - ScrollViewProps, -} from "react-native"; +import type { LayoutChangeEvent, ScrollView } from "react-native"; import type { FocusedInputLayoutChangedEvent, FocusedInputSelectionChangedEvent, + KeyboardAwareScrollViewProps, + KeyboardAwareScrollViewRef, NativeEvent, } from "react-native-keyboard-controller"; -export type KeyboardAwareScrollViewProps = { - /** The distance between the keyboard and the caret inside a focused `TextInput` when the keyboard is shown. Default is `0`. */ - bottomOffset?: number; - /** Prevents automatic scrolling of the `ScrollView` when the keyboard gets hidden, maintaining the current screen position. Default is `false`. */ - disableScrollOnKeyboardHide?: boolean; - /** Controls whether this `KeyboardAwareScrollView` instance should take effect. Default is `true`. */ - enabled?: boolean; - /** Adjusting the bottom spacing of KeyboardAwareScrollView. Default is `0`. */ - extraKeyboardSpace?: number; - /** Custom component for `ScrollView`. Default is `ScrollView`. */ - ScrollViewComponent?: AnimatedScrollViewComponent; -} & ScrollViewProps; -export type KeyboardAwareScrollViewRef = { - assureFocusedInputVisible: () => void; -} & ScrollView; - // Everything begins from `onStart` handler. This handler is called every time, // when keyboard changes its size or when focused `TextInput` was changed. In // this handler we are calculating/memoizing values which later will be used @@ -186,19 +167,29 @@ const KeyboardAwareScrollView = forwardRef< (e: number, animated: boolean = false) => { "worklet"; - console.log(`[KASV::maybeScroll] called with e=${e}, animated=${animated}`); - console.log(`[KASV::maybeScroll] enabled=${enabled}, scrollPosition=${scrollPosition.value}, position=${position.value}`); - console.log(`[KASV::maybeScroll] keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`); - console.log(`[KASV::maybeScroll] layout.parentScrollViewTarget=${layout.value?.parentScrollViewTarget}, scrollViewTarget=${scrollViewTarget.value}`); + console.log( + `[KASV::maybeScroll] called with e=${e}, animated=${animated}`, + ); + console.log( + `[KASV::maybeScroll] enabled=${enabled}, scrollPosition=${scrollPosition.value}, position=${position.value}`, + ); + console.log( + `[KASV::maybeScroll] keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`, + ); + console.log( + `[KASV::maybeScroll] layout.parentScrollViewTarget=${layout.value?.parentScrollViewTarget}, scrollViewTarget=${scrollViewTarget.value}`, + ); if (!enabled) { console.log("[KASV::maybeScroll] BAIL: not enabled"); + return 0; } // input belongs to ScrollView if (layout.value?.parentScrollViewTarget !== scrollViewTarget.value) { console.log("[KASV::maybeScroll] BAIL: input not in this ScrollView"); + return 0; } @@ -207,8 +198,16 @@ const KeyboardAwareScrollView = forwardRef< const inputHeight = layout.value?.layout.height || 0; const point = absoluteY + inputHeight; - console.log(`[KASV::maybeScroll] height=${height}, visibleRect=${visibleRect}, absoluteY=${absoluteY}, inputHeight=${inputHeight}, point=${point}, bottomOffset=${bottomOffset}`); - console.log(`[KASV::maybeScroll] condition: visibleRect - point (${visibleRect - point}) <= bottomOffset (${bottomOffset}) => ${visibleRect - point <= bottomOffset}`); + console.log( + `[KASV::maybeScroll] height=${height}, visibleRect=${visibleRect}, absoluteY=${absoluteY}, inputHeight=${inputHeight}, point=${point}, bottomOffset=${bottomOffset}`, + ); + console.log( + `[KASV::maybeScroll] condition: visibleRect - point (${ + visibleRect - point + }) <= bottomOffset (${bottomOffset}) => ${ + visibleRect - point <= bottomOffset + }`, + ); if (visibleRect - point <= bottomOffset) { const relativeScrollTo = @@ -227,7 +226,9 @@ const KeyboardAwareScrollView = forwardRef< const targetScrollY = Math.max(interpolatedScrollTo, 0) + scrollPosition.value; - console.log(`[KASV::maybeScroll] SCROLL DOWN: relativeScrollTo=${relativeScrollTo}, interpolatedScrollTo=${interpolatedScrollTo}, targetScrollY=${targetScrollY}`); + console.log( + `[KASV::maybeScroll] SCROLL DOWN: relativeScrollTo=${relativeScrollTo}, interpolatedScrollTo=${interpolatedScrollTo}, targetScrollY=${targetScrollY}`, + ); scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated); @@ -238,7 +239,11 @@ const KeyboardAwareScrollView = forwardRef< const positionOnScreen = visibleRect - bottomOffset; const topOfScreen = scrollPosition.value + point; - console.log(`[KASV::maybeScroll] SCROLL UP: point=${point} < scrollViewPageY=${scrollViewPageY.value}, scrollTo=${topOfScreen - positionOnScreen}`); + console.log( + `[KASV::maybeScroll] SCROLL UP: point=${point} < scrollViewPageY=${ + scrollViewPageY.value + }, scrollTo=${topOfScreen - positionOnScreen}`, + ); scrollTo( scrollViewAnimatedRef, @@ -284,7 +289,9 @@ const KeyboardAwareScrollView = forwardRef< const prevScroll = scrollPosition.value; - console.log(`[KASV::performScrollWithPositionRestoration] newPosition=${newPosition}, prevScroll=${prevScroll}, keyboardHeight=${keyboardHeight.value}`); + console.log( + `[KASV::performScrollWithPositionRestoration] newPosition=${newPosition}, prevScroll=${prevScroll}, keyboardHeight=${keyboardHeight.value}`, + ); // eslint-disable-next-line react-compiler/react-compiler scrollPosition.value = newPosition; @@ -357,31 +364,49 @@ const KeyboardAwareScrollView = forwardRef< const lastTarget = lastSelection.value?.target; const latestSelection = lastSelection.value?.selection; - console.log(`[KASV::onSelectionChange] target=${e.target}, lastTarget=${lastTarget}, pendingSelectionForFocus=${pendingSelectionForFocus.value}`); - console.log(`[KASV::onSelectionChange] selection.end.y=${e.selection.end.y}, selection.end.position=${e.selection.end.position}`); + console.log( + `[KASV::onSelectionChange] target=${e.target}, lastTarget=${lastTarget}, pendingSelectionForFocus=${pendingSelectionForFocus.value}`, + ); + console.log( + `[KASV::onSelectionChange] selection.end.y=${e.selection.end.y}, selection.end.position=${e.selection.end.position}`, + ); lastSelection.value = e; selectionUpdatedSinceHide.value = true; if (e.target !== lastTarget || pendingSelectionForFocus.value) { - console.log(`[KASV::onSelectionChange] target changed or pending focus! (targetChanged=${e.target !== lastTarget}, pending=${pendingSelectionForFocus.value})`); + console.log( + `[KASV::onSelectionChange] target changed or pending focus! (targetChanged=${ + e.target !== lastTarget + }, pending=${pendingSelectionForFocus.value})`, + ); if (pendingSelectionForFocus.value) { // selection arrived after onStart - complete the deferred setup pendingSelectionForFocus.value = false; updateLayoutFromSelection(); - console.log(`[KASV::onSelectionChange] deferred setup complete. layout=${JSON.stringify(layout.value?.layout)}`); - console.log(`[KASV::onSelectionChange] keyboardWillAppear=${keyboardWillAppear.value}, keyboardHeight=${keyboardHeight.value}`); + console.log( + `[KASV::onSelectionChange] deferred setup complete. layout=${JSON.stringify( + layout.value?.layout, + )}`, + ); + console.log( + `[KASV::onSelectionChange] keyboardWillAppear=${keyboardWillAppear.value}, keyboardHeight=${keyboardHeight.value}`, + ); // if keyboard was already visible (focus change, no onMove expected), // perform the deferred scroll now if (!keyboardWillAppear.value && keyboardHeight.value > 0) { const scrollDelta = maybeScroll(keyboardHeight.value, true); - console.log(`[KASV::onSelectionChange] deferred scroll: scrollDelta=${scrollDelta}, position before=${position.value}`); + console.log( + `[KASV::onSelectionChange] deferred scroll: scrollDelta=${scrollDelta}, position before=${position.value}`, + ); position.value += scrollDelta; - console.log(`[KASV::onSelectionChange] position after=${position.value}`); + console.log( + `[KASV::onSelectionChange] position after=${position.value}`, + ); } } @@ -393,13 +418,17 @@ const KeyboardAwareScrollView = forwardRef< e.selection.end.position === e.selection.start.position && latestSelection?.end.y !== e.selection.end.y ) { - console.log("[KASV::onSelectionChange] new line detected, scrollFromCurrentPosition"); + console.log( + "[KASV::onSelectionChange] new line detected, scrollFromCurrentPosition", + ); return scrollFromCurrentPosition(); } // selection has been changed if (e.selection.start.position !== e.selection.end.position) { - console.log("[KASV::onSelectionChange] selection range changed, scrollFromCurrentPosition"); + console.log( + "[KASV::onSelectionChange] selection range changed, scrollFromCurrentPosition", + ); return scrollFromCurrentPosition(); } @@ -427,10 +456,20 @@ const KeyboardAwareScrollView = forwardRef< "worklet"; console.log("=== [KASV::onStart] ==="); - console.log(`[KASV::onStart] e.height=${e.height}, e.target=${e.target}, e.duration=${e.duration}`); - console.log(`[KASV::onStart] BEFORE: keyboardHeight=${keyboardHeight.value}, tag=${tag.value}, position=${position.value}, scrollPosition=${scrollPosition.value}`); - console.log(`[KASV::onStart] BEFORE: initialKeyboardSize=${initialKeyboardSize.value}, scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`); - console.log(`[KASV::onStart] input.value?.layout=${JSON.stringify(input.value?.layout)}`); + console.log( + `[KASV::onStart] e.height=${e.height}, e.target=${e.target}, e.duration=${e.duration}`, + ); + console.log( + `[KASV::onStart] BEFORE: keyboardHeight=${keyboardHeight.value}, tag=${tag.value}, position=${position.value}, scrollPosition=${scrollPosition.value}`, + ); + console.log( + `[KASV::onStart] BEFORE: initialKeyboardSize=${initialKeyboardSize.value}, scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`, + ); + console.log( + `[KASV::onStart] input.value?.layout=${JSON.stringify( + input.value?.layout, + )}`, + ); const keyboardWillChangeSize = keyboardHeight.value !== e.height && e.height > 0; @@ -442,11 +481,15 @@ const KeyboardAwareScrollView = forwardRef< (tag.value !== e.target && e.target !== -1) || keyboardWillChangeSize; - console.log(`[KASV::onStart] keyboardWillAppear=${keyboardWillAppear.value}, keyboardWillHide=${keyboardWillHide}, keyboardWillChangeSize=${keyboardWillChangeSize}, focusWasChanged=${focusWasChanged}`); + console.log( + `[KASV::onStart] keyboardWillAppear=${keyboardWillAppear.value}, keyboardWillHide=${keyboardWillHide}, keyboardWillChangeSize=${keyboardWillChangeSize}, focusWasChanged=${focusWasChanged}`, + ); if (keyboardWillChangeSize) { initialKeyboardSize.value = keyboardHeight.value; - console.log(`[KASV::onStart] keyboardWillChangeSize -> initialKeyboardSize set to ${keyboardHeight.value}`); + console.log( + `[KASV::onStart] keyboardWillChangeSize -> initialKeyboardSize set to ${keyboardHeight.value}`, + ); } if (keyboardWillHide) { @@ -454,7 +497,9 @@ const KeyboardAwareScrollView = forwardRef< initialKeyboardSize.value = 0; scrollPosition.value = scrollBeforeKeyboardMovement.value; pendingSelectionForFocus.value = false; - console.log(`[KASV::onStart] keyboardWillHide -> scrollPosition reset to scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`); + console.log( + `[KASV::onStart] keyboardWillHide -> scrollPosition reset to scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`, + ); } if ( @@ -468,16 +513,23 @@ const KeyboardAwareScrollView = forwardRef< keyboardHeight.value = e.height; // and update keyboard spacer size syncKeyboardFrame(e); - console.log(`[KASV::onStart] persisted: scrollPosition=${position.value}, keyboardHeight=${e.height}`); + console.log( + `[KASV::onStart] persisted: scrollPosition=${position.value}, keyboardHeight=${e.height}`, + ); } // focus was changed if (focusWasChanged) { tag.value = e.target; - if (lastSelection.value?.target === e.target && selectionUpdatedSinceHide.value) { + if ( + lastSelection.value?.target === e.target && + selectionUpdatedSinceHide.value + ) { // fresh selection arrived before onStart - use it to update layout - console.log("[KASV::onStart] focusChanged: FRESH selection arrived BEFORE onStart, updating layout from selection"); + console.log( + "[KASV::onStart] focusChanged: FRESH selection arrived BEFORE onStart, updating layout from selection", + ); updateLayoutFromSelection(); pendingSelectionForFocus.value = false; } else { @@ -487,11 +539,15 @@ const KeyboardAwareScrollView = forwardRef< // otherwise fall back to full input layout. // Will be corrected if a fresh onSelectionChange arrives. if (lastSelection.value?.target === e.target) { - console.log(`[KASV::onStart] focusChanged: using STALE selection as fallback, selection.end.y=${lastSelection.value?.selection.end.y}`); + console.log( + `[KASV::onStart] focusChanged: using STALE selection as fallback, selection.end.y=${lastSelection.value?.selection.end.y}`, + ); updateLayoutFromSelection(); } else if (input.value) { layout.value = input.value; - console.log(`[KASV::onStart] focusChanged: no selection for target, using input layout: absoluteY=${input.value?.layout.absoluteY}, height=${input.value?.layout.height}`); + console.log( + `[KASV::onStart] focusChanged: no selection for target, using input layout: absoluteY=${input.value?.layout.absoluteY}, height=${input.value?.layout.height}`, + ); } pendingSelectionForFocus.value = true; } @@ -499,18 +555,24 @@ const KeyboardAwareScrollView = forwardRef< // save current scroll position - when keyboard will hide we'll reuse // this value to achieve smooth hide effect scrollBeforeKeyboardMovement.value = position.value; - console.log(`[KASV::onStart] focusChanged: scrollBeforeKeyboardMovement=${position.value}`); + console.log( + `[KASV::onStart] focusChanged: scrollBeforeKeyboardMovement=${position.value}`, + ); } if (focusWasChanged && !keyboardWillAppear.value) { - console.log(`[KASV::onStart] focusChanged + keyboard already visible, pendingSelection=${pendingSelectionForFocus.value}`); + console.log( + `[KASV::onStart] focusChanged + keyboard already visible, pendingSelection=${pendingSelectionForFocus.value}`, + ); if (!pendingSelectionForFocus.value) { // update position on scroll value, so `onEnd` handler // will pick up correct values const scrollDelta = maybeScroll(e.height, true); - console.log(`[KASV::onStart] maybeScroll returned scrollDelta=${scrollDelta}, position before=${position.value}`); + console.log( + `[KASV::onStart] maybeScroll returned scrollDelta=${scrollDelta}, position before=${position.value}`, + ); position.value += scrollDelta; console.log(`[KASV::onStart] position after=${position.value}`); } @@ -521,20 +583,32 @@ const KeyboardAwareScrollView = forwardRef< scrollViewLayout.value.height - scrollViewContentSize.value.height; - console.log(`[KASV::onStart] ghostViewSpace=${ghostViewSpace.value}, scrollViewLayout.height=${scrollViewLayout.value.height}, contentSize.height=${scrollViewContentSize.value.height}`); + console.log( + `[KASV::onStart] ghostViewSpace=${ghostViewSpace.value}, scrollViewLayout.height=${scrollViewLayout.value.height}, contentSize.height=${scrollViewContentSize.value.height}`, + ); if (ghostViewSpace.value > 0) { scrollPosition.value = position.value; - console.log(`[KASV::onStart] ghostViewSpace > 0 -> scrollPosition=${position.value}`); + console.log( + `[KASV::onStart] ghostViewSpace > 0 -> scrollPosition=${position.value}`, + ); } - console.log(`[KASV::onStart] FINAL: scrollPosition=${scrollPosition.value}, position=${position.value}, keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`); - console.log(`[KASV::onStart] FINAL: layout=${JSON.stringify(layout.value?.layout)}`); + console.log( + `[KASV::onStart] FINAL: scrollPosition=${scrollPosition.value}, position=${position.value}, keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`, + ); + console.log( + `[KASV::onStart] FINAL: layout=${JSON.stringify( + layout.value?.layout, + )}`, + ); }, onMove: (e) => { "worklet"; - console.log(`[KASV::onMove] e.height=${e.height}, e.progress=${e.progress}, position=${position.value}`); + console.log( + `[KASV::onMove] e.height=${e.height}, e.progress=${e.progress}, position=${position.value}`, + ); if (removeGhostPadding(e.height)) { console.log("[KASV::onMove] BAIL: removeGhostPadding handled it"); @@ -551,8 +625,12 @@ const KeyboardAwareScrollView = forwardRef< "worklet"; console.log("=== [KASV::onEnd] ==="); - console.log(`[KASV::onEnd] e.height=${e.height}, e.target=${e.target}`); - console.log(`[KASV::onEnd] BEFORE: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`); + console.log( + `[KASV::onEnd] e.height=${e.height}, e.target=${e.target}`, + ); + console.log( + `[KASV::onEnd] BEFORE: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`, + ); removeGhostPadding(e.height); @@ -572,7 +650,9 @@ const KeyboardAwareScrollView = forwardRef< syncKeyboardFrame(e); - console.log(`[KASV::onEnd] AFTER: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`); + console.log( + `[KASV::onEnd] AFTER: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`, + ); }, }, [ diff --git a/src/components/KeyboardAwareScrollView/types.ts b/src/components/KeyboardAwareScrollView/types.ts new file mode 100644 index 0000000000..7fe4faf950 --- /dev/null +++ b/src/components/KeyboardAwareScrollView/types.ts @@ -0,0 +1,18 @@ +import type { AnimatedScrollViewComponent } from "../ScrollViewWithBottomPadding"; +import type { ScrollView, ScrollViewProps } from "react-native"; + +export type KeyboardAwareScrollViewProps = { + /** The distance between the keyboard and the caret inside a focused `TextInput` when the keyboard is shown. Default is `0`. */ + bottomOffset?: number; + /** Prevents automatic scrolling of the `ScrollView` when the keyboard gets hidden, maintaining the current screen position. Default is `false`. */ + disableScrollOnKeyboardHide?: boolean; + /** Controls whether this `KeyboardAwareScrollView` instance should take effect. Default is `true`. */ + enabled?: boolean; + /** Adjusting the bottom spacing of KeyboardAwareScrollView. Default is `0`. */ + extraKeyboardSpace?: number; + /** Custom component for `ScrollView`. Default is `ScrollView`. */ + ScrollViewComponent?: AnimatedScrollViewComponent; +} & ScrollViewProps; +export type KeyboardAwareScrollViewRef = { + assureFocusedInputVisible: () => void; +} & ScrollView; diff --git a/src/components/index.ts b/src/components/index.ts index 3a05547bbe..8ba53b29c0 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -11,6 +11,6 @@ export type { KeyboardStickyViewProps } from "./KeyboardStickyView"; export type { KeyboardAwareScrollViewProps, KeyboardAwareScrollViewRef, -} from "./KeyboardAwareScrollView"; +} from "./KeyboardAwareScrollView/types"; export type { KeyboardToolbarProps } from "./KeyboardToolbar"; export type { KeyboardChatScrollViewProps } from "./KeyboardChatScrollView/types"; From 729140ba9c8412c3cbfd22065afb0e62c75b1a2b Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 18:16:55 +0100 Subject: [PATCH 6/9] tests: reorganize unit test setup --- .../__fixtures__/mocks.tsx | 113 ++++++ .../__fixtures__/testUtils.tsx | 151 ++++++++ .../__tests__/refocus.spec.tsx | 114 ++++++ .../__tests__/scrollBehavior.spec.tsx | 359 ------------------ .../__tests__/selectionOrder.spec.tsx | 84 ++++ .../__tests__/toolbar.spec.tsx | 63 +++ 6 files changed, 525 insertions(+), 359 deletions(-) create mode 100644 src/components/KeyboardAwareScrollView/__fixtures__/mocks.tsx create mode 100644 src/components/KeyboardAwareScrollView/__fixtures__/testUtils.tsx create mode 100644 src/components/KeyboardAwareScrollView/__tests__/refocus.spec.tsx delete mode 100644 src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx create mode 100644 src/components/KeyboardAwareScrollView/__tests__/selectionOrder.spec.tsx create mode 100644 src/components/KeyboardAwareScrollView/__tests__/toolbar.spec.tsx diff --git a/src/components/KeyboardAwareScrollView/__fixtures__/mocks.tsx b/src/components/KeyboardAwareScrollView/__fixtures__/mocks.tsx new file mode 100644 index 0000000000..1f1ca690ab --- /dev/null +++ b/src/components/KeyboardAwareScrollView/__fixtures__/mocks.tsx @@ -0,0 +1,113 @@ +/** + * Shared `jest.mock()` registrations for KeyboardAwareScrollView tests. + * + * Import this file in each test file. Because `jest.mock()` calls are hoisted + * by babel, they will always run before any other imports. + * + * @example import "../__fixtures__/mocks"; + */ + +// --------------------------------------------------------------------------- +// Inline constants & helpers — defined here to avoid a circular dependency +// chain (testUtils → useChatKeyboard/testUtils → reanimated → this factory). +// --------------------------------------------------------------------------- +const MOCK_SCREEN_H = 928; +const MOCK_SV = 1469; + +const mockInterpolateFn = ( + value: number, + input: number[], + output: number[], +): number => { + "worklet"; + + if (input[1] === 0) { + return 0; + } + + const progress = (value - input[0]) / (input[1] - input[0]); + + return output[0] + progress * (output[1] - output[0]); +}; + +const mockState = () => require("./testUtils"); + +// --------------------------------------------------------------------------- +// jest.mock registrations +// --------------------------------------------------------------------------- + +jest.mock("react-native-reanimated", () => ({ + ...require("react-native-reanimated/mock"), + scrollTo: (...args: unknown[]) => mockState().mockScrollTo(...args), + interpolate: mockInterpolateFn, + clamp: (value: number, min: number, max: number) => + Math.min(Math.max(value, min), max), +})); + +jest.mock("../useSmoothKeyboardHandler", () => ({ + useSmoothKeyboardHandler: jest.fn( + (h: { + onStart: (e: unknown) => void; + onMove: (e: unknown) => void; + onEnd: (e: unknown) => void; + }) => { + mockState().mockKeyboardHandlers.current = h; + }, + ), +})); + +jest.mock("../../../hooks", () => ({ + useFocusedInputHandler: jest.fn( + (h: { onSelectionChange: (...args: unknown[]) => void }) => { + mockState().mockSelectionHandler.current = h.onSelectionChange; + }, + ), + useReanimatedFocusedInput: jest.fn(() => ({ + input: mockState().mockInput, + update: jest.fn().mockResolvedValue(undefined), + })), + useWindowDimensions: jest.fn(() => ({ height: MOCK_SCREEN_H })), +})); + +jest.mock("../../hooks/useScrollState", () => ({ + __esModule: true, + default: jest.fn(() => ({ + offset: mockState().mockOffset, + layout: mockState().mockLayout, + size: mockState().mockSize, + })), +})); + +jest.mock("../../hooks/useCombinedRef", () => ({ + __esModule: true, + default: jest.fn(() => jest.fn()), +})); + +jest.mock("../../../utils/findNodeHandle", () => ({ + findNodeHandle: jest.fn(() => MOCK_SV), +})); + +jest.mock("../../../bindings", () => ({ + KeyboardControllerNative: { + viewPositionInWindow: jest.fn().mockResolvedValue({ y: 0 }), + }, +})); + +jest.mock("../../ScrollViewWithBottomPadding", () => { + const { forwardRef, createElement } = require("react"); + const { View: MockView } = require("react-native"); + + return { + __esModule: true, + default: forwardRef( + ( + props: { onLayout?: (e: never) => void; children?: unknown }, + ref: unknown, + ) => { + mockState().mockCapturedOnLayout.current = props.onLayout ?? null; + + return createElement(MockView, { ref }, props.children); + }, + ), + }; +}); diff --git a/src/components/KeyboardAwareScrollView/__fixtures__/testUtils.tsx b/src/components/KeyboardAwareScrollView/__fixtures__/testUtils.tsx new file mode 100644 index 0000000000..13e0dca5fb --- /dev/null +++ b/src/components/KeyboardAwareScrollView/__fixtures__/testUtils.tsx @@ -0,0 +1,151 @@ +import { act, render } from "@testing-library/react-native"; +import React from "react"; +import { View } from "react-native"; + +import type { LayoutChangeEvent } from "react-native"; +import type { + FocusedInputLayoutChangedEvent, + FocusedInputSelectionChangedEvent, + NativeEvent, +} from "react-native-keyboard-controller"; +import type { SharedValue } from "react-native-reanimated"; + +// --------------------------------------------------------------------------- +// Constants (derived from real device logs) +// --------------------------------------------------------------------------- +export const MOCK_SCREEN_HEIGHT = 928; +export const KEYBOARD_HEIGHT = 312; +export const BOTTOM_OFFSET = 62; +export const MOCK_SV_TARGET = 1469; +export const INPUT_TARGET_A = 1373; +export const INPUT_TARGET_B = 1395; + +export const INPUT_LAYOUT_A = { + absoluteY: 281.67, + height: 60.33, + y: 165.67, + x: 0, + absoluteX: 16, + width: 394.67, +}; + +export const INPUT_LAYOUT_B = { + absoluteY: 695.67, + height: 60.33, + y: 579.67, + x: 0, + absoluteX: 16, + width: 121, +}; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- +export type KeyboardHandlers = { + onStart: (e: NativeEvent) => void; + onMove: (e: NativeEvent) => void; + onEnd: (e: NativeEvent) => void; +}; + +export type SelectionHandler = (e: FocusedInputSelectionChangedEvent) => void; + +// --------------------------------------------------------------------------- +// Mock state +// --------------------------------------------------------------------------- +export const mockScrollTo = jest.fn(); +export const mockInput = { + value: null, +} as SharedValue; +export const mockOffset = { value: 0 } as SharedValue; +export const mockLayout = { + value: { width: 390, height: 812 }, +} as SharedValue<{ width: number; height: number }>; +export const mockSize = { + value: { width: 390, height: 2000 }, +} as SharedValue<{ width: number; height: number }>; + +// --------------------------------------------------------------------------- +// Captured handlers (populated on each render) +// --------------------------------------------------------------------------- +export const mockKeyboardHandlers: { current: KeyboardHandlers } = { + current: undefined as unknown as KeyboardHandlers, +}; +export const mockSelectionHandler: { current: SelectionHandler } = { + current: undefined as unknown as SelectionHandler, +}; +export const mockCapturedOnLayout: { + current: ((e: LayoutChangeEvent) => void) | null; +} = { current: null }; + +// --------------------------------------------------------------------------- +// Re-export shared utilities +// --------------------------------------------------------------------------- + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- +export const reset = () => { + mockScrollTo.mockClear(); + mockOffset.value = 0; + mockLayout.value = { width: 390, height: 812 }; + mockSize.value = { width: 390, height: 2000 }; + mockInput.value = null; + mockCapturedOnLayout.current = null; +}; + +export const inputEvent = ( + target: number, + layout: typeof INPUT_LAYOUT_A, +): FocusedInputLayoutChangedEvent => ({ + target, + parentScrollViewTarget: MOCK_SV_TARGET, + layout: { ...layout }, +}); + +export const selectionEvent = ( + target: number, + endY = 47, + endPosition = 0, +): FocusedInputSelectionChangedEvent => ({ + target, + selection: { + start: { x: 0, y: endY, position: endPosition }, + end: { x: 0, y: endY, position: endPosition }, + }, +}); + +export const kbEvent = (height: number, target: number): NativeEvent => ({ + height, + target, + duration: 285, + progress: KEYBOARD_HEIGHT > 0 ? height / KEYBOARD_HEIGHT : 0, +}); + +export const renderKeyboardAwareScrollView = async ( + bottomOffset = BOTTOM_OFFSET, +) => { + const KeyboardAwareScrollView = require("../index").default; + + render( + + + , + ); + + await act(async () => { + mockCapturedOnLayout.current?.({ + nativeEvent: { layout: { x: 0, y: 0, width: 390, height: 812 } }, + } as LayoutChangeEvent); + }); +}; + +export const lastScrollToY = (): number | undefined => { + const calls = mockScrollTo.mock.calls; + + if (calls.length === 0) { + return undefined; + } + + // scrollTo(ref, x, y, animated) + return calls[calls.length - 1][2] as number; +}; diff --git a/src/components/KeyboardAwareScrollView/__tests__/refocus.spec.tsx b/src/components/KeyboardAwareScrollView/__tests__/refocus.spec.tsx new file mode 100644 index 0000000000..b405161635 --- /dev/null +++ b/src/components/KeyboardAwareScrollView/__tests__/refocus.spec.tsx @@ -0,0 +1,114 @@ +import "../__fixtures__/mocks"; + +import { + INPUT_LAYOUT_B, + INPUT_TARGET_B, + KEYBOARD_HEIGHT, + inputEvent, + kbEvent, + lastScrollToY, + mockInput, + mockKeyboardHandlers, + mockOffset, + mockScrollTo, + mockSelectionHandler, + renderKeyboardAwareScrollView, + reset, + selectionEvent, +} from "../__fixtures__/testUtils"; + +beforeEach(() => { + reset(); +}); + +describe("KeyboardAwareScrollView — refocus same input", () => { + // Input B: point = 695.67 + 47 = 742.67 + // relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67 + + describe("iOS 15: refocus after keyboard hide", () => { + it("should use fresh selection data on refocus", async () => { + await renderKeyboardAwareScrollView(); + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); + + // ---- First focus (cursor at y=47) ---- + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B, 47, 0)); + mockKeyboardHandlers.current.onMove( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockOffset.value = 189; + + // ---- Keyboard hide ---- + mockKeyboardHandlers.current.onStart(kbEvent(0, INPUT_TARGET_B)); + mockKeyboardHandlers.current.onEnd(kbEvent(0, INPUT_TARGET_B)); + mockOffset.value = 0; + + // ---- Second focus (cursor moved to y=20) ---- + mockScrollTo.mockClear(); + + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B, 20, 5)); + mockKeyboardHandlers.current.onMove( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + + // point = 695.67 + 20 = 715.67 + // relativeScrollTo = 312 - (928 - 715.67) + 62 = 161.67 + expect(lastScrollToY()).toBeCloseTo(161.67, 0); + + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + }); + }); + + describe("Android: selection not re-emitted on refocus", () => { + it("should use stale selection as fallback", async () => { + await renderKeyboardAwareScrollView(); + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); + + // ---- First focus (selection arrives) ---- + mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B, 47, 0)); + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockKeyboardHandlers.current.onMove( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockOffset.value = 189; + + // ---- Keyboard hide ---- + mockKeyboardHandlers.current.onStart(kbEvent(0, INPUT_TARGET_B)); + mockKeyboardHandlers.current.onEnd(kbEvent(0, INPUT_TARGET_B)); + mockOffset.value = 0; + + // ---- Second focus (NO selection event — Android behavior) ---- + mockScrollTo.mockClear(); + + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + // No selectionHandler call — Android doesn't re-emit for same cursor + mockKeyboardHandlers.current.onMove( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + + // Must use stale selection (height=47), NOT full input height (60.33) + expect(lastScrollToY()).toBeCloseTo(188.67, 0); + + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + }); + }); +}); diff --git a/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx b/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx deleted file mode 100644 index e101834539..0000000000 --- a/src/components/KeyboardAwareScrollView/__tests__/scrollBehavior.spec.tsx +++ /dev/null @@ -1,359 +0,0 @@ -import React from "react"; -import { View } from "react-native"; -import { render, act } from "@testing-library/react-native"; - -// --------------------------------------------------------------------------- -// Constants (derived from real device logs) -// --------------------------------------------------------------------------- -const SCREEN_HEIGHT = 928; -const KEYBOARD_HEIGHT = 312; -const BOTTOM_OFFSET = 62; -const SV_TARGET = 1469; // scrollView native handle -const INPUT_TARGET_A = 1373; // first TextInput native handle -const INPUT_TARGET_B = 1395; // second TextInput native handle - -const INPUT_LAYOUT_A = { - absoluteY: 281.67, - height: 60.33, - y: 165.67, - x: 0, - absoluteX: 16, - width: 394.67, -}; - -const INPUT_LAYOUT_B = { - absoluteY: 695.67, - height: 60.33, - y: 579.67, - x: 0, - absoluteX: 16, - width: 121, -}; - -// --------------------------------------------------------------------------- -// Mocks – state -// --------------------------------------------------------------------------- -const mockScrollTo = jest.fn(); -const mockInput: { value: any } = { value: null }; -const mockOffset: { value: number } = { value: 0 }; -const mockLayout: { value: { width: number; height: number } } = { - value: { width: 390, height: 812 }, -}; -const mockSize: { value: { width: number; height: number } } = { - value: { width: 390, height: 2000 }, -}; - -let capturedOnLayout: ((e: any) => void) | null = null; - -// --------------------------------------------------------------------------- -// Mocks – modules -// --------------------------------------------------------------------------- - -function mockInterpolate( - value: number, - input: number[], - output: number[], -): number { - "worklet"; - - if (input[1] === input[0]) { - return output[0]; - } - - const progress = (value - input[0]) / (input[1] - input[0]); - - return output[0] + progress * (output[1] - output[0]); -} - -jest.mock("react-native-reanimated", () => ({ - ...require("react-native-reanimated/mock"), - scrollTo: (...args: any[]) => mockScrollTo(...args), - interpolate: mockInterpolate, - clamp: (value: number, min: number, max: number) => - Math.min(Math.max(value, min), max), -})); - -let keyboardHandlers: { - onStart: (e: any) => void; - onMove: (e: any) => void; - onEnd: (e: any) => void; -}; -jest.mock("../useSmoothKeyboardHandler", () => ({ - useSmoothKeyboardHandler: jest.fn((h: any) => { - keyboardHandlers = h; - }), -})); - -let selectionHandler: (e: any) => void; -jest.mock("../../../hooks", () => ({ - useFocusedInputHandler: jest.fn((h: any) => { - selectionHandler = h.onSelectionChange; - }), - useReanimatedFocusedInput: jest.fn(() => ({ - input: mockInput, - update: jest.fn().mockResolvedValue(undefined), - })), - useWindowDimensions: jest.fn(() => ({ height: SCREEN_HEIGHT })), -})); - -jest.mock("../../hooks/useScrollState", () => ({ - __esModule: true, - default: jest.fn(() => ({ - offset: mockOffset, - layout: mockLayout, - size: mockSize, - })), -})); - -jest.mock("../../hooks/useCombinedRef", () => ({ - __esModule: true, - default: jest.fn(() => jest.fn()), -})); - -jest.mock("../../../utils/findNodeHandle", () => ({ - findNodeHandle: jest.fn(() => SV_TARGET), -})); - -jest.mock("../../../bindings", () => ({ - KeyboardControllerNative: { - viewPositionInWindow: jest.fn().mockResolvedValue({ y: 0 }), - }, -})); - -jest.mock("../../ScrollViewWithBottomPadding", () => { - const { forwardRef } = require("react"); - const RN = require("react-native"); - - return { - __esModule: true, - default: forwardRef((props: any, ref: any) => { - capturedOnLayout = props.onLayout; - - return {props.children}; - }), - }; -}); - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function inputEvent( - target: number, - layout: typeof INPUT_LAYOUT_A, -) { - return { - target, - parentScrollViewTarget: SV_TARGET, - layout: { ...layout }, - }; -} - -function selectionEvent( - target: number, - endY = 47, - endPosition = 0, -) { - return { - target, - selection: { - start: { x: 0, y: endY, position: endPosition }, - end: { x: 0, y: endY, position: endPosition }, - }, - }; -} - -function kbEvent(height: number, target: number) { - return { - height, - target, - duration: height > 0 ? 285 : 285, - progress: KEYBOARD_HEIGHT > 0 ? height / KEYBOARD_HEIGHT : 0, - }; -} - -function resetMocks() { - mockScrollTo.mockClear(); - mockOffset.value = 0; - mockLayout.value = { width: 390, height: 812 }; - mockSize.value = { width: 390, height: 2000 }; - mockInput.value = null; - capturedOnLayout = null; -} - -async function renderKASV(bottomOffset = BOTTOM_OFFSET) { - const KeyboardAwareScrollView = require("../index").default; - - render( - - - , - ); - - await act(async () => { - capturedOnLayout?.({ - nativeEvent: { layout: { x: 0, y: 0, width: 390, height: 812 } }, - }); - }); -} - -function lastScrollToY(): number | undefined { - const calls = mockScrollTo.mock.calls; - - if (calls.length === 0) { - return undefined; - } - - return calls[calls.length - 1][2]; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -beforeEach(() => { - resetMocks(); -}); - -describe("KeyboardAwareScrollView scroll behavior", () => { - // First input (A) is above the keyboard — no scroll needed. - // Second input (B) is below the keyboard — scroll IS needed. - // - // visibleRect = 928 - 312 = 616 - // - // Input A: point = 281.67 + 47 = 328.67 - // visibleRect - point = 287.33 > bottomOffset(62) → NO scroll - // - // Input B: point = 695.67 + 47 = 742.67 - // visibleRect - point = -126.67 <= bottomOffset(62) → SCROLL - // relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67 - // targetScrollY = 188.67 + scrollPosition - - describe("iOS 16+: onSelectionChange → onStart → onMove → onEnd", () => { - it("should not scroll when input is already visible", async () => { - await renderKASV(); - mockInput.value = inputEvent(INPUT_TARGET_A, INPUT_LAYOUT_A); - - selectionHandler(selectionEvent(INPUT_TARGET_A)); - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - - mockScrollTo.mockClear(); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - - // Input A is visible — no scroll needed - expect(mockScrollTo).not.toHaveBeenCalled(); - - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - }); - }); - - describe("iOS 15: onStart → onSelectionChange → onMove → onEnd", () => { - it("should use deferred selection and scroll correctly", async () => { - await renderKASV(); - mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); - - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - selectionHandler(selectionEvent(INPUT_TARGET_B)); - - mockScrollTo.mockClear(); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - - // Must use selection height (47), not full input (60.33) - expect(lastScrollToY()).toBeCloseTo(188.67, 0); - - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - }); - }); - - describe("iOS 15: refocus same input after keyboard hide", () => { - it("should use fresh selection data on refocus", async () => { - await renderKASV(); - mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); - - // ---- First focus (cursor at y=47) ---- - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - selectionHandler(selectionEvent(INPUT_TARGET_B, 47, 0)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - mockOffset.value = 189; - - // ---- Keyboard hide ---- - keyboardHandlers.onStart(kbEvent(0, INPUT_TARGET_B)); - keyboardHandlers.onEnd(kbEvent(0, INPUT_TARGET_B)); - mockOffset.value = 0; - - // ---- Second focus (cursor at y=20 — different position) ---- - mockScrollTo.mockClear(); - - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - selectionHandler(selectionEvent(INPUT_TARGET_B, 20, 5)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - - // point = 695.67 + 20 = 715.67 - // relativeScrollTo = 312 - (928 - 715.67) + 62 = 161.67 - expect(lastScrollToY()).toBeCloseTo(161.67, 0); - - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - }); - }); - - describe("Android: refocus same input, selection not re-emitted", () => { - it("should use stale selection as fallback when no new selection arrives", async () => { - await renderKASV(); - mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); - - // ---- First focus (selection arrives) ---- - selectionHandler(selectionEvent(INPUT_TARGET_B, 47, 0)); - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - mockOffset.value = 189; - - // ---- Keyboard hide ---- - keyboardHandlers.onStart(kbEvent(0, INPUT_TARGET_B)); - keyboardHandlers.onEnd(kbEvent(0, INPUT_TARGET_B)); - mockOffset.value = 0; - - // ---- Second focus (NO selection event — Android behavior) ---- - mockScrollTo.mockClear(); - - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - - // Must use stale selection (height=47), NOT full input height (60.33) - expect(lastScrollToY()).toBeCloseTo(188.67, 0); - - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - }); - }); - - describe("Toolbar: focus switch while keyboard is visible", () => { - it("should scroll to newly focused input via deferred selection", async () => { - await renderKASV(); - mockInput.value = inputEvent(INPUT_TARGET_A, INPUT_LAYOUT_A); - - // ---- Focus input A — keyboard opens, no scroll needed ---- - selectionHandler(selectionEvent(INPUT_TARGET_A)); - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - keyboardHandlers.onMove(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A)); - - // ---- Toolbar moves focus to input B (keyboard stays visible) ---- - // Native sends onStart → onEnd (no animation, duration=0) - // then onSelectionChange arrives - mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); - mockScrollTo.mockClear(); - - keyboardHandlers.onStart(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - keyboardHandlers.onEnd(kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B)); - - // Selection arrives AFTER onEnd — the pendingSelectionForFocus flag - // must survive onEnd so the deferred scroll runs here - selectionHandler(selectionEvent(INPUT_TARGET_B, 47, 0)); - - // point = 695.67 + 47 = 742.67 - // relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67 - expect(lastScrollToY()).toBeCloseTo(188.67, 0); - }); - }); -}); diff --git a/src/components/KeyboardAwareScrollView/__tests__/selectionOrder.spec.tsx b/src/components/KeyboardAwareScrollView/__tests__/selectionOrder.spec.tsx new file mode 100644 index 0000000000..dff78b3250 --- /dev/null +++ b/src/components/KeyboardAwareScrollView/__tests__/selectionOrder.spec.tsx @@ -0,0 +1,84 @@ +import "../__fixtures__/mocks"; + +import { + INPUT_LAYOUT_A, + INPUT_LAYOUT_B, + INPUT_TARGET_A, + INPUT_TARGET_B, + KEYBOARD_HEIGHT, + inputEvent, + kbEvent, + lastScrollToY, + mockInput, + mockKeyboardHandlers, + mockScrollTo, + mockSelectionHandler, + renderKeyboardAwareScrollView, + reset, + selectionEvent, +} from "../__fixtures__/testUtils"; + +beforeEach(() => { + reset(); +}); + +describe("KeyboardAwareScrollView — selection order", () => { + // Input A: above the keyboard → no scroll needed + // Input B: below the keyboard → scroll needed + // + // visibleRect = 928 - 312 = 616 + // + // Input A: point = 281.67 + 47 = 328.67 + // visibleRect - point = 287.33 > bottomOffset(62) → NO scroll + // + // Input B: point = 695.67 + 47 = 742.67 + // visibleRect - point = -126.67 <= bottomOffset(62) → SCROLL + // relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67 + + describe("iOS 16+: onSelectionChange → onStart → onMove → onEnd", () => { + it("should not scroll when input is already visible", async () => { + await renderKeyboardAwareScrollView(); + mockInput.value = inputEvent(INPUT_TARGET_A, INPUT_LAYOUT_A); + + mockSelectionHandler.current(selectionEvent(INPUT_TARGET_A)); + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A), + ); + + mockScrollTo.mockClear(); + mockKeyboardHandlers.current.onMove( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A), + ); + + expect(mockScrollTo).not.toHaveBeenCalled(); + + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A), + ); + }); + }); + + describe("iOS 15: onStart → onSelectionChange → onMove → onEnd", () => { + it("should use deferred selection and scroll correctly", async () => { + await renderKeyboardAwareScrollView(); + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); + + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B)); + + mockScrollTo.mockClear(); + mockKeyboardHandlers.current.onMove( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + + // Must use selection height (47), not full input (60.33) + expect(lastScrollToY()).toBeCloseTo(188.67, 0); + + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + }); + }); +}); diff --git a/src/components/KeyboardAwareScrollView/__tests__/toolbar.spec.tsx b/src/components/KeyboardAwareScrollView/__tests__/toolbar.spec.tsx new file mode 100644 index 0000000000..52cbf19d66 --- /dev/null +++ b/src/components/KeyboardAwareScrollView/__tests__/toolbar.spec.tsx @@ -0,0 +1,63 @@ +import "../__fixtures__/mocks"; + +import { + INPUT_LAYOUT_A, + INPUT_LAYOUT_B, + INPUT_TARGET_A, + INPUT_TARGET_B, + KEYBOARD_HEIGHT, + inputEvent, + kbEvent, + lastScrollToY, + mockInput, + mockKeyboardHandlers, + mockScrollTo, + mockSelectionHandler, + renderKeyboardAwareScrollView, + reset, + selectionEvent, +} from "../__fixtures__/testUtils"; + +beforeEach(() => { + reset(); +}); + +describe("KeyboardAwareScrollView — toolbar focus switch", () => { + it("should scroll to newly focused input via deferred selection", async () => { + await renderKeyboardAwareScrollView(); + mockInput.value = inputEvent(INPUT_TARGET_A, INPUT_LAYOUT_A); + + // ---- Focus input A — keyboard opens, no scroll needed ---- + mockSelectionHandler.current(selectionEvent(INPUT_TARGET_A)); + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A), + ); + mockKeyboardHandlers.current.onMove( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A), + ); + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_A), + ); + + // ---- Toolbar moves focus to input B (keyboard stays visible) ---- + // Native sends onStart → onEnd (no animation, duration=0) + // then onSelectionChange arrives + mockInput.value = inputEvent(INPUT_TARGET_B, INPUT_LAYOUT_B); + mockScrollTo.mockClear(); + + mockKeyboardHandlers.current.onStart( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + mockKeyboardHandlers.current.onEnd( + kbEvent(KEYBOARD_HEIGHT, INPUT_TARGET_B), + ); + + // Selection arrives AFTER onEnd — the pendingSelectionForFocus flag + // must survive onEnd so the deferred scroll runs here + mockSelectionHandler.current(selectionEvent(INPUT_TARGET_B, 47, 0)); + + // point = 695.67 + 47 = 742.67 + // relativeScrollTo = 312 - (928 - 742.67) + 62 = 188.67 + expect(lastScrollToY()).toBeCloseTo(188.67, 0); + }); +}); From cdecce6c7c64e36502181a573611198a7665b0c9 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 18:58:43 +0100 Subject: [PATCH 7/9] fix: build settings --- tsconfig.build.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.build.json b/tsconfig.build.json index b5b22b4aa5..4725ef9974 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,4 @@ { "extends": "./tsconfig", - "exclude": ["example", "FabricExample", "docs", "e2e"] + "exclude": ["example", "FabricExample", "docs", "e2e", "**/__tests__", "**/__fixtures__"] } From 53959d8f9500b88d5facda2e527f3ecaaa2dcb01 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 19:05:19 +0100 Subject: [PATCH 8/9] fix: update prettier --- .prettierignore | 3 +++ tsconfig.build.json | 9 ++++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/.prettierignore b/.prettierignore index 8214e01592..b8849114c3 100644 --- a/.prettierignore +++ b/.prettierignore @@ -22,3 +22,6 @@ android/build/ android/.cxx/ tea.yaml + +coverage/ +e2e/kit/assets/ diff --git a/tsconfig.build.json b/tsconfig.build.json index 4725ef9974..050013e145 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,4 +1,11 @@ { "extends": "./tsconfig", - "exclude": ["example", "FabricExample", "docs", "e2e", "**/__tests__", "**/__fixtures__"] + "exclude": [ + "example", + "FabricExample", + "docs", + "e2e", + "**/__tests__", + "**/__fixtures__" + ] } From d07569ab9145826560405ec741108df02be65bc2 Mon Sep 17 00:00:00 2001 From: kirillzyusko Date: Thu, 26 Mar 2026 19:31:31 +0100 Subject: [PATCH 9/9] chore: revert console.log --- .../KeyboardAwareScrollView/index.tsx | 171 +----------------- 1 file changed, 2 insertions(+), 169 deletions(-) diff --git a/src/components/KeyboardAwareScrollView/index.tsx b/src/components/KeyboardAwareScrollView/index.tsx index 8c74bba7f6..da5bf242a4 100644 --- a/src/components/KeyboardAwareScrollView/index.tsx +++ b/src/components/KeyboardAwareScrollView/index.tsx @@ -167,29 +167,12 @@ const KeyboardAwareScrollView = forwardRef< (e: number, animated: boolean = false) => { "worklet"; - console.log( - `[KASV::maybeScroll] called with e=${e}, animated=${animated}`, - ); - console.log( - `[KASV::maybeScroll] enabled=${enabled}, scrollPosition=${scrollPosition.value}, position=${position.value}`, - ); - console.log( - `[KASV::maybeScroll] keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`, - ); - console.log( - `[KASV::maybeScroll] layout.parentScrollViewTarget=${layout.value?.parentScrollViewTarget}, scrollViewTarget=${scrollViewTarget.value}`, - ); - if (!enabled) { - console.log("[KASV::maybeScroll] BAIL: not enabled"); - return 0; } // input belongs to ScrollView if (layout.value?.parentScrollViewTarget !== scrollViewTarget.value) { - console.log("[KASV::maybeScroll] BAIL: input not in this ScrollView"); - return 0; } @@ -198,17 +181,6 @@ const KeyboardAwareScrollView = forwardRef< const inputHeight = layout.value?.layout.height || 0; const point = absoluteY + inputHeight; - console.log( - `[KASV::maybeScroll] height=${height}, visibleRect=${visibleRect}, absoluteY=${absoluteY}, inputHeight=${inputHeight}, point=${point}, bottomOffset=${bottomOffset}`, - ); - console.log( - `[KASV::maybeScroll] condition: visibleRect - point (${ - visibleRect - point - }) <= bottomOffset (${bottomOffset}) => ${ - visibleRect - point <= bottomOffset - }`, - ); - if (visibleRect - point <= bottomOffset) { const relativeScrollTo = keyboardHeight.value - (height - point) + bottomOffset; @@ -226,10 +198,6 @@ const KeyboardAwareScrollView = forwardRef< const targetScrollY = Math.max(interpolatedScrollTo, 0) + scrollPosition.value; - console.log( - `[KASV::maybeScroll] SCROLL DOWN: relativeScrollTo=${relativeScrollTo}, interpolatedScrollTo=${interpolatedScrollTo}, targetScrollY=${targetScrollY}`, - ); - scrollTo(scrollViewAnimatedRef, 0, targetScrollY, animated); return interpolatedScrollTo; @@ -239,20 +207,12 @@ const KeyboardAwareScrollView = forwardRef< const positionOnScreen = visibleRect - bottomOffset; const topOfScreen = scrollPosition.value + point; - console.log( - `[KASV::maybeScroll] SCROLL UP: point=${point} < scrollViewPageY=${ - scrollViewPageY.value - }, scrollTo=${topOfScreen - positionOnScreen}`, - ); - scrollTo( scrollViewAnimatedRef, 0, topOfScreen - positionOnScreen, animated, ); - } else { - console.log("[KASV::maybeScroll] NO SCROLL needed"); } return 0; @@ -289,10 +249,6 @@ const KeyboardAwareScrollView = forwardRef< const prevScroll = scrollPosition.value; - console.log( - `[KASV::performScrollWithPositionRestoration] newPosition=${newPosition}, prevScroll=${prevScroll}, keyboardHeight=${keyboardHeight.value}`, - ); - // eslint-disable-next-line react-compiler/react-compiler scrollPosition.value = newPosition; maybeScroll(keyboardHeight.value, true); @@ -364,49 +320,19 @@ const KeyboardAwareScrollView = forwardRef< const lastTarget = lastSelection.value?.target; const latestSelection = lastSelection.value?.selection; - console.log( - `[KASV::onSelectionChange] target=${e.target}, lastTarget=${lastTarget}, pendingSelectionForFocus=${pendingSelectionForFocus.value}`, - ); - console.log( - `[KASV::onSelectionChange] selection.end.y=${e.selection.end.y}, selection.end.position=${e.selection.end.position}`, - ); - lastSelection.value = e; selectionUpdatedSinceHide.value = true; if (e.target !== lastTarget || pendingSelectionForFocus.value) { - console.log( - `[KASV::onSelectionChange] target changed or pending focus! (targetChanged=${ - e.target !== lastTarget - }, pending=${pendingSelectionForFocus.value})`, - ); - if (pendingSelectionForFocus.value) { // selection arrived after onStart - complete the deferred setup pendingSelectionForFocus.value = false; updateLayoutFromSelection(); - console.log( - `[KASV::onSelectionChange] deferred setup complete. layout=${JSON.stringify( - layout.value?.layout, - )}`, - ); - console.log( - `[KASV::onSelectionChange] keyboardWillAppear=${keyboardWillAppear.value}, keyboardHeight=${keyboardHeight.value}`, - ); - // if keyboard was already visible (focus change, no onMove expected), // perform the deferred scroll now if (!keyboardWillAppear.value && keyboardHeight.value > 0) { - const scrollDelta = maybeScroll(keyboardHeight.value, true); - - console.log( - `[KASV::onSelectionChange] deferred scroll: scrollDelta=${scrollDelta}, position before=${position.value}`, - ); - position.value += scrollDelta; - console.log( - `[KASV::onSelectionChange] position after=${position.value}`, - ); + position.value += maybeScroll(keyboardHeight.value, true); } } @@ -418,18 +344,10 @@ const KeyboardAwareScrollView = forwardRef< e.selection.end.position === e.selection.start.position && latestSelection?.end.y !== e.selection.end.y ) { - console.log( - "[KASV::onSelectionChange] new line detected, scrollFromCurrentPosition", - ); - return scrollFromCurrentPosition(); } // selection has been changed if (e.selection.start.position !== e.selection.end.position) { - console.log( - "[KASV::onSelectionChange] selection range changed, scrollFromCurrentPosition", - ); - return scrollFromCurrentPosition(); } @@ -455,22 +373,6 @@ const KeyboardAwareScrollView = forwardRef< onStart: (e) => { "worklet"; - console.log("=== [KASV::onStart] ==="); - console.log( - `[KASV::onStart] e.height=${e.height}, e.target=${e.target}, e.duration=${e.duration}`, - ); - console.log( - `[KASV::onStart] BEFORE: keyboardHeight=${keyboardHeight.value}, tag=${tag.value}, position=${position.value}, scrollPosition=${scrollPosition.value}`, - ); - console.log( - `[KASV::onStart] BEFORE: initialKeyboardSize=${initialKeyboardSize.value}, scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`, - ); - console.log( - `[KASV::onStart] input.value?.layout=${JSON.stringify( - input.value?.layout, - )}`, - ); - const keyboardWillChangeSize = keyboardHeight.value !== e.height && e.height > 0; @@ -481,15 +383,8 @@ const KeyboardAwareScrollView = forwardRef< (tag.value !== e.target && e.target !== -1) || keyboardWillChangeSize; - console.log( - `[KASV::onStart] keyboardWillAppear=${keyboardWillAppear.value}, keyboardWillHide=${keyboardWillHide}, keyboardWillChangeSize=${keyboardWillChangeSize}, focusWasChanged=${focusWasChanged}`, - ); - if (keyboardWillChangeSize) { initialKeyboardSize.value = keyboardHeight.value; - console.log( - `[KASV::onStart] keyboardWillChangeSize -> initialKeyboardSize set to ${keyboardHeight.value}`, - ); } if (keyboardWillHide) { @@ -497,9 +392,6 @@ const KeyboardAwareScrollView = forwardRef< initialKeyboardSize.value = 0; scrollPosition.value = scrollBeforeKeyboardMovement.value; pendingSelectionForFocus.value = false; - console.log( - `[KASV::onStart] keyboardWillHide -> scrollPosition reset to scrollBeforeKeyboardMovement=${scrollBeforeKeyboardMovement.value}`, - ); } if ( @@ -513,9 +405,6 @@ const KeyboardAwareScrollView = forwardRef< keyboardHeight.value = e.height; // and update keyboard spacer size syncKeyboardFrame(e); - console.log( - `[KASV::onStart] persisted: scrollPosition=${position.value}, keyboardHeight=${e.height}`, - ); } // focus was changed @@ -527,9 +416,6 @@ const KeyboardAwareScrollView = forwardRef< selectionUpdatedSinceHide.value ) { // fresh selection arrived before onStart - use it to update layout - console.log( - "[KASV::onStart] focusChanged: FRESH selection arrived BEFORE onStart, updating layout from selection", - ); updateLayoutFromSelection(); pendingSelectionForFocus.value = false; } else { @@ -539,15 +425,9 @@ const KeyboardAwareScrollView = forwardRef< // otherwise fall back to full input layout. // Will be corrected if a fresh onSelectionChange arrives. if (lastSelection.value?.target === e.target) { - console.log( - `[KASV::onStart] focusChanged: using STALE selection as fallback, selection.end.y=${lastSelection.value?.selection.end.y}`, - ); updateLayoutFromSelection(); } else if (input.value) { layout.value = input.value; - console.log( - `[KASV::onStart] focusChanged: no selection for target, using input layout: absoluteY=${input.value?.layout.absoluteY}, height=${input.value?.layout.height}`, - ); } pendingSelectionForFocus.value = true; } @@ -555,26 +435,13 @@ const KeyboardAwareScrollView = forwardRef< // save current scroll position - when keyboard will hide we'll reuse // this value to achieve smooth hide effect scrollBeforeKeyboardMovement.value = position.value; - console.log( - `[KASV::onStart] focusChanged: scrollBeforeKeyboardMovement=${position.value}`, - ); } if (focusWasChanged && !keyboardWillAppear.value) { - console.log( - `[KASV::onStart] focusChanged + keyboard already visible, pendingSelection=${pendingSelectionForFocus.value}`, - ); - if (!pendingSelectionForFocus.value) { // update position on scroll value, so `onEnd` handler // will pick up correct values - const scrollDelta = maybeScroll(e.height, true); - - console.log( - `[KASV::onStart] maybeScroll returned scrollDelta=${scrollDelta}, position before=${position.value}`, - ); - position.value += scrollDelta; - console.log(`[KASV::onStart] position after=${position.value}`); + position.value += maybeScroll(e.height, true); } } @@ -583,36 +450,14 @@ const KeyboardAwareScrollView = forwardRef< scrollViewLayout.value.height - scrollViewContentSize.value.height; - console.log( - `[KASV::onStart] ghostViewSpace=${ghostViewSpace.value}, scrollViewLayout.height=${scrollViewLayout.value.height}, contentSize.height=${scrollViewContentSize.value.height}`, - ); - if (ghostViewSpace.value > 0) { scrollPosition.value = position.value; - console.log( - `[KASV::onStart] ghostViewSpace > 0 -> scrollPosition=${position.value}`, - ); } - - console.log( - `[KASV::onStart] FINAL: scrollPosition=${scrollPosition.value}, position=${position.value}, keyboardHeight=${keyboardHeight.value}, initialKeyboardSize=${initialKeyboardSize.value}`, - ); - console.log( - `[KASV::onStart] FINAL: layout=${JSON.stringify( - layout.value?.layout, - )}`, - ); }, onMove: (e) => { "worklet"; - console.log( - `[KASV::onMove] e.height=${e.height}, e.progress=${e.progress}, position=${position.value}`, - ); - if (removeGhostPadding(e.height)) { - console.log("[KASV::onMove] BAIL: removeGhostPadding handled it"); - return; } @@ -624,14 +469,6 @@ const KeyboardAwareScrollView = forwardRef< onEnd: (e) => { "worklet"; - console.log("=== [KASV::onEnd] ==="); - console.log( - `[KASV::onEnd] e.height=${e.height}, e.target=${e.target}`, - ); - console.log( - `[KASV::onEnd] BEFORE: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`, - ); - removeGhostPadding(e.height); keyboardHeight.value = e.height; @@ -649,10 +486,6 @@ const KeyboardAwareScrollView = forwardRef< } syncKeyboardFrame(e); - - console.log( - `[KASV::onEnd] AFTER: keyboardHeight=${keyboardHeight.value}, scrollPosition=${scrollPosition.value}, position=${position.value}`, - ); }, }, [