check_motion Rules

The twenty-one rules check_motion enforces, what each one catches, and why none of them show up as build errors.

check_motion is a static analysis linter designed for motion, WebGL, and animation code in React and Three.js applications. It identifies performance bottlenecks, lifecycle bugs, and architectural flaws that pass standard TypeScript compilation but cause runtime frame drops, memory leaks, or unhandled GPU context loss.

Each diagnostic includes the target file, line number, rule identifier, and concrete remediation steps. Where runtime primitives address the issue natively, the rule specifies the corresponding hook or pattern.

Running it

Through an AI coding assistant connected via MCP, request a check using natural language:

Check this file with check_motion.

The command accepts a specific file path or evaluates active working files. Rules are bundled directly within the Vector Vesper CLI without requiring separate package installations. See the MCP Server guide for editor integration details.

Severities

  • Error: Critical faults that trigger runtime exceptions, unhandled memory leaks, or silent failures that break core component behavior.
  • Warning: Architectural or performance defects (such as unmanaged animation loops, layout thrashing, or missing context-loss handlers) that degrade frame rates or cause instability under load.
  • Info: Advisory notices where static single-file analysis cannot fully verify prop propagation across component boundaries.

Errors

RuleWhat it catchesThe fix
client-boundaryA client-only hook in a file with no client directive.Add "use client" as the first line.
ssr-module-scopeA browser-only value read at module scope. It is undefined on the server and throws at import time.Move the access inside a component, an effect, or an event handler.
orphan-subscriptionA subscription whose teardown was discarded. Nothing can stop it afterwards: it survives unmount, and every route change adds another.Keep the returned function and call it on unmount, or subscribe through useTick, which unsubscribes itself when the component goes.
canvas-prop-overrideA <Canvas> given the adapter's props and then handed the same setting directly. React Three Fiber reconciles from props on every render, so the later prop wins and the adapter's decision is discarded, with no error and no type complaint.Delete the duplicate prop and let the spread supply it. Quality levels belong in the profiles argument to useRenderQuality.

Warnings

RuleWhat it catchesThe fix
orphan-rafA private requestAnimationFrame loop. Independent loops each schedule their own frame and cannot yield under load, which is the coordination the runtime exists to provide.Subscribe to the shared conductor with useTick instead.
missing-cleanupA listener, observer or timeline created and never torn down. Repeated mounts stack copies that compound into jank.Return a cleanup from the effect. For pointer, scroll and viewport, read them from useSensorBus, which attaches one set of listeners for the whole page and removes them itself.
setstate-per-frameReact state written on every frame. At 60–120 writes a second this is the single most common cause of animation jank.Write to a ref or straight to the DOM from useTick, which runs outside React. If the render genuinely needs the value, throttle it to a few times a second.
lane-disciplineA measurement taken in the write lane, which forces the browser to recalculate layout mid-frame, or a DOM write in the read lane, which dirties layout underneath every reader still measuring.Read in input, compute in update, write in render: the three lanes useTick takes as its first argument. Pass values between lanes through a ref.
transform-conflictTwo owners animating one element's transform, overwriting each other frame to frame.Give the hook its own element and move the other transform to a wrapper or child.
no-reduced-motionAutonomous motion with no prefers-reduced-motion guard, including a three.js scene drawn from its own loop. People who set that preference, often for vestibular reasons, still get the full motion.Gate it behind the preference, or read useAdaptiveQuality, which folds the preference into the quality signal. A three.js scene gets this from useThreeScene.
canvas-partial-spreaduseRenderQuality called but its result picked apart instead of spread. It returns four things, so choosing fields means choosing which of its four jobs still happens.Spread the whole object: <Canvas key={scene.generation} {...canvas}>.
webgl-context-lossNothing in the file notices a lost graphics context, whether it comes from an R3F canvas, getContext("webgl") or a hand-built new THREE.WebGLRenderer(). The canvas goes black permanently and nothing is logged, so it reads as a rendering bug rather than a recoverable event. Also raised when a file reports the loss but never calls reportHealthy().Spread useRenderQuality's props and key the canvas on a scene gate's generation. For plain three.js, use useThreeScene. For a hand-made context, attach a webglcontextlost listener that calls preventDefault() and reports with getRendererHealth().reportLost({ builtAt }), then call reportHealthy() once the replacement draws.
webgpu-device-lossA WebGPU device taken and never watched. A WebGPU canvas never fires webglcontextlost, so a listener written for WebGL will not hear this one.Pass the device to watchGPUDevice, and key the scene on generation. Through React Three Fiber, spreading useRenderQuality's props does both.
webgpu-destroy-not-a-lossA device.lost handler that treats every resolution as a failure. It also resolves when your own code calls destroy(), so unmounting reports a loss, which rebuilds the page, which unmounts more scenes.Return early when info?.reason === "destroyed", or use watchGPUDevice, which does it and coalesces simultaneous losses into one rebuild.
offscreen-renderA hand-built three.js scene drawn from its own loop with nothing to stop it off screen. Nobody sees those frames, and they are taken from whatever is on screen.Draw on the shared conductor and skip frames while the scene is out of view, or let useThreeScene do it.
uncapped-pixel-ratiosetPixelRatio(window.devicePixelRatio) with no cap. A 3x phone is asked for nine times the pixels of a 1x screen, and the ratio never comes down when the GPU cannot keep up.Cap it, Math.min(devicePixelRatio, 2), and lower it when frames slip. useThreeScene does both, and useRenderQuality never goes above the screen's own ratio.
unreleased-contextA hand-built renderer that is disposed but never gives its context back. dispose() frees three's resources, not the context. Browsers cap live contexts per page, and the oldest, usually the hero, is the first to go.Remove the loss listener, then call renderer.forceContextLoss() after renderer.dispose(). useThreeScene does this when the scene unmounts.
renderer-rebuilt-on-changeAn effect that builds a renderer and runs again whenever a value such as the quality changes. Every change throws the context away and makes a new one: one page went from 3 contexts to 7 in 20 seconds.Build the renderer once and apply changes to it with setPixelRatio and setSize. useThreeScene builds once and hands the live quality to update.

Info

RuleWhat it catchesThe fix
hand-rolled-scene-gateAn IntersectionObserver flipping React state to gate a scene. Viewport proximity alone will mount a heavy scene mid-flick on a saturated thread, at full quality, on hardware that cannot hold it.useSceneGate, which answers visibility, headroom, quality and context recovery together.
adapter-props-to-wrapperuseRenderQuality's props spread onto a component that is not <Canvas>. A wrapper absorbs them into its rest parameter and forwards them wherever it likes.Open the wrapper and confirm it spreads what it receives onto its <Canvas>, onCreated included. Otherwise spread onto the <Canvas> directly.
webgpu-renderer-class-checkinstanceof WebGPURenderer used to pick a code path. three catches a failed WebGPU init and swaps in a WebGL backend while keeping the same object, so the class can say WebGPU on a canvas that is really WebGL.Ask the backend: renderer.backend?.isWebGPUBackend. Better still, wire both paths: the WebGL listener is inert on a real WebGPU canvas and saves the fallback.

Common Pitfalls and Edge Cases

While most linter rules detect straightforward configuration errors, several critical rules prevent subtle runtime failures that static TypeScript checks cannot catch.

Prop Forwarding to Canvas (`adapter-props-to-wrapper`)

The useRenderQuality hook provides essential canvas configuration properties, most notably onCreated, which binds WebGL context loss and restoration listeners alongside frameloop and dpr.

When spreading these adapter props onto a custom wrapper component instead of directly onto <Canvas>, the wrapper must explicitly forward them to the underlying <Canvas> element. If the wrapper absorbs these props via rest parameters (...rest) and applies them to a child container or mesh, the context loss listener and adaptive quality settings are silently dropped.

Because TypeScript permits arbitrary props in rest spreads, this issue produces no compile-time errors. The scene appears functional initially, but context loss recovery will fail silently when GPU resources are reclaimed.

The adapter-props-to-wrapper rule is classified as advisory because single-file static analysis cannot inspect wrapper internals. It prompts developers to verify that the canvas element actually receives the adapter props.

Intentional Destruction vs. Hardware Loss (`webgpu-destroy-not-a-loss`)

In WebGPU, the device.lost Promise resolves with a GPUDeviceLostInfo object whenever a device becomes unavailable. The reason property distinguishes between intentional cleanups and unexpected failures:

  • reason === "destroyed": The device was intentionally released by application code calling device.destroy(), such as during component unmounting.
  • reason === "unknown": The device failed unexpectedly due to GPU hang, driver crash, or memory limits.

If a loss handler fails to inspect info.reason, intentional component teardowns are misidentified as device failures. This triggers recursive recovery loops where tearing down a scene initiates a rebuild, creating and tearing down additional devices in an uncontrolled cycle.

The webgpu-destroy-not-a-loss rule flags handlers that omit info.reason verification, ensuring scene recovery only fires on genuine hardware loss.

Forced Reflow and Execution Lanes (`lane-discipline`)

Motion pipelines separate operations into distinct execution phases (measure lanes for reading layout and render lanes for mutations) to prevent layout thrashing.

Executing layout queries (such as getBoundingClientRect(), offsetWidth, or scrollTop) inside the render lane forces the browser to synchronously recalculate layout on every animation frame. While individual frame logic may run in milliseconds, synchronous reflows can stall the main thread for 16ms to 50ms per frame.

The lane-discipline rule detects DOM layout reads executed inside render and mutation callbacks, ensuring all measurements are completed in designated read phases before render passes begin.

Static Analysis Limitations

Static linting evaluates individual files in isolation without whole-program type tracing. Consequently, check_motion has the following known boundaries:

  • Component Wrapper Prop Forwarding: The linter detects when adapter props are passed to a wrapper component, but cannot verify whether the wrapper forwards them to an internal <Canvas>.
  • Destructured Prop Omissions: If a custom wrapper manually destructures individual properties from the adapter and omits one (such as onCreated), single-file static analysis cannot detect the omitted prop across file boundaries. Runtime health monitoring and context generation counters should be used to verify active subscriptions.