Skip to content
Draft
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
7 changes: 7 additions & 0 deletions .changeset/fix-three-prefer-set-animation-loop-fp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"oxlint-plugin-react-doctor": patch
---

Fix `three-prefer-set-animation-loop` false positive on 2D canvas animations

The rule now checks for Three.js imports (`three`, `@react-three/*`) before reporting recursive `requestAnimationFrame` loops. This prevents false positives on 2D canvas/DOM animations in projects that have Three.js as a dependency but don't use it in specific files.
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,10 @@ describe("three-prefer-set-animation-loop", () => {
expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(1);
});

it("reports a recursive loop that delegates rendering to an imported viewer", () => {
it("reports a recursive loop with @react-three/fiber import", () => {
const code = `
import { Viewer } from "./scene/viewer";
const viewer = new Viewer(canvas);
import { Canvas } from "@react-three/fiber";
const viewer = setupViewer(canvas);
function frame() {
viewer.frame();
app.tick();
Expand All @@ -43,6 +43,19 @@ describe("three-prefer-set-animation-loop", () => {
expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(1);
});

it("allows a 2D canvas animation loop without Three.js imports", () => {
const code = `
const canvas = document.querySelector("canvas");
const context = canvas.getContext("2d");
function frame() {
context.fillRect(0, 0, 1, 1);
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
`;
expect(runRule(threePreferSetAnimationLoop, code).diagnostics).toHaveLength(0);
});

it("allows renderer-managed frames and unrelated or shadowed callbacks", () => {
const code = `
import { WebGLRenderer } from "three";
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { defineRule } from "../../utils/define-rule.js";
import type { EsTreeNode } from "../../utils/es-tree-node.js";
import type { EsTreeNodeOfType } from "../../utils/es-tree-node-of-type.js";
import { isGlobalAnimationFrameCallee } from "../../utils/is-global-animation-frame-callee.js";
import { resolveRecursiveAnimationFrameCallback } from "../../utils/resolve-recursive-animation-frame-callback.js";
import { hasThreeImport } from "./utils/has-three-import.js";

export const threePreferSetAnimationLoop = defineRule({
id: "three-prefer-set-animation-loop",
Expand All @@ -12,8 +14,13 @@ export const threePreferSetAnimationLoop = defineRule({
"Use renderer.setAnimationLoop for Three.js animation-loop compatibility, including WebXR",
create: (context) => {
const reportedCallbacks = new Set<EsTreeNode>();
let fileImportsThree = false;
return {
Program(node: EsTreeNodeOfType<"Program">) {
fileImportsThree = hasThreeImport(node, context.scopes);
},
CallExpression(node) {
if (!fileImportsThree) return;
if (!isGlobalAnimationFrameCallee(node.callee, context.scopes)) return;
const callback = resolveRecursiveAnimationFrameCallback(node, context.scopes, {
requireUnconditionalSchedule: true,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ScopeAnalysis } from "../../../semantic/scope-analysis.js";
import type { EsTreeNodeOfType } from "../../../utils/es-tree-node-of-type.js";
import { getGlobalRequireModuleSource } from "../../../utils/get-global-require-module-source.js";
import { isNodeOfType } from "../../../utils/is-node-of-type.js";
import { isTypeOnlyImport } from "../../../utils/is-type-only-import.js";
import { getModuleNamespaceSource } from "./get-module-namespace-source.js";

const isThreeModule = (moduleSource: string): boolean =>
moduleSource === "three" || moduleSource.startsWith("@react-three/");

export const hasThreeImport = (
program: EsTreeNodeOfType<"Program">,
scopes: ScopeAnalysis,
): boolean =>
program.body.some((statement) => {
if (
isNodeOfType(statement, "ImportDeclaration") &&
!isTypeOnlyImport(statement) &&
typeof statement.source.value === "string"
) {
return isThreeModule(statement.source.value);
}
if (isNodeOfType(statement, "TSImportEqualsDeclaration")) {
const moduleSource = getModuleNamespaceSource(statement.id, scopes);
return moduleSource !== null && isThreeModule(moduleSource);
}
if (isNodeOfType(statement, "ExpressionStatement")) {
const moduleSource = getGlobalRequireModuleSource(statement.expression, scopes);
return moduleSource !== null && isThreeModule(moduleSource);
}
if (!isNodeOfType(statement, "VariableDeclaration")) return false;
return statement.declarations.some((declaration) => {
if (!declaration.init) return false;
const moduleSource = getGlobalRequireModuleSource(declaration.init, scopes);
return moduleSource !== null && isThreeModule(moduleSource);
});
});
Loading