Skip to content

Commit ddd2fbb

Browse files
authored
Add web support without a Babel/SWC Plugin (software-mansion#3997)
## Description This PR adds support for using Reanimated without a plugin on Web. ### Before 😢 Without a Babel plugin (or SWC on Next.js), animations do nothing. https://user-images.githubusercontent.com/13172299/214991524-0819fb9d-d72f-4a2c-b067-3adff7818a50.mp4 ### After ✅ Everything now works flawlessly and reactively on Web, including `useAnimatedStyle`, `useDerivedValue`, and `useAnimatedReaction`. https://user-images.githubusercontent.com/13172299/214991572-c3a355c2-1a54-43d2-965b-916463b0e281.mp4 The only requirement to make this work is explicitly using dependency arrays. ## Changes In places where the `._closure` is undefined, we set the `inputs` to equal the `dependencies` (so long as they have been explicitly set). This makes Reanimated work on Web, even if there is no Babel/SWC plugin set. Lastly, I added a simple check in `Mapper` to make sure we aren't trying to get object keys of DOM elements. ## Test code and steps to reproduce Let me know the best way to do this. I tested the code in a separate repo outside of this one, and it works well on Web. Native won't be affected, since all changes are behind a `shouldBeUseWeb()` flag.
1 parent f624abb commit ddd2fbb

File tree

11 files changed

+267
-19
lines changed

11 files changed

+267
-19
lines changed

WebExample/App.tsx

+12-5
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,8 @@ import {
88
GestureDetector,
99
enableExperimentalWebImplementation,
1010
} from 'react-native-gesture-handler';
11-
import { StyleSheet, View } from 'react-native';
12-
13-
import { StatusBar } from 'expo-status-bar';
11+
import { StyleSheet, View, Text } from 'react-native';
12+
import { WithoutBabelTest } from './WithoutBabel';
1413

1514
enableExperimentalWebImplementation(true);
1615

@@ -53,10 +52,12 @@ export default function App() {
5352

5453
return (
5554
<View style={styles.container}>
56-
<StatusBar style="auto" />
5755
<GestureDetector gesture={gesture}>
58-
<Animated.View style={[styles.ball, animatedStyle]} />
56+
<Animated.View style={[styles.ball, animatedStyle]}>
57+
<Text style={styles.text}>I need Babel plugin</Text>
58+
</Animated.View>
5959
</GestureDetector>
60+
<WithoutBabelTest />
6061
</View>
6162
);
6263
}
@@ -74,5 +75,11 @@ const styles = StyleSheet.create({
7475
borderRadius: 100,
7576
backgroundColor: 'blue',
7677
alignSelf: 'center',
78+
justifyContent: 'center',
79+
alignItems: 'center',
80+
},
81+
text: {
82+
color: 'white',
83+
textAlign: 'center',
7784
},
7885
});

WebExample/WithoutBabel.tsx

+90
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Animated, {
2+
useAnimatedStyle,
3+
useSharedValue,
4+
withSpring,
5+
} from 'react-native-reanimated';
6+
import {
7+
Gesture,
8+
GestureDetector,
9+
enableExperimentalWebImplementation,
10+
} from 'react-native-gesture-handler';
11+
import { StyleSheet, Text } from 'react-native';
12+
import { useEffect, useState } from 'react';
13+
14+
enableExperimentalWebImplementation(true);
15+
16+
export function WithoutBabelTest() {
17+
const isPressed = useSharedValue(false);
18+
const offset = useSharedValue({ x: 0, y: 0 });
19+
20+
const [stateObject, rerender] = useState({});
21+
22+
const stateNumber = Math.random();
23+
const stateBoolean = stateNumber >= 0.5;
24+
25+
console.log('[without-babel][render]');
26+
27+
useEffect(function updateState() {
28+
const interval = setInterval(() => {
29+
rerender({});
30+
}, 1000);
31+
return () => clearInterval(interval);
32+
}, []);
33+
34+
const animatedStyle = useAnimatedStyle(() => {
35+
return {
36+
transform: [
37+
{ translateX: offset.value.x },
38+
{ translateY: offset.value.y },
39+
{ scale: withSpring(isPressed.value ? 1.2 : 1) },
40+
],
41+
backgroundColor: isPressed.value ? 'cyan' : 'hotpink',
42+
cursor: isPressed.value ? 'grabbing' : 'grab',
43+
};
44+
}, [isPressed, offset, stateObject, stateBoolean, stateNumber]);
45+
46+
const gesture = Gesture.Pan()
47+
.manualActivation(true)
48+
.onBegin(() => {
49+
'worklet';
50+
isPressed.value = true;
51+
})
52+
.onChange((e) => {
53+
'worklet';
54+
offset.value = {
55+
x: e.changeX + offset.value.x,
56+
y: e.changeY + offset.value.y,
57+
};
58+
})
59+
.onFinalize(() => {
60+
'worklet';
61+
isPressed.value = false;
62+
})
63+
.onTouchesMove((_, state) => {
64+
state.activate();
65+
});
66+
67+
return (
68+
<GestureDetector gesture={gesture}>
69+
<Animated.View style={[styles.ball, animatedStyle]}>
70+
<Text style={styles.text}>I work without Babel</Text>
71+
</Animated.View>
72+
</GestureDetector>
73+
);
74+
}
75+
76+
const styles = StyleSheet.create({
77+
ball: {
78+
width: 100,
79+
height: 100,
80+
borderRadius: 100,
81+
backgroundColor: 'hotpink',
82+
alignSelf: 'center',
83+
padding: 8,
84+
justifyContent: 'center',
85+
alignItems: 'center',
86+
},
87+
text: {
88+
textAlign: 'center',
89+
},
90+
});

WebExample/babel.config.js

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
module.exports = function (api) {
2-
api.cache(true);
2+
const disableBabelPlugin = process.env.DISABLE_BABEL_PLUGIN === '1';
3+
// https://babeljs.io/docs/en/config-files#apicache
4+
api.cache.invalidate(() => disableBabelPlugin);
5+
if (disableBabelPlugin) {
6+
console.log('Starting Web example without Babel plugin.');
7+
}
38
return {
49
presets: ['babel-preset-expo'],
510
plugins: [
@@ -15,7 +20,7 @@ module.exports = function (api) {
1520
},
1621
],
1722
'@babel/plugin-proposal-export-namespace-from',
18-
'react-native-reanimated/plugin',
19-
],
23+
!disableBabelPlugin && 'react-native-reanimated/plugin',
24+
].filter(Boolean),
2025
};
2126
};

WebExample/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"start": "expo start",
77
"android": "expo start --android",
88
"ios": "expo start --ios",
9-
"web": "expo start --web"
9+
"web": "expo start --web",
10+
"web:plugin": "DISABLE_BABEL_PLUGIN=1 expo start --web"
1011
},
1112
"dependencies": {
1213
"@expo/webpack-config": "^0.17.2",

WebExample/tsconfig.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
{
22
"extends": "expo/tsconfig.base",
33
"compilerOptions": {
4-
"strict": true
4+
"strict": true,
5+
"rootDir": "..",
6+
"paths": {
7+
"react-native-reanimated": ["./src"]
8+
}
59
}
610
}

docs/docs/fundamentals/web-support.md

+81
Original file line numberDiff line numberDiff line change
@@ -86,3 +86,84 @@ module.exports = {
8686
},
8787
};
8888
```
89+
90+
## Web without a Babel plugin
91+
92+
As of Reanimated `2.15`, the Babel plugin (`react-native-reanimated/plugin`) is optional on Web, with some additional configuration.
93+
94+
Reanimated hooks all accept optional dependency arrays. Under the hood, the Reanimated Babel plugin inserts these for you.
95+
96+
In order to use Reanimated without a Babel/SWC plugin, you need to explicitly pass the dependency array whenever you use a Reanimated hook.
97+
98+
Passing a dependency array is valid on both Web and native. Adding them will not negatively impact iOS or Android.
99+
100+
Make sure the following hooks have a dependency array as the last argument:
101+
102+
- `useDerivedValue`
103+
- `useAnimatedStyle`
104+
- `useAnimatedProps`
105+
- `useAnimatedReaction`
106+
107+
For example:
108+
109+
```ts
110+
const sv = useSharedValue(0);
111+
const dv = useDerivedValue(
112+
() => sv.value + 1,
113+
[sv] // dependency array here
114+
);
115+
```
116+
117+
Be sure to pass the dependency itself (`sv`) and not `sv.value` to the dependency array.
118+
119+
> Babel users will still need to install the `@babel/plugin-proposal-class-properties` plugin.
120+
121+
122+
### ESLint Support
123+
124+
When you use hooks from React, they give you nice suggestions from ESLint to include all dependencies. In order to add this support to Reanimated hooks, add the following to your ESLint config:
125+
126+
```json
127+
{
128+
"rules": {
129+
"react-hooks/exhaustive-deps": [
130+
"error",
131+
{
132+
"additionalHooks": "(useAnimatedStyle|useDerivedValue|useAnimatedProps)"
133+
}
134+
]
135+
}
136+
}
137+
```
138+
139+
This assumes you've already installed the `react-hooks` eslint [plugin](https://www.npmjs.com/package/eslint-plugin-react-hooks).
140+
141+
If you're using ESLint autofix, the ESLint plugin may add `.value` to the dependency arrays, rather than the root dependency. In these cases, you should update the array yourself.
142+
143+
```tsx
144+
const sv = useSharedValue(0)
145+
146+
// 🚨 bad, sv.value is in the array
147+
const dv = useDerivedValue(() => sv.value, [sv.value]);
148+
149+
// ✅ good, sv is in the array
150+
const dv = useDerivedValue(() => sv.value, [sv]);
151+
```
152+
153+
## Solito / Next.js Compatibility
154+
155+
There is an experimental SWC plugin in the works. However, given that this may not work properly, you can use the ["Web without a Babel plugin"](#web-without-a-babel-plugin) instructions above.
156+
157+
### Next.js Polyfill
158+
159+
In order to use Reanimated with Next.js / Solito, you'll need to add the `raf` polyfill for `requestAnimationFrame` to not throw on the server:
160+
161+
```sh
162+
yarn add raf
163+
```
164+
165+
Add the following to the top of your `_app.tsx`:
166+
167+
```ts
168+
import 'raf/polyfill'
169+
```

src/reanimated2/hook/useAnimatedReaction.ts

+12-5
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { BasicWorkletFunction, WorkletFunction } from '../commonTypes';
33
import { startMapper, stopMapper } from '../core';
44
import { DependencyList } from './commonTypes';
55
import { useSharedValue } from './useSharedValue';
6+
import { shouldBeUseWeb } from '../PlatformChecker';
67

78
export interface AnimatedReactionWorkletFunction<T> extends WorkletFunction {
89
(prepared: T, previous: T | null): void;
@@ -19,6 +20,16 @@ export function useAnimatedReaction<T>(
1920
dependencies: DependencyList
2021
): void {
2122
const previous = useSharedValue<T | null>(null);
23+
24+
let inputs = Object.values(prepare._closure ?? {});
25+
26+
if (shouldBeUseWeb()) {
27+
if (!inputs.length && dependencies?.length) {
28+
// let web work without a Babel/SWC plugin
29+
inputs = dependencies;
30+
}
31+
}
32+
2233
if (dependencies === undefined) {
2334
dependencies = [
2435
Object.values(prepare._closure ?? {}),
@@ -37,11 +48,7 @@ export function useAnimatedReaction<T>(
3748
react(input, previous.value);
3849
previous.value = input;
3950
};
40-
const mapperId = startMapper(
41-
fun,
42-
Object.values(prepare._closure ?? {}),
43-
[]
44-
);
51+
const mapperId = startMapper(fun, inputs, []);
4552
return () => {
4653
stopMapper(mapperId);
4754
};

src/reanimated2/hook/useAnimatedStyle.ts

+15-2
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ import {
2020
ViewDescriptorsSet,
2121
ViewRefSet,
2222
} from '../ViewDescriptorsSet';
23-
import { isJest } from '../PlatformChecker';
23+
import { isJest, shouldBeUseWeb } from '../PlatformChecker';
2424
import {
2525
AnimationObject,
2626
Timestamp,
@@ -400,7 +400,20 @@ export function useAnimatedStyle<T extends AnimatedStyle>(
400400
): AnimatedStyleResult {
401401
const viewsRef: ViewRefSet<any> = makeViewsRefSet();
402402
const initRef = useRef<AnimationRef>();
403-
const inputs = Object.values(updater._closure ?? {});
403+
let inputs = Object.values(updater._closure ?? {});
404+
if (shouldBeUseWeb()) {
405+
if (!inputs.length && dependencies?.length) {
406+
// let web work without a Babel/SWC plugin
407+
inputs = dependencies;
408+
}
409+
if (__DEV__ && !inputs.length && !dependencies && !updater.__workletHash) {
410+
throw new Error(
411+
`useAnimatedStyle was used without a dependency array or Babel plugin. Please explicitly pass a dependency array, or enable the Babel/SWC plugin.
412+
413+
For more, see the docs: https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/web-support#web-without-a-babel-plugin`
414+
);
415+
}
416+
}
404417
const adaptersArray: AdapterWorkletFunction[] = adapters
405418
? Array.isArray(adapters)
406419
? adapters

src/reanimated2/hook/useDerivedValue.ts

+8-1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { initialUpdaterRun } from '../animation';
33
import { BasicWorkletFunction, SharedValue } from '../commonTypes';
44
import { makeMutable, startMapper, stopMapper } from '../core';
55
import { DependencyList } from './commonTypes';
6+
import { shouldBeUseWeb } from '../PlatformChecker';
67

78
export type DerivedValue<T> = Readonly<SharedValue<T>>;
89

@@ -11,7 +12,13 @@ export function useDerivedValue<T>(
1112
dependencies: DependencyList
1213
): DerivedValue<T> {
1314
const initRef = useRef<SharedValue<T> | null>(null);
14-
const inputs = Object.values(processor._closure ?? {});
15+
let inputs = Object.values(processor._closure ?? {});
16+
if (shouldBeUseWeb()) {
17+
if (!inputs.length && dependencies?.length) {
18+
// let web work without a Babel/SWC plugin
19+
inputs = dependencies;
20+
}
21+
}
1522

1623
// build dependencies
1724
if (dependencies === undefined) {

src/reanimated2/js-reanimated/Mapper.ts

+32
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NestedObjectValues } from '../commonTypes';
2+
import { shouldBeUseWeb } from '../PlatformChecker';
23
import { JSReanimated } from './commonTypes';
34
import MutableValue from './MutableValue';
45

@@ -49,6 +50,9 @@ export default class Mapper<T> {
4950
res.push(value);
5051
} else if (Array.isArray(value)) {
5152
value.forEach((v) => extractMutables(v));
53+
} else if (isWebDomElement(value)) {
54+
// do nothing on dom elements
55+
// without this check, we get a "Maximum call size exceeded error"
5256
} else if (typeof value === 'object') {
5357
Object.keys(value).forEach((key) => {
5458
extractMutables(value[key]);
@@ -60,3 +64,31 @@ export default class Mapper<T> {
6064
return res;
6165
}
6266
}
67+
68+
function isWebDomElement(value: any) {
69+
if (!shouldBeUseWeb()) {
70+
return false;
71+
}
72+
73+
// https://stackoverflow.com/a/384380/7869175
74+
function isWebNode(o: any) {
75+
return typeof Node === 'object'
76+
? o instanceof Node
77+
: o &&
78+
typeof o === 'object' &&
79+
typeof o.nodeType === 'number' &&
80+
typeof o.nodeName === 'string';
81+
}
82+
83+
function isWebElement(o: any) {
84+
return typeof HTMLElement === 'object'
85+
? o instanceof HTMLElement // DOM2
86+
: o &&
87+
typeof o === 'object' &&
88+
o !== null &&
89+
o.nodeType === 1 &&
90+
typeof o.nodeName === 'string';
91+
}
92+
93+
return isWebNode(value) || isWebElement(value);
94+
}

0 commit comments

Comments
 (0)