Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/fix-1784-native-driver-animated.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"oxlint-plugin-react-doctor": patch
---

Fix an `rn-prefer-reanimated` false positive on files whose `Animated` calls all run with `useNativeDriver: true`, while still flagging any JS-driven animation in the same file.
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,110 @@ describe("react-native/rn-prefer-reanimated — regressions", () => {
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics.length).toBeGreaterThan(0);
});

it("stays silent when the only animation loops a native-driver timing", () => {
const result = runRule(
rnPreferReanimated,
`import { useEffect, useState } from "react";
import { Animated } from "react-native";

export function NativePulse() {
const [opacity] = useState(() => new Animated.Value(1));
useEffect(() => {
const animation = Animated.loop(
Animated.timing(opacity, {
toValue: 0.7,
duration: 2000,
useNativeDriver: true,
isInteraction: false,
}),
);
animation.start();
return () => animation.stop();
}, [opacity]);
return <Animated.View style={{ opacity }} />;
}`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("stays silent when the native-driver config comes from a const object", () => {
const result = runRule(
rnPreferReanimated,
`import { Animated } from "react-native";
const FADE_CONFIG = { toValue: 1, duration: 300, useNativeDriver: true };
export const fadeIn = (value) => Animated.timing(value, FADE_CONFIG).start();`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("stays silent when an aliased Animated import only runs native-driver springs and events", () => {
const result = runRule(
rnPreferReanimated,
`import { Animated as RNAnimated } from "react-native";
export const useScroll = (scrollY, scale) => ({
onScroll: RNAnimated.event([{ nativeEvent: { contentOffset: { y: scrollY } } }], { useNativeDriver: true }),
press: () => RNAnimated.spring(scale, { toValue: 0.9, useNativeDriver: true }).start(),
});`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toEqual([]);
});

it("still flags a timing with useNativeDriver: false", () => {
const result = runRule(
rnPreferReanimated,
`import { Animated } from "react-native";
export const grow = (height) => Animated.timing(height, { toValue: 200, useNativeDriver: false }).start();`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
});

it("still flags a timing that omits useNativeDriver", () => {
const result = runRule(
rnPreferReanimated,
`import { Animated } from "react-native";
export const grow = (height) => Animated.timing(height, { toValue: 200, duration: 300 }).start();`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
});

it("does not let one native-driver call exempt a JS-thread animation in the same file", () => {
const result = runRule(
rnPreferReanimated,
`import { Animated } from "react-native";
export const pulse = (opacity, height) => {
Animated.timing(opacity, { toValue: 0.5, useNativeDriver: true }).start();
Animated.timing(height, { toValue: 120, useNativeDriver: false }).start();
};`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
});

it("still flags a native-driver animation when a LayoutAnimation import sits alongside it", () => {
const result = runRule(
rnPreferReanimated,
`import { Animated, LayoutAnimation } from "react-native";
export const fade = (opacity) => Animated.timing(opacity, { toValue: 0, useNativeDriver: true }).start();`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
expect(result.diagnostics[0].message).toContain("LayoutAnimation");
});

it("ignores timing calls on an Animated that is not the react-native export", () => {
const result = runRule(
rnPreferReanimated,
`import { Animated } from "react-native";
import Reanimated from "react-native-reanimated";
export const spin = (value) => Reanimated.timing(value, { toValue: 1, useNativeDriver: true });`,
);
expect(result.parseErrors).toEqual([]);
expect(result.diagnostics).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
import { defineRule } from "../../utils/define-rule.js";
import { isTypeOnlyImport } from "../../utils/is-type-only-import.js";
import type { RuleContext } from "../../utils/rule-context.js";
import type { ScopeAnalysis } from "../../semantic/scope-analysis.js";
import { isNodeOfType } from "../../utils/is-node-of-type.js";
import { getImportedName } from "../../utils/get-imported-name.js";
import { getStaticObjectPropertyValue } from "../../utils/get-static-object-property-value.js";
import { getStaticPropertyName } from "../../utils/get-static-property-name.js";
import { resolveConstIdentifierAlias } from "../../utils/resolve-const-identifier-alias.js";
import { resolveImportedApiReference } from "../../utils/resolve-imported-api-reference.js";
import type { EsTreeNode } from "../../utils/es-tree-node.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";

const JS_THREAD_ANIMATION_IMPORTS = new Set(["Animated", "LayoutAnimation"]);
const REACT_NATIVE_MODULE = "react-native";
const ANIMATED_IMPORT_NAME = "Animated";
const JS_THREAD_ANIMATION_IMPORTS = new Set([ANIMATED_IMPORT_NAME, "LayoutAnimation"]);

// The `Animated` APIs that take a `useNativeDriver` config; `loop` /
// `sequence` / `parallel` only compose these.
const ANIMATION_CONFIG_METHODS = new Set(["timing", "spring", "decay", "event"]);
const ANIMATION_CONFIG_ARGUMENT_INDEX = 1;
const USE_NATIVE_DRIVER_PROPERTY = "useNativeDriver";

const isReactNativeAnimatedReference = (node: EsTreeNode, scopes: ScopeAnalysis): boolean => {
const reference = resolveImportedApiReference(node, scopes);
return (
reference?.source === REACT_NATIVE_MODULE && reference.importedName === ANIMATED_IMPORT_NAME
);
};

const isNativeDriverConfig = (config: EsTreeNode | undefined, scopes: ScopeAnalysis): boolean => {
if (!config) return false;
const configObject = resolveConstIdentifierAlias(config, scopes)?.initializer ?? config;
const driverValue = getStaticObjectPropertyValue(configObject, USE_NATIVE_DRIVER_PROPERTY);
return Boolean(driverValue && isNodeOfType(driverValue, "Literal") && driverValue.value === true);
};

export const rnPreferReanimated = defineRule({
id: "rn-prefer-reanimated",
Expand All @@ -15,27 +43,54 @@ export const rnPreferReanimated = defineRule({
severity: "warn",
recommendation:
"Use `import Animated from 'react-native-reanimated'` so animations run on the UI thread instead of the JS thread, which keeps them smooth.",
create: (context: RuleContext) => ({
ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) {
if (node.source?.value !== "react-native") return;
if (isTypeOnlyImport(node)) return;

for (const specifier of node.specifiers ?? []) {
if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
if (specifier.importKind === "type") continue;
const importedName = getImportedName(specifier);
if (!importedName || !JS_THREAD_ANIMATION_IMPORTS.has(importedName)) continue;

const suggestion =
importedName === "LayoutAnimation"
? "Your users see stutter when LayoutAnimation runs on the JS thread."
: "Your users see stutter when Animated from react-native runs on the JS thread.";

context.report({
node: specifier,
message: suggestion,
});
}
},
}),
create: (context: RuleContext) => {
const animatedSpecifiers: EsTreeNodeOfType<"ImportSpecifier">[] = [];
let hasNativeDriverAnimation = false;
let hasJsThreadAnimation = false;

return {
ImportDeclaration(node: EsTreeNodeOfType<"ImportDeclaration">) {
if (node.source?.value !== REACT_NATIVE_MODULE) return;
if (isTypeOnlyImport(node)) return;

for (const specifier of node.specifiers ?? []) {
if (!isNodeOfType(specifier, "ImportSpecifier")) continue;
if (specifier.importKind === "type") continue;
const importedName = getImportedName(specifier);
if (!importedName || !JS_THREAD_ANIMATION_IMPORTS.has(importedName)) continue;

if (importedName === ANIMATED_IMPORT_NAME) {
animatedSpecifiers.push(specifier);
continue;
}
context.report({
node: specifier,
message: "Your users see stutter when LayoutAnimation runs on the JS thread.",
});
}
},
CallExpression(node: EsTreeNodeOfType<"CallExpression">) {
if (!isNodeOfType(node.callee, "MemberExpression")) return;
const methodName = getStaticPropertyName(node.callee);
if (!methodName || !ANIMATION_CONFIG_METHODS.has(methodName)) return;
if (!isReactNativeAnimatedReference(node.callee.object, context.scopes)) return;

if (isNativeDriverConfig(node.arguments[ANIMATION_CONFIG_ARGUMENT_INDEX], context.scopes)) {
hasNativeDriverAnimation = true;
} else {
hasJsThreadAnimation = true;
}
},
"Program:exit"() {
if (hasNativeDriverAnimation && !hasJsThreadAnimation) return;
for (const specifier of animatedSpecifiers) {
context.report({
node: specifier,
message:
"Your users see stutter when Animated from react-native runs on the JS thread.",
});
}
},
};
},
});
Loading