From f7b6d1ff9e4d27752142f404423591aca746f30b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 17:34:56 +0000 Subject: [PATCH 1/2] fix: three-prefer-set-animation-loop only fires when file imports Three.js The rule was firing on any recursive requestAnimationFrame loop in projects with three as a dependency, even for 2D canvas animations that never use Three.js. Now the rule checks for file-level Three.js imports (three, @react-three/*) before reporting. This prevents false positives on 2D canvas/DOM animation loops while still catching real Three.js animation frame usage. Closes #1795 Co-authored-by: Skosh --- .../three-prefer-set-animation-loop.test.ts | 19 ++++++++-- .../r3f/three-prefer-set-animation-loop.ts | 7 ++++ .../rules/r3f/utils/has-three-import.ts | 37 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/has-three-import.ts diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts index 911a1e6c8a..1da268a5f0 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.test.ts @@ -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(); @@ -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"; diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts index 1d6a3e2dbe..ec58c16ca1 100644 --- a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts @@ -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", @@ -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(); + 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, diff --git a/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/has-three-import.ts b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/has-three-import.ts new file mode 100644 index 0000000000..c897eca51a --- /dev/null +++ b/packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/utils/has-three-import.ts @@ -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); + }); + }); From a9158372638ad47ba405b0d788bbedd13492e507 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 10 Sep 2026 17:39:56 +0000 Subject: [PATCH 2/2] chore: add changeset for three-prefer-set-animation-loop fix Co-authored-by: Skosh --- .changeset/fix-three-prefer-set-animation-loop-fp.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/fix-three-prefer-set-animation-loop-fp.md diff --git a/.changeset/fix-three-prefer-set-animation-loop-fp.md b/.changeset/fix-three-prefer-set-animation-loop-fp.md new file mode 100644 index 0000000000..f9054f0a70 --- /dev/null +++ b/.changeset/fix-three-prefer-set-animation-loop-fp.md @@ -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.