storytime · the story of a change
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)
- 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
- private static isRunning = false;
+ private static scheduledFrame:
+ | { id: ReturnType<typeof requestAnimationFrame>; type: "raf" }
+ | { id: ReturnType<typeof setTimeout>; type: "timeout" }
+ | null = null;+ 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
+ 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.
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.
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.
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.
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.
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.