Skip to content

Commit a27d9bb

Browse files
committed
feat(fonts): bounded late-load reflow scheduler
When a required face loads after the gate's first-paint timeout the document must re-measure, but on a slow network a font-heavy doc's faces arrive in many waves (a probe saw ~38 over ~103s for 40 fonts) - reflowing per wave is a re-measure storm. FontReadinessGate now batches late-loaded faces through a small scheduler that flushes once per quiet window (no new face for ~250ms) or at a max-wait ceiling (~2s) so a steady trickle still corrects within a bounded time. Not a plain debounce: the ceiling bounds the worst case to ~total/maxWait flushes instead of one-per-wave. First paint is untouched (still bounded by the 3s per-font gate timeout); notifyFontConfigChanged stays immediate; dispose cancels pending work so a torn-down editor never reflows.
1 parent fd58a97 commit a27d9bb

4 files changed

Lines changed: 360 additions & 10 deletions

File tree

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { FontLateLoadReflowScheduler, type FontReflowFlushDetails } from './FontLateLoadReflowScheduler';
3+
4+
/** Virtual clock: fires injected timers in due order as the test advances time. */
5+
function makeClock() {
6+
let nowMs = 0;
7+
let seq = 0;
8+
const timers = new Map<number, { due: number; cb: () => void }>();
9+
return {
10+
scheduleTimeout: (cb: () => void, ms: number) => {
11+
const id = ++seq;
12+
timers.set(id, { due: nowMs + ms, cb });
13+
return id;
14+
},
15+
cancelTimeout: (handle: unknown) => {
16+
timers.delete(handle as number);
17+
},
18+
advance: (ms: number) => {
19+
const target = nowMs + ms;
20+
for (;;) {
21+
const dueEntries = [...timers.entries()].filter(([, t]) => t.due <= target).sort((a, b) => a[1].due - b[1].due);
22+
if (dueEntries.length === 0) break;
23+
const [id, t] = dueEntries[0];
24+
timers.delete(id);
25+
nowMs = t.due;
26+
t.cb();
27+
}
28+
nowMs = target;
29+
},
30+
};
31+
}
32+
33+
function makeScheduler(overrides: { quietMs?: number; maxWaitMs?: number } = {}) {
34+
const clock = makeClock();
35+
const flushes: FontReflowFlushDetails[] = [];
36+
const scheduler = new FontLateLoadReflowScheduler({
37+
quietMs: overrides.quietMs ?? 250,
38+
maxWaitMs: overrides.maxWaitMs ?? 2000,
39+
flush: (d) => flushes.push(d),
40+
scheduleTimeout: clock.scheduleTimeout,
41+
cancelTimeout: clock.cancelTimeout,
42+
});
43+
return { scheduler, clock, flushes };
44+
}
45+
46+
describe('FontLateLoadReflowScheduler', () => {
47+
it('batches bursty arrivals into a single quiet-window flush', () => {
48+
const { scheduler, clock, flushes } = makeScheduler();
49+
scheduler.schedule(['a']);
50+
clock.advance(100);
51+
scheduler.schedule(['b']);
52+
scheduler.schedule(['c']);
53+
expect(flushes).toHaveLength(0); // still within the quiet window
54+
clock.advance(250);
55+
expect(flushes).toHaveLength(1);
56+
expect(flushes[0].reason).toBe('quiet');
57+
expect(new Set(flushes[0].faceKeys)).toEqual(new Set(['a', 'b', 'c']));
58+
});
59+
60+
it('restarts the quiet window on each new arrival', () => {
61+
const { scheduler, clock, flushes } = makeScheduler();
62+
scheduler.schedule(['a']);
63+
clock.advance(200); // < quiet
64+
scheduler.schedule(['b']); // resets quiet
65+
clock.advance(200); // 400 total from 'a', but only 200 since 'b'
66+
expect(flushes).toHaveLength(0);
67+
clock.advance(50); // now 250 since 'b'
68+
expect(flushes).toHaveLength(1);
69+
});
70+
71+
it('flushes at the max-wait ceiling even under a steady trickle', () => {
72+
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, maxWaitMs: 2000 });
73+
scheduler.schedule(['a']);
74+
// A new face every 200ms keeps resetting the quiet window, but max-wait caps it.
75+
for (let t = 0; t < 2000; t += 200) {
76+
clock.advance(200);
77+
scheduler.schedule([`f${t}`]);
78+
}
79+
expect(flushes).toHaveLength(1);
80+
expect(flushes[0].reason).toBe('max-wait');
81+
});
82+
83+
it('does not flush twice for a repeated same face key', () => {
84+
const { scheduler, clock, flushes } = makeScheduler();
85+
scheduler.schedule(['a']);
86+
scheduler.schedule(['a']); // duplicate -> no new pending, no timer restart
87+
clock.advance(250);
88+
expect(flushes).toHaveLength(1);
89+
expect(flushes[0].faceKeys).toEqual(['a']);
90+
});
91+
92+
it('cancel() drops pending work without flushing', () => {
93+
const { scheduler, clock, flushes } = makeScheduler();
94+
scheduler.schedule(['a', 'b']);
95+
scheduler.cancel();
96+
clock.advance(5000);
97+
expect(flushes).toHaveLength(0);
98+
});
99+
100+
it('flushNow flushes immediately and arms nothing further', () => {
101+
const { scheduler, clock, flushes } = makeScheduler();
102+
scheduler.schedule(['a']);
103+
scheduler.flushNow();
104+
expect(flushes).toHaveLength(1);
105+
expect(flushes[0].reason).toBe('manual');
106+
clock.advance(5000);
107+
expect(flushes).toHaveLength(1); // no second flush from the cleared timers
108+
});
109+
110+
it('flushNow is a no-op when nothing is pending', () => {
111+
const { scheduler, flushes } = makeScheduler();
112+
scheduler.flushNow();
113+
expect(flushes).toHaveLength(0);
114+
});
115+
116+
it('starts a fresh batch after a flush', () => {
117+
const { scheduler, clock, flushes } = makeScheduler();
118+
scheduler.schedule(['a']);
119+
clock.advance(250);
120+
expect(flushes).toHaveLength(1);
121+
scheduler.schedule(['b']);
122+
clock.advance(250);
123+
expect(flushes).toHaveLength(2);
124+
expect(flushes[1].faceKeys).toEqual(['b']);
125+
});
126+
});
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Bounded late-load reflow scheduler.
3+
*
4+
* When a required font face loads after the readiness gate's first-paint timeout, the
5+
* document must re-measure + reflow so it stops rendering against a fallback. On a slow
6+
* network a font-heavy document's faces arrive in many waves over tens of seconds (a probe
7+
* measured ~38 waves over ~103s for 40 fonts on Slow 3G). Reflowing on every wave is a
8+
* full-document re-measure storm.
9+
*
10+
* This batches late-loaded faces and flushes (one reflow) when EITHER:
11+
* - a QUIET window passes with no new required face arriving (coalesces bursts), OR
12+
* - a MAX-WAIT ceiling passes since the batch started (so a steady trickle of arrivals
13+
* can never delay the correction forever).
14+
*
15+
* It is NOT a plain debounce: a debounce alone never fires while arrivals keep coming, so a
16+
* trickle every few seconds would still reflow per wave. The max-wait ceiling bounds the
17+
* worst case to ~totalTime/maxWait reflows instead of one-per-wave. It does not touch first
18+
* paint - the gate's per-font timeout already bounds that; this only governs the
19+
* after-the-fact corrections.
20+
*
21+
* Timer hooks are injectable so the policy is unit-testable without real time.
22+
*/
23+
24+
export type FontReflowFlushReason = 'quiet' | 'max-wait' | 'manual';
25+
26+
export interface FontReflowFlushDetails {
27+
reason: FontReflowFlushReason;
28+
/** The face keys batched into this flush (diagnostic; the gate reflows the whole doc). */
29+
faceKeys: string[];
30+
}
31+
32+
export interface FontLateLoadReflowSchedulerOptions {
33+
/** Idle gap after the last new required face before flushing. */
34+
quietMs?: number;
35+
/** Hard ceiling from the first pending face to a flush, regardless of new arrivals. */
36+
maxWaitMs?: number;
37+
/** Perform the actual one-shot reflow (bump epoch + invalidate caches + request reflow). */
38+
flush: (details: FontReflowFlushDetails) => void;
39+
/** Timer hooks (injectable for tests); default to the globals. */
40+
scheduleTimeout?: (cb: () => void, ms: number) => unknown;
41+
cancelTimeout?: (handle: unknown) => void;
42+
}
43+
44+
export const DEFAULT_REFLOW_QUIET_MS = 250;
45+
export const DEFAULT_REFLOW_MAX_WAIT_MS = 2000;
46+
47+
export class FontLateLoadReflowScheduler {
48+
readonly #quietMs: number;
49+
readonly #maxWaitMs: number;
50+
readonly #flush: (details: FontReflowFlushDetails) => void;
51+
readonly #scheduleTimeout: (cb: () => void, ms: number) => unknown;
52+
readonly #cancelTimeout: (handle: unknown) => void;
53+
54+
readonly #pending = new Set<string>();
55+
#quietHandle: unknown = null;
56+
#maxWaitHandle: unknown = null;
57+
58+
constructor(options: FontLateLoadReflowSchedulerOptions) {
59+
this.#quietMs = options.quietMs ?? DEFAULT_REFLOW_QUIET_MS;
60+
this.#maxWaitMs = options.maxWaitMs ?? DEFAULT_REFLOW_MAX_WAIT_MS;
61+
this.#flush = options.flush;
62+
this.#scheduleTimeout = options.scheduleTimeout ?? ((cb, ms) => globalThis.setTimeout(cb, ms));
63+
this.#cancelTimeout =
64+
options.cancelTimeout ?? ((handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>));
65+
}
66+
67+
/**
68+
* Record newly-available required face keys and (re)arm the flush timers. A call that
69+
* adds no new key is a no-op (it does not restart the quiet window), so repeated
70+
* `loadingdone` for the same face cannot keep the batch open or cause a second flush.
71+
*/
72+
schedule(changedFaceKeys: Iterable<string>): void {
73+
let added = false;
74+
for (const key of changedFaceKeys) {
75+
if (!this.#pending.has(key)) {
76+
this.#pending.add(key);
77+
added = true;
78+
}
79+
}
80+
if (!added) return;
81+
82+
// Restart the quiet window on each new arrival.
83+
if (this.#quietHandle !== null) this.#cancelTimeout(this.#quietHandle);
84+
this.#quietHandle = this.#scheduleTimeout(() => this.#fire('quiet'), this.#quietMs);
85+
86+
// Anchor the max-wait ceiling at the FIRST pending face of this batch.
87+
if (this.#maxWaitHandle === null) {
88+
this.#maxWaitHandle = this.#scheduleTimeout(() => this.#fire('max-wait'), this.#maxWaitMs);
89+
}
90+
}
91+
92+
/** Flush any pending batch immediately (no-op if nothing is pending). */
93+
flushNow(reason: FontReflowFlushReason = 'manual'): void {
94+
if (this.#pending.size === 0) return;
95+
this.#fire(reason);
96+
}
97+
98+
/** Drop any pending batch and timers WITHOUT flushing (call on teardown). */
99+
cancel(): void {
100+
this.#clearTimers();
101+
this.#pending.clear();
102+
}
103+
104+
#fire(reason: FontReflowFlushReason): void {
105+
this.#clearTimers();
106+
if (this.#pending.size === 0) return;
107+
const faceKeys = [...this.#pending];
108+
this.#pending.clear();
109+
this.#flush({ reason, faceKeys });
110+
}
111+
112+
#clearTimers(): void {
113+
if (this.#quietHandle !== null) {
114+
this.#cancelTimeout(this.#quietHandle);
115+
this.#quietHandle = null;
116+
}
117+
if (this.#maxWaitHandle !== null) {
118+
this.#cancelTimeout(this.#maxWaitHandle);
119+
this.#maxWaitHandle = null;
120+
}
121+
}
122+
}

0 commit comments

Comments
 (0)