Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -22,3 +22,6 @@ android/build/
android/.cxx/

tea.yaml

coverage/
e2e/kit/assets/
113 changes: 113 additions & 0 deletions src/components/KeyboardAwareScrollView/__fixtures__/mocks.tsx
Original file line number Diff line number Diff line change
@@ -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);
},
),
};
});
151 changes: 151 additions & 0 deletions src/components/KeyboardAwareScrollView/__fixtures__/testUtils.tsx
Original file line number Diff line number Diff line change
@@ -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<FocusedInputLayoutChangedEvent | null>;
export const mockOffset = { value: 0 } as SharedValue<number>;
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(
<KeyboardAwareScrollView bottomOffset={bottomOffset}>
<View />
</KeyboardAwareScrollView>,
);

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;
};
114 changes: 114 additions & 0 deletions src/components/KeyboardAwareScrollView/__tests__/refocus.spec.tsx
Original file line number Diff line number Diff line change
@@ -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),
);
});
});
});
Loading
Loading