Skip to content

Commit

Permalink
feat: synchronous handler mount (#538)
Browse files Browse the repository at this point in the history
## 📜 Description

Mount workletized handlers synchronously 😎 

## 💡 Motivation and Context

Previously we were storing all worklet handlers in a global object and
broadcasted events through all of them. However such approach had one
big downside: updating shared value from JS is asynchronous (and if JS
thread becomes busy, then mounting can take significant time).

In most of the cases it's not a big problem, **but** if keyboard moves
when handler is not attached yet, then such keyboard movement is not
getting tracked and as a result an actual keyboard position is not
synchronized with shared values (if we are using components, such as
`KeyboardAvoidingView` then they will not handle keyboard appearance
properly).

I've considered two approaches how to fix it:

### 1️⃣ Distinguish which events were not sent to particular handler and
send them after actual mount

That was the first idea and I thought it's quite perspective, but when I
implemented it, I quickly realized, that:
- it bring more hidden complexity;
- it produces more race conditions - we can verify whether event was
handled only in JS (only there we know which handlers are mounted and
from UI thread we can send all ids of handlers that handled event), but
we may have a situation, when handler skipped 10/30 events and handled
last 20 events. In this case we shouldn't send these events, but we
don't distinguish whether these events belong to the same group of
events or not, so if we send them from JS to worklet, we may have a
situation, where we handle last 20 events and only after that we handle
first 10 events.

So at this point of time I realized, that it's not straightforward
appraoch and started to look into different solutions.

### 2️⃣ Attach handler through JSI function

Another approach that I considered is attaching worklet handlers to the
view directly (without intermediate handlers).
I discovered, that we call JSI function, which means that we have no
delays or async stuff and was very inspired by that fact. I did some
experiments and indeed it proved, that handlers can be attached
synchronously.

However I discovered one use case - we still attach handler from
`useEffect` (which is executed asynchronously) and worklet handlers are
added via `addUiBlock`, so it's again asynchronous. So even with JSI we
could have a delay up to 2 frames, which wasn't acceptable in certain
cases.

At this point of time I thought that it's unreal to complete my
objective, but then decided to try to use `useSyncEffect`. And with
`useSyncEffect` it looks like we have only `addUIBlock` asynchronous and
it's kind of acceptable (my handlers gets mounted faster, than keyboard
events arrive).

So I re-worked code, added unit tests for `useSyncEffect`, run e2e and
CI and everything seems to be pretty good so fat.

I know, that it's not very highly desirable to run synchronous events in
react, but I feel like this approach is a last resort and want to try
it. I also did performance benchmarks and didn't notice anything - UI
gives 59/60 FPS and JS thread give 55 FPS (when navigating between
screens). So I think this approach is an acceptable option that worth to
try 🚀

## 📢 Changelog

<!-- High level overview of important changes -->
<!-- For example: fixed status bar manipulation; added new types
declarations; -->
<!-- If your changes don't affect one of platform/language below - then
remove this platform/language -->

### Docs

- mention that only `[email protected]` is a minimal
supported version;

### JS

- added infrastructure for hook testing in `src` folder;
- removed `useSharedHandlers` hook;
- added `useEventHandlerRegistration` hook;
- changed signature for `setKeyboardHandler` and `setInputHandlers`
method;
- assign `ref` to `KeyboardControllerView`;
- add `event-mapping` and `event-handler` files;
- added `useSyncEffect` hook;
- removed `useFocusedInputTextHandler`,
`useFocusedInputSelectionHandler` and `uuid` functions;

## 🤔 How Has This Been Tested?

Tested manually on iPhone 15 Pro.

Also verified that e2e tests are not failing.

## 📸 Screenshots (if appropriate):

|Before|After|
|-------|-----|
|<video
src="https://github.com/user-attachments/assets/b839212e-16da-4437-85e6-187b97a3ea55">|<video
src="https://github.com/user-attachments/assets/f78c2726-e103-4720-8041-9aafd812b30f">|

## 📝 Checklist

- [x] CI successfully passed
- [x] I added new mocks and corresponding unit-tests if library API was
changed
  • Loading branch information
kirillzyusko authored Aug 8, 2024
1 parent 6861faf commit cfc62b7
Show file tree
Hide file tree
Showing 17 changed files with 328 additions and 180 deletions.
1 change: 1 addition & 0 deletions .prettierignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ docs/build/
lib/
node_modules/
vendor/
.gradle/

*.lottie.json

Expand Down
2 changes: 1 addition & 1 deletion docs/docs/guides/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ This library supports as minimal `react-native` version as possible. However it

This library is heavily relies on `react-native-reanimated` primitives to bring advanced concepts for keyboard handling.

The minimal supported version of `react-native-reanimated` is `2.11.0`.
The minimum supported version of `react-native-reanimated` is `3.0.0` (as officially supported by `react-native-reanimated` team).

## Third-party libraries compatibility

Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
"@commitlint/config-conventional": "^11.0.0",
"@react-native/eslint-config": "^0.74.85",
"@release-it/conventional-changelog": "^2.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@types/jest": "^29.2.1",
"@types/react": "^18.2.6",
"@typescript-eslint/eslint-plugin": "^6.7.4",
Expand All @@ -96,6 +97,7 @@
"react-native": "0.74.3",
"react-native-builder-bob": "^0.18.0",
"react-native-reanimated": "3.12.1",
"react-test-renderer": "18.2.0",
"release-it": "^14.2.2",
"typescript": "5.0.4"
},
Expand Down
56 changes: 15 additions & 41 deletions src/animated.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,16 @@
/* eslint react/jsx-sort-props: off */
import React, { useEffect, useMemo, useState } from "react";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Animated, Platform, StyleSheet } from "react-native";
import Reanimated, { useSharedValue } from "react-native-reanimated";

import { KeyboardControllerView } from "./bindings";
import { KeyboardContext } from "./context";
import { useAnimatedValue, useSharedHandlers } from "./internal";
import { focusedInputEventsMap, keyboardEventsMap } from "./event-mappings";
import { useAnimatedValue, useEventHandlerRegistration } from "./internal";
import { applyMonkeyPatch, revertMonkeyPatch } from "./monkey-patch";
import {
useAnimatedKeyboardHandler,
useFocusedInputLayoutHandler,
useFocusedInputSelectionHandler,
useFocusedInputTextHandler,
} from "./reanimated";

import type { KeyboardAnimationContext } from "./context";
Expand All @@ -25,9 +24,7 @@ import type {
import type { ViewStyle } from "react-native";

const KeyboardControllerViewAnimated = Reanimated.createAnimatedComponent(
Animated.createAnimatedComponent(
KeyboardControllerView,
) as React.FC<KeyboardControllerProps>,
Animated.createAnimatedComponent(KeyboardControllerView),
);

type Styles = {
Expand Down Expand Up @@ -84,6 +81,8 @@ export const KeyboardProvider = ({
navigationBarTranslucent,
enabled: initiallyEnabled = true,
}: KeyboardProviderProps) => {
// ref
const viewTagRef = useRef<React.Component<KeyboardControllerProps>>(null);
// state
const [enabled, setEnabled] = useState(initiallyEnabled);
// animated values
Expand All @@ -93,10 +92,14 @@ export const KeyboardProvider = ({
const progressSV = useSharedValue(0);
const heightSV = useSharedValue(0);
const layout = useSharedValue<FocusedInputLayoutChangedEvent | null>(null);
const [setKeyboardHandlers, broadcastKeyboardEvents] =
useSharedHandlers<KeyboardHandler>();
const [setInputHandlers, broadcastInputEvents] =
useSharedHandlers<FocusedInputHandler>();
const setKeyboardHandlers = useEventHandlerRegistration<KeyboardHandler>(
keyboardEventsMap,
viewTagRef,
);
const setInputHandlers = useEventHandlerRegistration<FocusedInputHandler>(
focusedInputEventsMap,
viewTagRef,
);
// memo
const context = useMemo<KeyboardAnimationContext>(
() => ({
Expand Down Expand Up @@ -147,25 +150,17 @@ export const KeyboardProvider = ({
onKeyboardMoveStart: (event: NativeEvent) => {
"worklet";

broadcastKeyboardEvents("onStart", event);
updateSharedValues(event, ["ios"]);
},
onKeyboardMove: (event: NativeEvent) => {
"worklet";

broadcastKeyboardEvents("onMove", event);
updateSharedValues(event, ["android"]);
},
onKeyboardMoveEnd: (event: NativeEvent) => {
"worklet";

broadcastKeyboardEvents("onEnd", event);
},
onKeyboardMoveInteractive: (event: NativeEvent) => {
"worklet";

updateSharedValues(event, ["android", "ios"]);
broadcastKeyboardEvents("onInteractive", event);
},
},
[],
Expand All @@ -184,26 +179,6 @@ export const KeyboardProvider = ({
},
[],
);
const inputTextHandler = useFocusedInputTextHandler(
{
onFocusedInputTextChanged: (e) => {
"worklet";

broadcastInputEvents("onChangeText", e);
},
},
[],
);
const inputSelectionHandler = useFocusedInputSelectionHandler(
{
onFocusedInputSelectionChanged: (e) => {
"worklet";

broadcastInputEvents("onSelectionChange", e);
},
},
[],
);

// effects
useEffect(() => {
Expand All @@ -217,6 +192,7 @@ export const KeyboardProvider = ({
return (
<KeyboardContext.Provider value={context}>
<KeyboardControllerViewAnimated
ref={viewTagRef}
enabled={enabled}
navigationBarTranslucent={navigationBarTranslucent}
statusBarTranslucent={statusBarTranslucent}
Expand All @@ -227,8 +203,6 @@ export const KeyboardProvider = ({
onKeyboardMove={OS === "android" ? onKeyboardMove : undefined}
onKeyboardMoveInteractive={onKeyboardMove}
onFocusedInputLayoutChangedReanimated={inputLayoutHandler}
onFocusedInputSelectionChangedReanimated={inputSelectionHandler}
onFocusedInputTextChangedReanimated={inputTextHandler}
>
{children}
</KeyboardControllerViewAnimated>
Expand Down
13 changes: 7 additions & 6 deletions src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { createContext, useContext } from "react";
import { Animated } from "react-native";

import type {
FocusedInputHandlers,
FocusedInputHandler,
FocusedInputLayoutChangedEvent,
KeyboardHandlers,
KeyboardHandler,
} from "./types";
import type React from "react";
import type { SharedValue } from "react-native-reanimated";
Expand All @@ -22,11 +22,12 @@ export type KeyboardAnimationContext = {
animated: AnimatedContext;
reanimated: ReanimatedContext;
layout: SharedValue<FocusedInputLayoutChangedEvent | null>;
setKeyboardHandlers: (handlers: KeyboardHandlers) => void;
setInputHandlers: (handlers: FocusedInputHandlers) => void;
setKeyboardHandlers: (handlers: KeyboardHandler) => () => void;
setInputHandlers: (handlers: FocusedInputHandler) => () => void;
setEnabled: React.Dispatch<React.SetStateAction<boolean>>;
};
const NOOP = () => {};
const NESTED_NOOP = () => NOOP;
const withSharedValue = <T>(value: T): SharedValue<T> => ({
value,
addListener: NOOP,
Expand All @@ -48,8 +49,8 @@ const defaultContext: KeyboardAnimationContext = {
height: DEFAULT_SHARED_VALUE,
},
layout: DEFAULT_LAYOUT,
setKeyboardHandlers: NOOP,
setInputHandlers: NOOP,
setKeyboardHandlers: NESTED_NOOP,
setInputHandlers: NESTED_NOOP,
setEnabled: NOOP,
};

Expand Down
15 changes: 15 additions & 0 deletions src/event-handler.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
let REACore = null;

try {
REACore = require("react-native-reanimated/src/core");
} catch (e1) {
try {
REACore = require("react-native-reanimated/src/reanimated2/core");
} catch (e2) {
console.warn("Failed to load REACore from both paths");
}
}
const registerEventHandler = REACore.registerEventHandler;
const unregisterEventHandler = REACore.unregisterEventHandler;

export { registerEventHandler, unregisterEventHandler };
8 changes: 8 additions & 0 deletions src/event-handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
declare function registerEventHandler(
handler: (event: never) => void,
eventName: string,
viewTag: number,
): number;
declare function unregisterEventHandler(id: number): void;

export { registerEventHandler, unregisterEventHandler };
14 changes: 14 additions & 0 deletions src/event-mappings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { FocusedInputHandler, KeyboardHandler } from "./types";

export const keyboardEventsMap = new Map<keyof KeyboardHandler, string>([
["onStart", "onKeyboardMoveStart"],
["onMove", "onKeyboardMove"],
["onEnd", "onKeyboardMoveEnd"],
["onInteractive", "onKeyboardMoveInteractive"],
]);
export const focusedInputEventsMap = new Map<keyof FocusedInputHandler, string>(
[
["onChangeText", "onFocusedInputTextChanged"],
["onSelectionChange", "onFocusedInputSelectionChanged"],
],
);
25 changes: 9 additions & 16 deletions src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { useEffect } from "react";
import { KeyboardController } from "../bindings";
import { AndroidSoftInputModes } from "../constants";
import { useKeyboardContext } from "../context";
import { uuid } from "../utils";

import useSyncEffect from "./useSyncEffect";

import type { AnimatedContext, ReanimatedContext } from "../context";
import type { FocusedInputHandler, KeyboardHandler } from "../types";
Expand Down Expand Up @@ -39,14 +40,10 @@ export function useGenericKeyboardHandler(
) {
const context = useKeyboardContext();

useEffect(() => {
const key = uuid();

context.setKeyboardHandlers({ [key]: handler });
useSyncEffect(() => {
const cleanup = context.setKeyboardHandlers(handler);

return () => {
context.setKeyboardHandlers({ [key]: undefined });
};
return () => cleanup();
}, deps);
}

Expand All @@ -71,19 +68,15 @@ export function useReanimatedFocusedInput() {
}

export function useFocusedInputHandler(
handler?: FocusedInputHandler,
handler: FocusedInputHandler,
deps?: DependencyList,
) {
const context = useKeyboardContext();

useEffect(() => {
const key = uuid();

context.setInputHandlers({ [key]: handler });
useSyncEffect(() => {
const cleanup = context.setInputHandlers(handler);

return () => {
context.setInputHandlers({ [key]: undefined });
};
return () => cleanup();
}, deps);
}

Expand Down
Loading

0 comments on commit cfc62b7

Please sign in to comment.