odysseus/tests/test_spinner_stops_when_never_attached_js.py
Léo 663d6879b7
fix(ui): stop the whirlpool spinner animating when it is never attached (#5990)
_drawWhirlpool re-armed requestAnimationFrame forever whenever its element
had never been connected to the document. The grace period is there so a
spinner can keep drawing between start() and the caller appending the
element, but it had no deadline: while the element has never been connected
_wpWasConnected stays false, so the guard stays true and the else branch is
unreachable. Any caller that starts a spinner and then takes an early return,
such as an aborted request or a panel that resolved from cache, leaves a loop
redrawing an 84-segment spiral into a detached canvas at one frame per
displayed frame until the tab closes.

Put a 2 second deadline on the grace period. Callers append in the same task
as start(), so that is far more slack than any of them need. A spinner that
is actually in the document is unaffected.

Two supporting changes in the same file:

- Both self-terminate paths now call stop() instead of setting isRunning
  directly, so termination always runs one cancelAnimationFrame and never
  depends solely on inferring DOM connectivity. Both draw functions bail at
  the top when they are no longer running, and _requestFrame() clears rafId
  as the callback enters so it is a truthful "a frame is pending" flag.
- start() arms a visibilitychange listener and stop() removes it. A hidden
  tab cancels the pending frame, a re-shown tab re-arms it. Chrome throttles
  background rAF but does not reliably stop the canvas work, and owning the
  listener from start/stop means a dead spinner never leaves one behind.

Adds tests/test_spinner_stops_when_never_attached_js.py, which drives the
real module under node with a fake clock and a manual frame pump. It covers
all four exits and, importantly, the converse: a spinner that is attached
keeps running well past the grace window.
2026-08-12 00:25:03 +01:00

319 lines
11 KiB
Python

"""Pin the self-termination contract of the canvas spinners in static/js/spinner.js.
Background: the whirlpool spinner drives itself with requestAnimationFrame and
decides whether to keep going by looking at `element.isConnected`. It used to
re-arm forever whenever the element had *never* been connected, on the theory
that start() runs before the caller appends the element. Callers that start a
spinner and then take an early return - an aborted request, a panel that
resolved from cache before the loading row was inserted - therefore left a rAF
loop redrawing an 84-segment spiral into a detached canvas until the tab closed.
Measured on an idle app: ~110 whirlpool frames per second with zero canvases in
the document.
These tests lock in all four exits (never attached, attached-then-removed,
stop(), tab hidden) and, just as importantly, the one case that must NOT stop:
a spinner that is actually on screen.
Driven through `node --input-type=module` so the real module runs, same idiom as
test_esc_menu_stack_js.py. The module source is inlined rather than imported by
path because the repo has no `"type": "module"` in package.json; spinner.js has
no imports of its own, so inlining is exact. A fake clock and a manual frame
pump replace performance.now()/requestAnimationFrame, so nothing here depends on
wall-clock time or real frame timing.
"""
import json
import shutil
import subprocess
from pathlib import Path
import pytest
_REPO = Path(__file__).resolve().parent.parent
_MODULE = _REPO / "static" / "js" / "spinner.js"
_HAS_NODE = shutil.which("node") is not None
_SRC = _MODULE.read_text(encoding="utf-8") if _MODULE.exists() else ""
# Browser stand-ins, installed before the module body runs. `clock` is advanced
# only by pump(), so every timing decision in the module is deterministic.
_STUBS = r"""
let clock = 0;
Object.defineProperty(globalThis, 'performance', {
value: { now: () => clock }, configurable: true, writable: true,
});
const pending = new Map();
let nextFrameId = 1;
let framesRun = 0;
globalThis.requestAnimationFrame = (cb) => {
const id = nextFrameId++;
pending.set(id, cb);
return id;
};
globalThis.cancelAnimationFrame = (id) => { pending.delete(id); };
/** Advance the clock `steps` frames of `msPerFrame` and run whatever is queued. */
function pump(steps, msPerFrame = 16) {
for (let i = 0; i < steps; i++) {
clock += msPerFrame;
const due = [...pending.values()];
pending.clear();
for (const cb of due) { framesRun++; cb(); }
}
}
function framesPending() { return pending.size; }
function framesSince(mark) { return framesRun - mark; }
function frameMark() { return framesRun; }
function makeCtx() {
const noop = () => {};
return {
clearRect: noop, beginPath: noop, arc: noop, moveTo: noop, lineTo: noop,
stroke: noop, fill: noop, save: noop, restore: noop,
strokeStyle: '', fillStyle: '', lineWidth: 0, globalAlpha: 1,
lineCap: '', lineJoin: '',
};
}
function makeElement(tag) {
const el = {
tagName: tag, className: '', textContent: '', innerHTML: '',
width: 0, height: 0, isConnected: false, parentNode: null,
style: { cssText: '' },
children: [],
classList: { add: () => {}, remove: () => {}, contains: () => false },
getContext: () => makeCtx(),
appendChild(child) {
child.parentNode = this;
this.children.push(child);
return child;
},
removeChild(child) {
this.children = this.children.filter((c) => c !== child);
child.parentNode = null;
return child;
},
};
return el;
}
const docListeners = [];
globalThis.document = {
hidden: false,
documentElement: makeElement('html'),
createElement: makeElement,
createTextNode: (t) => ({ textContent: t }),
addEventListener: (type, fn) => { docListeners.push([type, fn]); },
removeEventListener: (type, fn) => {
const i = docListeners.findIndex(([t, f]) => t === type && f === fn);
if (i >= 0) docListeners.splice(i, 1);
},
};
globalThis.getComputedStyle = () => ({ getPropertyValue: () => '' });
function visibilityListeners() {
return docListeners.filter(([t]) => t === 'visibilitychange').length;
}
function fireVisibility(hidden) {
document.hidden = hidden;
for (const [t, fn] of [...docListeners]) if (t === 'visibilitychange') fn();
}
/** A started whirlpool spinner whose element is not in the document. */
function startedWhirlpool() {
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.start();
return sp;
}
"""
def _run(body: str) -> dict:
"""Run `body` with the real spinner module and the browser stubs in scope."""
js = _STUBS + "\n" + _SRC + "\n" + body
proc = subprocess.run(
["node", "--input-type=module"],
input=js, capture_output=True, text=True, encoding="utf-8",
cwd=str(_REPO), timeout=30,
)
assert proc.returncode == 0, proc.stderr
return json.loads(proc.stdout.strip())
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_never_attached_whirlpool_stops_itself():
# The leak: element created, spinner started, element never inserted. Past
# the grace window it must give up rather than re-arm forever.
body = """
const sp = startedWhirlpool();
pump(30); // 480 ms - inside the grace window
const early = { running: sp.isRunning, pending: framesPending() };
pump(120); // ~2.4 s total - past the grace window
const mark = frameMark();
pump(60); // nothing should be left to run
console.log(JSON.stringify({
early,
running: sp.isRunning,
rafId: sp.rafId,
pending: framesPending(),
framesAfterStop: framesSince(mark),
}));
"""
out = _run(body)
assert out["early"] == {"running": True, "pending": 1}, "gave up during the grace window"
assert out["running"] is False
assert out["rafId"] is None
assert out["pending"] == 0
assert out["framesAfterStop"] == 0, "loop kept drawing after it gave up"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_attached_whirlpool_keeps_running_past_the_grace_window():
# The converse guard: the fix must not kill spinners that are on screen.
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(400); // ~6.4 s, far past the grace window
const mark = frameMark();
pump(10);
console.log(JSON.stringify({
running: sp.isRunning,
pending: framesPending(),
framesDrawn: framesSince(mark),
}));
"""
out = _run(body)
assert out["running"] is True
assert out["pending"] == 1
assert out["framesDrawn"] == 10, "a visible spinner stopped animating"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_attached_then_removed_whirlpool_stops():
# The pre-existing exit - a loading row replaced by results - still works.
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(200);
const whileAttached = sp.isRunning;
sp.element.isConnected = false; // results arrived, row swapped out
pump(3);
const mark = frameMark();
pump(20);
console.log(JSON.stringify({
whileAttached,
running: sp.isRunning,
pending: framesPending(),
framesAfterRemoval: framesSince(mark),
}));
"""
out = _run(body)
assert out["whileAttached"] is True
assert out["running"] is False
assert out["pending"] == 0
assert out["framesAfterRemoval"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_loading_row_helper_stops_when_the_row_is_never_inserted():
# createLoadingRow() starts the spinner for the caller and hands back a
# detached row, so a caller that early-returns is the real leak shape.
body = """
const row = createLoadingRow('Loading...', 16);
pump(200);
const mark = frameMark();
pump(40);
console.log(JSON.stringify({
pending: framesPending(),
framesAfterStop: framesSince(mark),
rowHasChildren: row.children.length > 0,
}));
"""
out = _run(body)
assert out["rowHasChildren"] is True, "harness built the wrong row"
assert out["pending"] == 0
assert out["framesAfterStop"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_stop_cancels_the_pending_frame_and_releases_the_listener():
# stop() must be authoritative: no queued frame survives it, and it leaves
# no visibilitychange listener behind on a dead spinner.
body = """
const before = visibilityListeners();
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
const armed = visibilityListeners();
sp.stop();
const mark = frameMark();
pump(20);
console.log(JSON.stringify({
before, armed, after: visibilityListeners(),
running: sp.isRunning,
rafId: sp.rafId,
pending: framesPending(),
framesAfterStop: framesSince(mark),
}));
"""
out = _run(body)
assert (out["before"], out["armed"], out["after"]) == (0, 1, 0)
assert out["running"] is False
assert out["rafId"] is None
assert out["pending"] == 0
assert out["framesAfterStop"] == 0
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_hidden_tab_pauses_frames_and_showing_resumes_them():
body = """
const sp = new Spinner('', 'clean', 'whirlpool');
sp.createElement();
sp.element.isConnected = true;
sp.start();
pump(5);
fireVisibility(true);
const hiddenMark = frameMark();
pump(30);
const whileHidden = { drawn: framesSince(hiddenMark), pending: framesPending() };
fireVisibility(false);
const shownMark = frameMark();
pump(10);
console.log(JSON.stringify({
whileHidden,
running: sp.isRunning,
drawnAfterShow: framesSince(shownMark),
}));
"""
out = _run(body)
assert out["whileHidden"] == {"drawn": 0, "pending": 0}, "kept drawing in a hidden tab"
assert out["running"] is True
assert out["drawnAfterShow"] == 10, "did not resume when the tab came back"
@pytest.mark.skipif(not _HAS_NODE, reason="node binary not on PATH")
def test_restarted_spinner_gets_a_fresh_grace_window():
# The grace deadline is per-run. A spinner reused after stop() must not
# inherit the previous run's timestamp and die on its first frame.
body = """
const sp = startedWhirlpool();
pump(200); // times out, never attached
const stopped = sp.isRunning;
sp.element.isConnected = true; // now inserted for real
sp.start();
pump(30);
console.log(JSON.stringify({
stopped,
running: sp.isRunning,
pending: framesPending(),
}));
"""
out = _run(body)
assert out["stopped"] is False
assert out["running"] is True
assert out["pending"] == 1