92 lines
4.3 KiB
TypeScript
92 lines
4.3 KiB
TypeScript
// The observer's first-paint report (src/observer/paint.ts): the startup cover leaves when X shows
|
|
// real content. A hidden document gets no animation frames (WebKit holds a hidden window's frames
|
|
// back, and the app's window is hidden until the shell has laid out), so the check waits for the
|
|
// document to be visible, is asked again the moment it is, and its give-up time counts only
|
|
// visible time. Seen on macOS: the report never came and the panes stayed covered.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { watchFirstPaint, type PaintClock, type PaintDocument } from '../src/observer/paint.ts';
|
|
|
|
function world(visible: boolean) {
|
|
const frames: Array<() => void> = [];
|
|
const timers = new Map<number, { at: number; callback: () => void }>();
|
|
let now = 0;
|
|
let next = 1;
|
|
const listeners = new Set<() => void>();
|
|
const doc = {
|
|
visibilityState: visible ? 'visible' : 'hidden',
|
|
addEventListener: (_: 'visibilitychange', listener: () => void) => { listeners.add(listener); },
|
|
removeEventListener: (_: 'visibilitychange', listener: () => void) => { listeners.delete(listener); },
|
|
} satisfies PaintDocument & { visibilityState: string };
|
|
const clock: PaintClock = {
|
|
// Like an engine: no frames for a hidden document.
|
|
requestAnimationFrame: callback => { frames.push(callback); return frames.length; },
|
|
setTimeout: (callback, ms) => { const id = next++; timers.set(id, { at: now + ms, callback }); return id; },
|
|
clearTimeout: handle => { timers.delete(handle as number); },
|
|
};
|
|
let changed: () => void = () => {};
|
|
let observing = false;
|
|
return {
|
|
doc, clock,
|
|
changed: (listener: () => void) => { changed = listener; observing = true; return () => { observing = false; }; },
|
|
get observing() { return observing; },
|
|
mutate: () => changed(),
|
|
frame: () => { if (doc.visibilityState !== 'visible') return; const pending = frames.splice(0); for (const callback of pending) callback(); },
|
|
advance: (ms: number) => { now += ms; for (const [id, timer] of [...timers]) if (timer.at <= now) { timers.delete(id); timer.callback(); } },
|
|
show: () => { doc.visibilityState = 'visible'; for (const listener of [...listeners]) listener(); },
|
|
hide: () => { doc.visibilityState = 'hidden'; for (const listener of [...listeners]) listener(); },
|
|
listeners,
|
|
};
|
|
}
|
|
|
|
test('a visible page reports once, two frames after it shows content', () => {
|
|
const w = world(true);
|
|
let painted = false;
|
|
let reports = 0;
|
|
watchFirstPaint({ doc: w.doc, clock: w.clock, changed: w.changed, painted: () => painted, report: () => { reports += 1; } });
|
|
w.frame();
|
|
assert.equal(reports, 0, 'nothing yet');
|
|
painted = true;
|
|
w.mutate(); w.frame();
|
|
assert.equal(reports, 0, 'the report waits two more frames');
|
|
w.frame(); w.frame();
|
|
assert.equal(reports, 1);
|
|
w.mutate(); w.frame(); w.frame(); w.frame();
|
|
assert.equal(reports, 1, 'once');
|
|
assert.equal(w.observing, false, 'the DOM is no longer watched');
|
|
});
|
|
|
|
test('a page that painted while its window was hidden reports as soon as it is shown', () => {
|
|
const w = world(false);
|
|
let reports = 0;
|
|
watchFirstPaint({ doc: w.doc, clock: w.clock, changed: w.changed, painted: () => true, report: () => { reports += 1; } });
|
|
// X renders while the window is hidden: changes come, frames do not.
|
|
w.mutate(); w.frame(); w.advance(60_000);
|
|
assert.equal(reports, 0);
|
|
assert.equal(w.observing, true, 'hidden time does not count toward giving up');
|
|
w.show();
|
|
w.frame(); w.frame(); w.frame();
|
|
assert.equal(reports, 1, 'reported once visible, even with no further DOM change');
|
|
});
|
|
|
|
test('the give-up time counts only visible time', () => {
|
|
const w = world(true);
|
|
let reports = 0;
|
|
watchFirstPaint({ doc: w.doc, clock: w.clock, changed: w.changed, painted: () => false, report: () => { reports += 1; }, limitMs: 10_000 });
|
|
w.advance(9_999);
|
|
assert.equal(w.observing, true);
|
|
w.advance(1);
|
|
assert.equal(w.observing, false, 'gives up after the limit');
|
|
assert.equal(w.listeners.size, 0);
|
|
assert.equal(reports, 0);
|
|
});
|
|
|
|
test('stop ends everything at once', () => {
|
|
const w = world(false);
|
|
let reports = 0;
|
|
const stop = watchFirstPaint({ doc: w.doc, clock: w.clock, changed: w.changed, painted: () => true, report: () => { reports += 1; } });
|
|
stop();
|
|
w.show(); w.frame(); w.frame(); w.frame();
|
|
assert.equal(reports, 0);
|
|
assert.equal(w.observing, false);
|
|
});
|