storytime · the story of a change

excalidraw · PR #11377 · mtolmacs/fix/battery-usemaster · 12 files, +282 −171 · generated 2026-06-11

Stop Excalidraw from draining your battery while the canvas sits idle

Every Excalidraw page was waking the browser 60+ times a second from load to unload — even untouched. This makes stillness actually still: nothing to animate, nothing scheduled, CPU asleep.

Act 1 · The picture

Excalidraw has three pointer effects — the laser trail, the eraser trail, the lasso — and they all rely on the browser's animation heartbeat: “wake me before the next screen repaint.” The bug: one of those effects asked to be woken on every repaint, forever, from the moment the canvas appeared — whether or not you ever used the laser, whether or not anyone was collaborating. Each wake-up did almost nothing; the cost was that your laptop could never idle. That's the battery drain.

The fix is not “make the loop cheaper” — it's “make zero loops the resting state.” Every animation must now answer, on every frame, “do you need another one?” — and the moment no animation says yes, the scheduler cancels the pending wake-up entirely.

Act 2 · The journey

Page loadAn engine left running at a red light

The three trail effects start the moment the canvas mounts. The laser effect then hooked a callback into the browser's animation heartbeat whose entire body was a poll — “did collaborator laser pointers change?” — and whose contract had no way to say “I'm done.” The browser read that as keep going, and did, for the lifetime of the page.

Show me the code — the loop with no exit (deleted by this PR)
old laser-trails.ts:76–78
-  onFrame() {
-    this.updateCollabTrails();
-  }

Returned void, which the old handler read as “keep going” (old animation-frame-handler.ts:50-51); started at canvas mount via SVGLayer.tsx:14-19. The whole AnimationFrameHandler (−79 lines) is deleted with it.

The fixIdle means zero scheduled work

All animations move onto one global scheduler, rewritten around a single idea: instead of a boolean “am I running?” flag, it holds a handle on the actually-scheduled frame — something that can always be cancelled, and that can't drift out of sync. The moment its animation map goes empty, it cancels the pending frame. Trails now rent frames: each frame they answer “keep me” or “I'm done,” and done deregisters them. The next pointer stroke simply registers again.

The old flag wasn't just wasteful — it could get stuck. Reviewer dwelle's repro: laser-point from a remote client in a collab room and disconnect it mid-animation; the flag stayed true, and no animation in the editor could ever run again until reload. The rewrite fixes that class of bug by construction, and a new test pins it. One behavior change rides along: stopping a trail now discards its content — stop-then-restart no longer resumes old strokes.

Show me the code — the handle, and the rent-a-frame contract
packages/excalidraw/renderer/animation.ts:9–12
-  private static isRunning = false;
+  private static scheduledFrame:
+    | { id: ReturnType<typeof requestAnimationFrame>; type: "raf" }
+    | { id: ReturnType<typeof setTimeout>; type: "timeout" }
+    | null = null;
packages/excalidraw/animatedTrail.ts:95–106
+  AnimationController.start(this.key, () => {
+    const needsNext = this.onFrame();
+    if (needsNext) {
+      return { keep: true };
+    }
+    this.cleanup();
+    return null;
+  });

The idle-cancel lives at animation.ts:75-82, guarding both exits of tick() (:98-103, :110-112) and cancel() (:122-125); onFrame() returns false once nothing is left to draw (animatedTrail.ts:175-180), and update() re-registers on the next stroke (:149-155).

CollaborationPushed, not polled

The per-frame poll of collaborator state is replaced by a push: the one place collaborator data is written — App.updateScene, which the collab client calls on every socket update — now hands the new collaborator map straight to the laser trails. Updates ride exactly the events that carry the data they depend on, and a frame is scheduled only when there's a trail to animate.

Inside that handler, order matters: trails of departed collaborators are cleaned up before the early return on an empty map — the branch's final commit exists precisely because cleanup must still run when the last collaborator leaves.

Show me the code — the single push site
packages/excalidraw/components/App.tsx:4616–4619
+  if (collaborators) {
+    this.laserTrails.updateCollabTrails(collaborators);
+    this.setState({ collaborators });
+  }

The only assignment of state.collaborators in App.tsx; fed by excalidraw-app/collab/Collab.tsx:881. Cleanup-before-early-return at laserTrails.ts:79-84.

Act 3 · Your review

I did the homework below. Tick the cards as you go — when all five are done, you've covered what matters in this PR.

needs your judgmentCould the perpetual loop come back?

Why it matters

It's the bug this PR exists to kill.

What I found

Correct idleness is now distributed: every animation must honor the keep/done contract, and one misbehaving callback that always returns keep silently reintroduces the leak. There is no central watchdog. Trail termination also quietly depends on a zoom-aware prune eventually emptying the trail list (animatedTrail.ts:169-173).

Read the contract (animatedTrail.ts:95-106); consider asking for a dev-mode warning when an animation stays registered unusually long.

needs your judgmentCould animations get stuck the other way — never running again?

Why it matters

The old design had exactly this failure (dwelle's disconnect repro), and the rewrite touches the same logic.

What I found

The stuck-flag class of bug is fixed by construction (a cancellable handle instead of a boolean), the idle check guards both exits of tick() and cancel(), and tests/animation.test.ts:21-45 pins the repro: cancel the last animation, start a new one, assert its frames advance.

Read tick()'s two exits (renderer/animation.ts:98-112); if they read right to you, this is covered.

checked — looks rightDoes idle actually mean zero work now?

What I found

Stated as an assertion: the scheduler test ends with expect(vi.getTimerCount()).toBe(0) (tests/animation.test.ts:71) — no timers left. You can also see it live: DevTools → Performance, record ~10 seconds of an untouched editor — on master you see “Animation Frame Fired” every ~16 ms; on this branch, none.

Optionally run that 10-second recording; it's the feature, visible.

checked — looks rightDo remote laser pointers still work?

What I found

The push path is tested end to end (tests/laser.test.tsx:132-162): one collaborator with a laser → one SVG path; empty map → zero paths (the cleanup case). Note the granularity trade: remote trail points now arrive only as often as updateScene is called, instead of being re-read every frame — decay still animates smoothly.

Nothing required — unless sub-update-rate laser smoothness matters to you; then try a collab room.

ask the authorWhy is a Mermaid snapshot change in a battery PR?

What I found

That suite's snapshot was flip-flopping on an injected animation-duration: 0s style; this PR's timing changes likely surfaced it, and the fix strips the style from the snapshot (tests/MermaidToExcalidraw.test.tsx:86-103). The author's own review note says the root cause is unknown — the flake is normalized, not explained.

Ask whether symptom-treatment is the intended end state, or file a follow-up to find the injector.