Skip to content

Commit e7de456

Browse files
committed
Make some adjustments to work with the new implementation, add tests
1 parent c48478f commit e7de456

4 files changed

Lines changed: 172 additions & 1 deletion

File tree

packages/react-native-reanimated/src/hook/__tests__/useHandler.test.tsx

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,16 @@
11
'use strict';
22

33
import { worklet } from '../../jestUtils';
4+
import { useIsFastRefresh } from '../utils';
45
import { renderUseHandler, runCommonTests } from './useHandler.shared';
56

7+
jest.mock('../utils', () => ({
8+
...jest.requireActual('../utils'),
9+
useIsFastRefresh: jest.fn(() => false),
10+
}));
11+
12+
const mockFastRefresh = useIsFastRefresh as jest.Mock;
13+
614
function nonWorkletError(...names: string[]) {
715
return new Error(
816
`[Reanimated] Passed handlers that are not worklets. Only worklet functions are allowed. Handlers "${names.join(', ')}" are not worklets.`
@@ -88,4 +96,39 @@ describe('useHandler (native)', () => {
8896
expect(result.current.doDependenciesDiffer).toBe(true);
8997
});
9098
});
99+
100+
describe('fast refresh', () => {
101+
test('flips doDependenciesDiffer to true even when worklet is unchanged', () => {
102+
const w = worklet();
103+
const { result, rerender } = renderUseHandler({ onScroll: w });
104+
105+
// We expect doDependenciesDiffer to be true on the first render.
106+
expect(result.current.doDependenciesDiffer).toBe(true);
107+
108+
// Rerender with the same worklet handler - doDependenciesDiffer should be false.
109+
rerender({ handlers: { onScroll: w } });
110+
expect(result.current.doDependenciesDiffer).toBe(false);
111+
112+
// Next render is a fast refresh - doDependenciesDiffer should be true (behave as if it was a first render).
113+
mockFastRefresh.mockReturnValueOnce(true);
114+
rerender({ handlers: { onScroll: w } });
115+
expect(result.current.doDependenciesDiffer).toBe(true);
116+
117+
// Subsequent renders return to normal behavior - doDependenciesDiffer should be false.
118+
rerender({ handlers: { onScroll: w } });
119+
expect(result.current.doDependenciesDiffer).toBe(false);
120+
});
121+
122+
test('context is reinitialized after fast refresh', () => {
123+
const w = worklet();
124+
const { result, rerender } = renderUseHandler({ onScroll: w });
125+
126+
const firstContext = result.current.context;
127+
128+
mockFastRefresh.mockReturnValueOnce(true);
129+
rerender({ handlers: { onScroll: w } });
130+
131+
expect(result.current.context).not.toBe(firstContext);
132+
});
133+
});
91134
});

packages/react-native-reanimated/src/hook/__tests__/useHandler.web.test.tsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,15 @@
11
'use strict';
22

3+
import { useIsFastRefresh } from '../utils';
34
import { renderUseHandler, runCommonTests } from './useHandler.shared';
45

6+
jest.mock('../utils', () => ({
7+
...jest.requireActual('../utils'),
8+
useIsFastRefresh: jest.fn(() => false),
9+
}));
10+
11+
const mockFastRefresh = useIsFastRefresh as jest.Mock;
12+
513
describe('useHandler (web)', () => {
614
runCommonTests();
715

@@ -94,4 +102,84 @@ describe('useHandler (web)', () => {
94102
});
95103
});
96104
});
105+
106+
describe('fast refresh', () => {
107+
describe('without babel plugin', () => {
108+
const fastRefreshCases: Array<{
109+
caseName: string;
110+
initialDeps: number[] | undefined;
111+
nextDeps: number[] | undefined;
112+
expected: [boolean, boolean, boolean, boolean];
113+
}> = [
114+
{
115+
caseName: 'undefined deps',
116+
initialDeps: undefined,
117+
nextDeps: undefined,
118+
expected: [true, true, true, true],
119+
},
120+
{
121+
caseName: 'empty deps array',
122+
initialDeps: [],
123+
nextDeps: [],
124+
expected: [false, false, true, false],
125+
},
126+
{
127+
caseName: 'non-empty deps array',
128+
initialDeps: [1],
129+
nextDeps: [1],
130+
expected: [true, false, true, false],
131+
},
132+
{
133+
caseName: 'changing deps between renders',
134+
initialDeps: [1],
135+
nextDeps: [2],
136+
expected: [true, true, true, false],
137+
},
138+
];
139+
140+
fastRefreshCases.forEach(
141+
({
142+
caseName,
143+
initialDeps,
144+
nextDeps,
145+
expected: [
146+
firstRenderValue,
147+
secondRenderValue,
148+
fastRefreshValue,
149+
postRefreshValue,
150+
],
151+
}) => {
152+
test(`handles fast-refresh behavior for ${caseName}`, () => {
153+
const handler = jest.fn();
154+
const { result, rerender } = renderUseHandler(
155+
{ onScroll: handler },
156+
initialDeps
157+
);
158+
159+
// Initial render.
160+
expect(result.current.doDependenciesDiffer).toBe(firstRenderValue);
161+
162+
// Second render can keep deps stable or change them.
163+
rerender({ handlers: { onScroll: handler }, deps: nextDeps });
164+
expect(result.current.doDependenciesDiffer).toBe(secondRenderValue);
165+
166+
// Fast refresh render.
167+
mockFastRefresh.mockReturnValueOnce(true);
168+
rerender({ handlers: { onScroll: handler }, deps: nextDeps });
169+
expect(result.current.doDependenciesDiffer).toBe(fastRefreshValue);
170+
171+
// Post-fast-refresh render.
172+
rerender({ handlers: { onScroll: handler }, deps: nextDeps });
173+
expect(result.current.doDependenciesDiffer).toBe(postRefreshValue);
174+
});
175+
}
176+
);
177+
178+
test('first-mount `[]` spec is still preserved (no false positive)', () => {
179+
// Guard against false positives on initial mount with [] deps.
180+
const { result } = renderUseHandler({ onScroll: jest.fn() }, []);
181+
expect(result.current.doDependenciesDiffer).toBe(false);
182+
});
183+
});
184+
});
97185
});

packages/react-native-reanimated/src/hook/useHandler.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import type { WorkletClosure } from 'react-native-worklets/lib/typescript/types'
77
import type { UnknownRecord } from '../common';
88
import { IS_WEB } from '../common';
99
import type { DependencyList, ReanimatedEvent } from './commonTypes';
10+
import { useIsFastRefresh } from './utils';
1011

1112
interface GeneralHandler<
1213
TEvent extends object,
@@ -147,11 +148,25 @@ export function useHandler<Event extends object, Context extends UnknownRecord>(
147148
prevDependencies: DependencyList;
148149
} | null>(null);
149150

151+
// On fast refresh we want the hook to behave as a first render rather
152+
// than a re-render, so `doDependenciesDiffer` is reported as true and
153+
// consumers can rebuild native bindings tied to pre-refresh JS identities.
154+
// See https://github.com/software-mansion/react-native-reanimated/pull/9075.
155+
const isFastRefresh = useIsFastRefresh();
156+
if (isFastRefresh) {
157+
stateRef.current = null;
158+
}
159+
150160
if (stateRef.current === null) {
151161
stateRef.current = {
152162
context: makeShareable({} as Context),
153163
prevHandlers: undefined,
154-
prevDependencies: [],
164+
// Only matters on the web/no-plugin path, where `areDependenciesEqual`
165+
// drives the result. Without the ternary, `deps=[]` consumers would
166+
// miss the fast-refresh behavior because `areDependenciesEqual([], [])`
167+
// is true. `undefined` ensures that the comparison returns `true` so
168+
// that consumers can re-register handlers.
169+
prevDependencies: isFastRefresh ? undefined : [],
155170
};
156171
}
157172

packages/react-native-reanimated/src/hook/utils.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,29 @@
11
'use strict';
2+
import { useMemo, useRef } from 'react';
3+
4+
/**
5+
* Returns `true` on the render immediately following a fast refresh.
6+
*
7+
* Relies on `useMemo`'s cached value being reset when the component's hook
8+
* signature is refreshed while `useRef` is preserved - a mismatch between the
9+
* two proves the module was re-executed. Always `false` outside `__DEV__` since
10+
* fast refresh is a development-only mechanism.
11+
*/
12+
/* eslint-disable react-hooks/rules-of-hooks -- __DEV__ is build-constant */
13+
export function useIsFastRefresh(): boolean {
14+
'use no memo';
15+
if (!__DEV__) {
16+
return false;
17+
}
18+
const signal = useMemo(() => ({}), []);
19+
const prev = useRef(signal);
20+
if (prev.current === signal) {
21+
return false;
22+
}
23+
prev.current = signal;
24+
return true;
25+
}
26+
/* eslint-enable react-hooks/rules-of-hooks */
227

328
export function isAnimated(prop: unknown) {
429
'worklet';

0 commit comments

Comments
 (0)