Skip to content

three-prefer-set-animation-loop: 2D canvas requestAnimationFrame loops in three-capable projects #1795

Description

@stamatim

three-prefer-set-animation-loop reports a 2D canvas requestAnimationFrame loop whenever the package depends on three, even when the file never imports Three.js and never constructs a renderer.

Applying the recommended renderer.setAnimationLoop(...) fix is invalid here: CanvasRenderingContext2D has no such API, so the “fix” would throw at runtime.

Reproduction

Reproduced with React Doctor 0.9.13, on macOS. JSON report: schemaVersion: 3, ok: true, project complete: true, hasThree: true. This is a static-analysis reproduction; no runtime WebXR or WebGL failure is inferred from the warning.

Create a directory with this package.json, add the file below, and run:

npx --yes react-doctor@0.9.13 . --scope full --json --blocking none --yes --no-score
{
  "name": "react-doctor-raf-fp",
  "private": true,
  "version": "1.0.0",
  "dependencies": {
    "react": "19.2.4",
    "three": "0.185.1"
  }
}

dither.tsx:

import { useEffect, useRef } from "react";

export function DitherBackground() {
  const canvasRef = useRef<HTMLCanvasElement | null>(null);

  useEffect(() => {
    const canvas = canvasRef.current;
    if (!canvas) {
      return;
    }
    const context = canvas.getContext("2d");
    if (!context) {
      return;
    }

    let animationFrame = 0;
    const drawFrame = () => {
      context.clearRect(0, 0, 1, 1);
      context.fillRect(0, 0, 1, 1);
    };
    const animate = () => {
      drawFrame();
      animationFrame = requestAnimationFrame(animate);
    };
    animationFrame = requestAnimationFrame(animate);
    return () => cancelAnimationFrame(animationFrame);
  }, []);

  return <canvas ref={canvasRef} />;
}

Actual

“This continuous Three.js animation loop is driven by requestAnimationFrame. Use renderer.setAnimationLoop(callback) for renderer-managed timing and compatibility”

The file has no three import, no WebGLRenderer / WebGPURenderer, and no WebXR session. requestAnimationFrame is the correct clock for a 2D canvas.

Expected

Do not report a recursive requestAnimationFrame loop unless the same file (or a proven Three.js renderer in scope) is actually driving a Three.js renderer.

The rule is project-gated on the three capability and then matches any global unconditional requestAnimationFrame self-schedule. That is enough to flag every 2D canvas / DOM animation loop in an app that also has an unrelated R3F scene.

Suggested regression (should be length 0):

it("allows a 2D canvas animation loop that never constructs a Three.js renderer", () => {
  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);
});

Notes

Rule source: packages/oxlint-plugin-react-doctor/src/plugin/rules/r3f/three-prefer-set-animation-loop.ts reports every unconditional recursive requestAnimationFrame with no renderer check.

The existing test “reports a recursive loop that delegates rendering to an imported viewer” encodes that breadth. Tests that construct WebGLRenderer are the intended hits; a same-file getContext("2d") loop should not inherit them just because package.json lists three.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions