Skip to content
Closed
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
10 changes: 7 additions & 3 deletions .github/workflows/ai-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,11 @@ on:
jobs:
review:
name: 🕵️ Finding bugs
if: github.event.pull_request.draft == false
# Fork pull requests receive a read-only GITHUB_TOKEN and therefore cannot
# upsert the review comment at the end of this job. Do not use
# pull_request_target here: this workflow processes contributor-controlled
# diffs and must not run with write credentials.
if: github.event.pull_request.draft == false && github.event.pull_request.head.repo.full_name == github.repository
runs-on: ubuntu-latest

env:
Expand Down Expand Up @@ -48,15 +52,15 @@ jobs:

- name: Pull model
run: |
ollama pull $MODEL
ollama pull "$MODEL"

- name: Build LLM context
run: |
git fetch origin main

FILES=$(git diff --name-status origin/main...HEAD | awk '{print $1 "|" $2}')

> prompt.txt
: > prompt.txt

while IFS="|" read -r status file; do
[ -z "$file" ] && continue
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,7 @@
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n/bin/sh -c \"$WITH_ENVIRONMENT $REACT_NATIVE_XCODE\"";
shellScript = "set -e\n\nWITH_ENVIRONMENT=\"$REACT_NATIVE_PATH/scripts/xcode/with-environment.sh\"\nREACT_NATIVE_XCODE=\"$REACT_NATIVE_PATH/scripts/react-native-xcode.sh\"\n\n\"$WITH_ENVIRONMENT\" \"$REACT_NATIVE_XCODE\"";
};
2D02E4CB1E0B4B27006451C7 /* Bundle React Native Code And Images */ = {
isa = PBXShellScriptBuildPhase;
Expand Down
13 changes: 9 additions & 4 deletions ios/observers/movement/KeyboardTrackingView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,17 @@ public final class KeyboardTrackingView: UIView {

// for `keyboardLayoutGuide` case we can just read keyboard position directly - no interpolation needed
if KeyboardControllerConfiguration.usesKeyboardLayoutGuideTracking {
if keyboardPosition > keyboardHeight {
// when we are the top position KVO takes `inputAccessoryView` into consideration,
// so we handle it here. KVO reports coordinates on the screen's pixel
// grid, while `keyboardHeight` can include a fractional accessory-view
// offset (for example, a hairline border). Treat values within one pixel
// as the same fully-open position instead of relying on exact equality.
let screenScale = trackedView.window?.screen.scale ?? UIScreen.main.scale
let positionTolerance = 1 / max(screenScale, 1)
if keyboardPosition > keyboardHeight + positionTolerance {
return Self.invalidPosition
}
// when we are the top position KVO takes `inputAccessoryView` into consideration,
// so we handle it here
if keyboardPosition == keyboardHeight {
if abs(keyboardPosition - keyboardHeight) <= positionTolerance {
return keyboardPosition - KeyboardAreaExtender.shared.offset
}
return keyboardPosition
Expand Down
108 changes: 108 additions & 0 deletions src/__tests__/animated.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import { act, render } from "@testing-library/react-native";
import { useEffect } from "react";

import { KeyboardProvider } from "../animated";
import { useKeyboardContext } from "../context";

import type { KeyboardAnimationContext } from "../context";

jest.mock("react-native-reanimated", () => {
const React = require("react");
const mock = require("react-native-reanimated/mock");

return {
...mock,
__esModule: true,
default: {
createAnimatedComponent: (Component: unknown) => Component,
},
useSharedValue: <T,>(initialValue: T) => {
const ref = React.useRef({ value: initialValue });

return ref.current;
},
};
});

jest.mock("../bindings", () => {
const { View } = require("react-native");

return {
FocusedInputEvents: {
addListener: jest.fn(() => ({ remove: jest.fn() })),
},
KeyboardControllerView: View,
KeyboardControllerViewCommands: {
synchronizeFocusedInputLayout: jest.fn(),
},
KeyboardEvents: {
addListener: jest.fn(() => ({ remove: jest.fn() })),
},
KeyboardControllerNative: {
dismiss: jest.fn(),
preload: jest.fn(),
setDefaultMode: jest.fn(),
setFocusTo: jest.fn(),
setInputMode: jest.fn(),
setTranslucent: jest.fn(),
},
};
});

jest.mock("../internal", () => ({
...jest.requireActual("../internal"),
useEventHandlerRegistration: jest.fn(() => jest.fn(() => jest.fn())),
}));

jest.mock("../reanimated", () => ({
useAnimatedKeyboardHandler: jest.fn(() => ({})),
useFocusedInputLayoutHandler: jest.fn(() => ({})),
}));

jest.mock("react-native-is-edge-to-edge", () => ({
controlEdgeToEdgeValues: jest.fn(),
isEdgeToEdge: jest.fn(() => false),
}));

type ContextCaptureProps = {
onContext: (value: KeyboardAnimationContext) => void;
};

/**
* Captures the provider context exposed to descendant hooks.
*
* @param props - Receives each context value after it is committed.
* @param props.onContext - Callback invoked with the latest context value.
* @returns No rendered output.
*/
function ContextCapture({ onContext }: ContextCaptureProps) {
const value = useKeyboardContext();

useEffect(() => {
onContext(value);
}, [onContext, value]);

return null;
}

describe("KeyboardProvider enabled state", () => {
it("resets Reanimated keyboard values when disabled mid-transition", () => {
const capture = jest.fn();

render(
<KeyboardProvider>
<ContextCapture onContext={capture} />
</KeyboardProvider>,
);
const context = capture.mock.lastCall![0] as KeyboardAnimationContext;

act(() => {
context.reanimated.progress.value = 0.5;
context.reanimated.height.value = -150;
context.setEnabled(false);
});

expect(context.reanimated.progress.value).toBe(0);
expect(context.reanimated.height.value).toBe(0);
});
});
14 changes: 13 additions & 1 deletion src/animated.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import React, {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand Down Expand Up @@ -96,6 +97,18 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => {
const layout = useSharedValue<FocusedInputLayoutChangedEvent | null>(null);
const setKeyboardHandlers = useEventHandlerRegistration(viewRef);
const setInputHandlers = useEventHandlerRegistration(viewRef);

useLayoutEffect(() => {
if (!enabled) {
progress.setValue(0);
height.setValue(0);
// eslint-disable-next-line react-compiler/react-compiler
progressSV.value = 0;
heightSV.value = 0;
layout.value = null;
}
}, [enabled, height, heightSV, layout, progress, progressSV]);

const update = useCallback(async () => {
KeyboardControllerViewCommands.synchronizeFocusedInputLayout(
viewRef.current,
Expand Down Expand Up @@ -153,7 +166,6 @@ export const KeyboardProvider = (props: KeyboardProviderProps) => {
"worklet";

if (platforms.includes(OS)) {
// eslint-disable-next-line react-compiler/react-compiler
progressSV.value = event.progress;
heightSV.value = -event.height;
}
Expand Down
157 changes: 157 additions & 0 deletions src/components/KeyboardAvoidingView/__tests__/initialFrame.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { fireEvent, render } from "@testing-library/react-native";
import { View } from "react-native";

import KeyboardAvoidingView from "..";

import type { LayoutRectangle } from "react-native";

const keyboard = {
heightWhenOpened: { value: 300 },
progress: { value: 1 },
isClosed: { value: true },
};
const translate = { value: 0 };
const padding = { value: 0 };
const animatedStyleUpdaters: Array<() => object> = [];

jest.mock("react-native-reanimated", () => {
const React = require("react");
const { View: NativeView } = require("react-native");

return {
__esModule: true,
default: {
View: React.forwardRef((props: object, ref: unknown) =>
React.createElement(NativeView, { ...props, ref }),
),
},
interpolate: (value: number, input: number[], output: number[]) => {
const progress = (value - input[0]) / (input[1] - input[0]);

return output[0] + progress * (output[1] - output[0]);
},
runOnUI: (worklet: (...args: unknown[]) => unknown) => worklet,
useAnimatedStyle: (updater: () => object) => {
animatedStyleUpdaters.push(updater);

return updater();
},
useDerivedValue: (updater: () => unknown) => ({
get value() {
return updater();
},
}),
useSharedValue: <T,>(value: T) => ({ value }),
};
});

jest.mock("../hooks", () => ({
useKeyboardAnimation: () => keyboard,
useTranslateAnimation: () => ({ translate, padding }),
}));

jest.mock("../../../hooks", () => ({
useWindowDimensions: () => ({ height: 800 }),
}));

jest.mock("../../../bindings", () => ({
KeyboardControllerNative: {
viewPositionInWindow: jest.fn(),
},
}));

const restingLayout: LayoutRectangle = {
x: 0,
y: 275,
width: 320,
height: 280,
};
const paddingAffectedLayout: LayoutRectangle = {
...restingLayout,
y: 220,
height: 400,
};

/**
* Evaluates the latest animated-style updater registered by the component.
*
* @returns The rendered animated style.
*/
function renderedStyle(): Record<string, number> {
return animatedStyleUpdaters.at(-1)?.() as Record<string, number>;
}

/**
* Returns the keyboard displacement property emitted by a given behavior.
*
* @param behavior - The `KeyboardAvoidingView` behavior being inspected.
* @returns The resulting keyboard displacement in points.
*/
function renderedDisplacement(
behavior: "padding" | "translate-with-padding" | "position",
): number {
const style = renderedStyle();

switch (behavior) {
case "padding":
return style.paddingBottom;
case "translate-with-padding":
return style.paddingTop;
case "position":
return style.bottom;
}
}

describe("KeyboardAvoidingView initial frame", () => {
beforeEach(() => {
keyboard.isClosed.value = true;
padding.value = 0;
animatedStyleUpdaters.length = 0;
});

it.each(["padding", "translate-with-padding"] as const)(
"keeps the resting frame for %s while the keyboard is open",
(behavior) => {
const screen = render(
<KeyboardAvoidingView behavior={behavior} testID="avoiding-view">
<View />
</KeyboardAvoidingView>,
);

fireEvent(screen.getByTestId("avoiding-view"), "layout", {
nativeEvent: { layout: restingLayout },
});
keyboard.isClosed.value = false;
padding.value = 1;

expect(renderedDisplacement(behavior)).toBe(55);

// These are the self-generated layout changes caused by animated
// padding. They must not replace the resting frame.
fireEvent(screen.getByTestId("avoiding-view"), "layout", {
nativeEvent: { layout: paddingAffectedLayout },
});

expect(renderedDisplacement(behavior)).toBe(55);
},
);

it("continues to refresh the outer frame for position behavior", () => {
const screen = render(
<KeyboardAvoidingView behavior="position" testID="avoiding-view">
<View />
</KeyboardAvoidingView>,
);

fireEvent(screen.getByTestId("avoiding-view"), "layout", {
nativeEvent: { layout: restingLayout },
});
keyboard.isClosed.value = false;

fireEvent(screen.getByTestId("avoiding-view"), "layout", {
nativeEvent: { layout: paddingAffectedLayout },
});

expect(renderedDisplacement("position")).toBe(120);
});
});
8 changes: 7 additions & 1 deletion src/components/KeyboardAvoidingView/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -120,10 +120,16 @@ const KeyboardAvoidingView = forwardRef<
(layout: LayoutRectangle) => {
"worklet";

// `padding` and `translate-with-padding` change the layout of this
// very view. Recording their onLayout events while the keyboard is
// open turns the animated padding into the next animation's base
// frame, which feeds back into `relativeKeyboardHeight` and causes
// jitter. `position` animates an inner view instead, so its outer
// frame can still be refreshed safely.
if (
keyboard.isClosed.value ||
initialFrame.value === null ||
behavior !== "height"
behavior === "position"
) {
// eslint-disable-next-line react-compiler/react-compiler
initialFrame.value = layout;
Expand Down
Loading