plans/029-frameloop-test-gaps.md
Executor instructions: Follow this plan step by step. Run every verification command and confirm the expected result before moving to the next step. If anything in the "STOP conditions" section occurs, stop and report — do not improvise. When done, update the status row for this plan in
plans/README.md— unless a reviewer dispatched you and told you they maintain the index.Drift check (run first):
git diff --stat 42bfbe3ed..HEAD -- packages/motion-dom/src/frameloop/Expected drift: plans 027/028 may have landed (try/finally inbatcher.ts/render-step.ts, an extrathisFrame.deleteincancel, a new__tests__/batcher.test.ts, two extra tests in__tests__/index.test.ts). That drift is fine. Iforder.ts, the step names, or the delta computation inbatcher.tschanged, compare against "Current state" and STOP on mismatch.
42bfbe3ed, 2026-06-11The frameloop is the substrate under every animation in Motion, but its test
suite (__tests__/index.test.ts, 9 tests at planning time) only exercises the
frame singleton: the step-order test covers 5 of the 8 steps (missing
setup, resolveKeyframes, preUpdate), the microtask batcher has zero
tests, MotionGlobalConfig.useManualTiming batching (the mechanism Jest tests
and the Remotion integration rely on) is untested, and the frame-delta
computation (default 1000/60 first frame, clamp to [1, 40]ms) is untested.
These are characterization tests: they pin current behavior so future
frameloop work (e.g. the effects/VisualElement unification) can refactor with
confidence.
Files (all under packages/motion-dom/src/frameloop/):
order.ts — the canonical step order:export const stepsOrder: StepId[] = [
"setup", // Compute
"read", // Read
"resolveKeyframes", // Write/Read/Write/Read
"preUpdate", // Compute
"update", // Compute
"preRender", // Compute
"render", // Write
"postRender", // Compute
] as const
__tests__/index.test.ts:4-27 — order test covers only
read → update → preRender → render → postRender.microtask.ts — createRenderBatcher(queueMicrotask, false); exports
microtask, cancelMicrotask. Untested.batcher.ts:42-55 — timestamp/delta logic:const useManualTiming = MotionGlobalConfig.useManualTiming
const timestamp = useManualTiming
? state.timestamp
: performance.now()
...
if (!useManualTiming) {
state.delta = useDefaultElapsed
? 1000 / 60
: Math.max(Math.min(timestamp - state.timestamp, maxElapsed), 1)
}
state.timestamp = timestamp
maxElapsed is 40 (batcher.ts:6). useDefaultElapsed starts true, is
reset to true by wake(), and set false when a batch reschedules the
next one — so the first frame after idle reports delta === 1000/60 and
subsequent keepAlive frames report the real (clamped) elapsed time.
frameData.timestamp is a module-level singleton that persists across
tests in a file — prefer locally-created batchers
(createRenderBatcher) over the frame singleton for timing assertions.MotionGlobalConfig.useManualTiming is global state — always restore it
in afterEach, or unrelated tests in the same Jest worker will break.__tests__/batcher.test.ts already exists with a
createTestBatcher helper that captures scheduleNextBatch — reuse it.
If not, create the file and the helper as specified in Step 3.| Purpose | Command (from repo root) | Expected on success |
|---|---|---|
| Frameloop tests | npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop" | all pass |
| Full motion-dom tests | npx jest --config packages/motion-dom/jest.config.json --max-workers=2 | same pass/fail set as pre-change run |
| Typecheck | npx tsc --noEmit -p packages/motion-dom/tsconfig.json | exit 0 |
| Lint | yarn lint | exit 0 |
In scope (test files only):
packages/motion-dom/src/frameloop/__tests__/index.test.ts (extend the order test)packages/motion-dom/src/frameloop/__tests__/microtask.test.ts (create)packages/motion-dom/src/frameloop/__tests__/batcher.test.ts (create if absent, else extend)Out of scope (do NOT touch):
__tests__ in other packages.advisor/029-frameloop-test-gapsIn __tests__/index.test.ts, update "fires callbacks in the correct order" to
schedule one callback per step — setup, read, resolveKeyframes,
preUpdate, update, preRender, render, postRender — pushing
0..7, asserting the full sequence in the postRender callback (follow the
existing test's promise/resolve-reject shape). Import nothing new; frame
already exposes all 8 (Batcher maps every StepId).
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop" → all pass.
__tests__/microtask.test.tsTest the microtask singleton (import { microtask, cancelMicrotask } from
../microtask). Three tests:
microtask.render(spy); assert not called synchronously; await Promise.resolve()
(flush microtasks) — possibly twice (await null twice) since the flush
itself is queued as a microtask; assert called once.render, read, update (deliberately out of
order), collect call order, flush, assert read → update → render.cancelMicrotask cancels: schedule spy, cancel it, flush, assert not
called.Keep each test's scheduling self-contained; the microtask batcher shares module state across tests in the file, so don't assert on timestamps.
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop/__tests__/microtask" → 3 pass.
__tests__/batcher.test.tsIf the file doesn't exist yet (plan 027 not landed), create it with this helper; otherwise reuse the existing one:
import { MotionGlobalConfig } from "motion-utils"
import { createRenderBatcher } from "../batcher"
function createTestBatcher(allowKeepAlive: boolean) {
let queued: Function | undefined
const batcher = createRenderBatcher((cb) => (queued = cb), allowKeepAlive)
return {
...batcher,
flush: () => {
const batch = queued
queued = undefined
batch?.()
},
hasQueuedBatch: () => queued !== undefined,
}
}
Add a describe("batcher timing") block with:
jest.spyOn(performance, "now").mockReturnValue(1000); new test batcher
(keepAlive true); schedule a spy on update capturing delta; flush;
assert delta === 1000 / 60.useDefaultElapsed becomes false): schedule with
keepAlive: true, flush at now = 1000, advance mock to 1200, flush
again; assert the second invocation's delta === 40.0.2
(e.g. 1200.2); assert delta === 1.state.timestamp and skips delta computation:
MotionGlobalConfig.useManualTiming = true (restore in
afterEach(() => { MotionGlobalConfig.useManualTiming = false })); new
test batcher; set batcher.state.timestamp = 500 and
batcher.state.delta = 123 directly; schedule a spy on update; flush;
assert the spy received timestamp === 500 and delta === 123
(i.e. neither was recomputed from performance.now()).Restore the performance.now spy in afterEach (jest.restoreAllMocks()).
Tests 1–3 may share one batcher in sequence (the progression default → real →
clamped is itself the behavior under test); test 4 uses a fresh batcher.
Verify: npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop/__tests__/batcher" → all pass.
Verify, in order:
npx jest --config packages/motion-dom/jest.config.json --max-workers=2
→ no new failures vs a clean-tree run.npx tsc --noEmit -p packages/motion-dom/tsconfig.json → exit 0.yarn lint → exit 0.--testPathPattern="frameloop") → identical results (guards against
MotionGlobalConfig/spy leakage between files).This plan is the test plan: +1 extended order test, +3 microtask tests,
+4 batcher timing tests, all characterization (passing against current
source). Pattern to follow: promise-style tests in
__tests__/index.test.ts for singleton tests; synchronous captured-scheduler
style for batcher.test.ts.
Machine-checkable. ALL must hold:
npx jest --config packages/motion-dom/jest.config.json --testPathPattern="frameloop" exits 0, with ≥8 more passing tests than the pre-plan runstepsOrder sequencenpx tsc --noEmit -p packages/motion-dom/tsconfig.json exits 0yarn lint exits 0git status shows only the 3 in-scope test files modified/created — zero source-file changesplans/README.md status row for 029 updatedStop and report back (do not improvise) if:
stepsOrder in order.ts no longer matches the excerpt above.afterEach restore doesn't resolve it.worktree-style-effect) and any future Remotion/
manual-timing work will lean on — useManualTiming semantics in
particular. If that work intentionally changes batching semantics, these
tests are the place the change must be made visible.maxElapsed = 40 and the 1000/60 first-frame
default as contract. If a future change makes the first-frame delta
display-rate-aware, update test 1 deliberately.microtask flush vs the
frame rAF batch (JSDOM's rAF is setTimeout-based, so the ordering
proven here wouldn't prove anything about browsers).