Skip to content

Commit 6347e2d

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 using a leading flush + throttled trailing (cooldown): the first late face flushes after a short quiet window, then a cooldown bounds the flush RATE to ~once per cooldown regardless of arrival spacing. Unlike a plain debounce (one flush per wave when waves are spaced apart) or a per-batch max-wait (the quiet flush resets it first), this actually bounds the slow, spaced-out case; arrivals wider than the cooldown reflow per arrival (the inherent floor / max correction lag). First paint is untouched (3s gate timeout). notifyFontConfigChanged reflows immediately AND cancels any pending batch (no double reflow); dispose cancels pending so a torn-down editor never reflows.
1 parent fd58a97 commit 6347e2d

4 files changed

Lines changed: 408 additions & 10 deletions

File tree

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, it, expect } 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 due = [...timers.entries()].filter(([, t]) => t.due <= target).sort((a, b) => a[1].due - b[1].due);
22+
if (due.length === 0) break;
23+
const [id, t] = due[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; cooldownMs?: number } = {}) {
34+
const clock = makeClock();
35+
const flushes: FontReflowFlushDetails[] = [];
36+
const scheduler = new FontLateLoadReflowScheduler({
37+
quietMs: overrides.quietMs ?? 250,
38+
cooldownMs: overrides.cooldownMs ?? 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('coalesces a burst into a single leading flush', () => {
48+
const { scheduler, clock, flushes } = makeScheduler();
49+
scheduler.schedule(['a']);
50+
scheduler.schedule(['b']);
51+
scheduler.schedule(['c']);
52+
expect(flushes).toHaveLength(0);
53+
clock.advance(250);
54+
expect(flushes).toHaveLength(1);
55+
expect(flushes[0].reason).toBe('quiet');
56+
expect(new Set(flushes[0].faceKeys)).toEqual(new Set(['a', 'b', 'c']));
57+
});
58+
59+
it('bounds SPACED-OUT waves: 40 arrivals 500ms apart produce far fewer than 40 flushes', () => {
60+
// The slow-network case: waves farther apart than the quiet window. A plain debounce
61+
// would flush once per wave (40); the cooldown throttle bounds it to ~total/cooldown.
62+
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, cooldownMs: 2000 });
63+
for (let i = 0; i < 40; i++) {
64+
scheduler.schedule([`f${i}`]);
65+
clock.advance(500);
66+
}
67+
clock.advance(2500); // let the final cooldown drain
68+
expect(flushes.length).toBeGreaterThan(1);
69+
expect(flushes.length).toBeLessThan(15); // ~ 20s / 2s cooldown, NOT 40
70+
});
71+
72+
it('defers arrivals during a cooldown into one trailing flush', () => {
73+
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, cooldownMs: 2000 });
74+
scheduler.schedule(['a']);
75+
clock.advance(250); // leading flush of 'a'
76+
expect(flushes).toHaveLength(1);
77+
scheduler.schedule(['b']); // during cooldown -> deferred
78+
scheduler.schedule(['c']);
79+
clock.advance(100);
80+
expect(flushes).toHaveLength(1); // still deferred
81+
clock.advance(2000); // cooldown ends -> one trailing flush
82+
expect(flushes).toHaveLength(2);
83+
expect(flushes[1].reason).toBe('throttle');
84+
expect(new Set(flushes[1].faceKeys)).toEqual(new Set(['b', 'c']));
85+
});
86+
87+
it('does not flush twice for a repeated same face key', () => {
88+
const { scheduler, clock, flushes } = makeScheduler();
89+
scheduler.schedule(['a']);
90+
scheduler.schedule(['a']);
91+
clock.advance(250);
92+
expect(flushes).toHaveLength(1);
93+
expect(flushes[0].faceKeys).toEqual(['a']);
94+
});
95+
96+
it('cancel() drops pending work without flushing', () => {
97+
const { scheduler, clock, flushes } = makeScheduler();
98+
scheduler.schedule(['a', 'b']);
99+
scheduler.cancel();
100+
clock.advance(10000);
101+
expect(flushes).toHaveLength(0);
102+
});
103+
104+
it('flushNow flushes immediately; the cleared quiet timer does not double-fire', () => {
105+
const { scheduler, clock, flushes } = makeScheduler();
106+
scheduler.schedule(['a']);
107+
scheduler.flushNow();
108+
expect(flushes).toHaveLength(1);
109+
expect(flushes[0].reason).toBe('manual');
110+
clock.advance(250);
111+
expect(flushes).toHaveLength(1);
112+
});
113+
114+
it('flushNow is a no-op when nothing is pending', () => {
115+
const { scheduler, flushes } = makeScheduler();
116+
scheduler.flushNow();
117+
expect(flushes).toHaveLength(0);
118+
});
119+
120+
it('starts a fresh quiet window after the cooldown drains idle', () => {
121+
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, cooldownMs: 2000 });
122+
scheduler.schedule(['a']);
123+
clock.advance(250); // flush 'a'
124+
clock.advance(2000); // cooldown elapses with nothing pending -> idle
125+
expect(flushes).toHaveLength(1);
126+
scheduler.schedule(['b']);
127+
clock.advance(250); // fresh leading flush
128+
expect(flushes).toHaveLength(2);
129+
expect(flushes[1].reason).toBe('quiet');
130+
expect(flushes[1].faceKeys).toEqual(['b']);
131+
});
132+
});
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
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+
* Policy: leading flush + throttled trailing (a cooldown). The FIRST late face flushes
11+
* after a short quiet window (coalescing the initial parallel batch). After ANY flush a
12+
* `cooldownMs` window opens during which further arrivals are deferred; at the cooldown's
13+
* end a single trailing flush drains them, then the cooldown reopens if more arrive. This
14+
* bounds the flush RATE to ~once per cooldown REGARDLESS of arrival spacing - unlike a
15+
* plain debounce (which fires once per wave when waves are farther apart than the window)
16+
* or a per-batch max-wait (which the quiet flush resets before it can bite).
17+
*
18+
* Honest floor: arrivals spaced WIDER than `cooldownMs` reflow per arrival - you cannot
19+
* coalesce a wave that lands after the document already corrected without delaying every
20+
* correction by at least the gap. `cooldownMs` is therefore the max correction lag.
21+
*
22+
* First paint is untouched - the gate's per-font timeout bounds that; this only governs
23+
* the after-the-fact corrections. Timer hooks are injectable so the policy is unit-testable
24+
* without real time.
25+
*/
26+
27+
export type FontReflowFlushReason = 'quiet' | 'throttle' | 'manual';
28+
29+
export interface FontReflowFlushDetails {
30+
reason: FontReflowFlushReason;
31+
/** The face keys batched into this flush (diagnostic; the gate reflows the whole doc). */
32+
faceKeys: string[];
33+
}
34+
35+
export interface FontLateLoadReflowSchedulerOptions {
36+
/** Quiet window before the FIRST flush of an idle scheduler (coalesces the initial burst). */
37+
quietMs?: number;
38+
/** Minimum interval between flushes; the max correction lag for deferred arrivals. */
39+
cooldownMs?: number;
40+
/** Perform the actual one-shot reflow (bump epoch + invalidate caches + request reflow). */
41+
flush: (details: FontReflowFlushDetails) => void;
42+
/** Timer hooks (injectable for tests); default to the globals. */
43+
scheduleTimeout?: (cb: () => void, ms: number) => unknown;
44+
cancelTimeout?: (handle: unknown) => void;
45+
}
46+
47+
export const DEFAULT_REFLOW_QUIET_MS = 250;
48+
export const DEFAULT_REFLOW_COOLDOWN_MS = 2000;
49+
50+
export class FontLateLoadReflowScheduler {
51+
readonly #quietMs: number;
52+
readonly #cooldownMs: number;
53+
readonly #flush: (details: FontReflowFlushDetails) => void;
54+
readonly #scheduleTimeout: (cb: () => void, ms: number) => unknown;
55+
readonly #cancelTimeout: (handle: unknown) => void;
56+
57+
readonly #pending = new Set<string>();
58+
/** Pending leading flush (idle -> quiet window). */
59+
#quietHandle: unknown = null;
60+
/** Active cooldown after a flush; arrivals during it are deferred to its end. */
61+
#cooldownHandle: unknown = null;
62+
/** A face arrived during the cooldown, so a trailing flush is owed at cooldown end. */
63+
#trailing = false;
64+
65+
constructor(options: FontLateLoadReflowSchedulerOptions) {
66+
this.#quietMs = options.quietMs ?? DEFAULT_REFLOW_QUIET_MS;
67+
this.#cooldownMs = options.cooldownMs ?? DEFAULT_REFLOW_COOLDOWN_MS;
68+
this.#flush = options.flush;
69+
this.#scheduleTimeout = options.scheduleTimeout ?? ((cb, ms) => globalThis.setTimeout(cb, ms));
70+
this.#cancelTimeout =
71+
options.cancelTimeout ?? ((handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>));
72+
}
73+
74+
/**
75+
* Record newly-available required face keys. A call adding no new key is a no-op. If a
76+
* cooldown is active, the arrival is deferred to its end (rate stays bounded); otherwise
77+
* a quiet-window leading flush is armed. Repeated `loadingdone` for the same face cannot
78+
* open a new batch or cause an extra flush.
79+
*/
80+
schedule(changedFaceKeys: Iterable<string>): void {
81+
let added = false;
82+
for (const key of changedFaceKeys) {
83+
if (!this.#pending.has(key)) {
84+
this.#pending.add(key);
85+
added = true;
86+
}
87+
}
88+
if (!added) return;
89+
90+
if (this.#cooldownHandle !== null) {
91+
// In cooldown: defer to its end so the flush rate stays bounded.
92+
this.#trailing = true;
93+
return;
94+
}
95+
if (this.#quietHandle !== null) return; // leading flush already armed
96+
this.#quietHandle = this.#scheduleTimeout(() => this.#onQuietElapsed(), this.#quietMs);
97+
}
98+
99+
/** Flush any pending batch immediately (no-op if nothing pending), then open a cooldown. */
100+
flushNow(reason: FontReflowFlushReason = 'manual'): void {
101+
if (this.#pending.size === 0) return;
102+
this.#clearTimers();
103+
this.#doFlush(reason);
104+
}
105+
106+
/** Drop pending work + timers without flushing, and reset cooldown (call on teardown / config change). */
107+
cancel(): void {
108+
this.#clearTimers();
109+
this.#pending.clear();
110+
this.#trailing = false;
111+
}
112+
113+
#onQuietElapsed(): void {
114+
this.#quietHandle = null;
115+
this.#doFlush('quiet');
116+
}
117+
118+
#onCooldownElapsed(): void {
119+
this.#cooldownHandle = null;
120+
if (this.#trailing && this.#pending.size > 0) {
121+
this.#doFlush('throttle'); // drain arrivals deferred during the cooldown
122+
}
123+
// else: idle - the next schedule() arms a fresh quiet window.
124+
}
125+
126+
/** Emit one reflow for the current batch, then open a cooldown that bounds the next flush. */
127+
#doFlush(reason: FontReflowFlushReason): void {
128+
this.#trailing = false;
129+
if (this.#pending.size > 0) {
130+
const faceKeys = [...this.#pending];
131+
this.#pending.clear();
132+
this.#flush({ reason, faceKeys });
133+
}
134+
this.#cooldownHandle = this.#scheduleTimeout(() => this.#onCooldownElapsed(), this.#cooldownMs);
135+
}
136+
137+
#clearTimers(): void {
138+
if (this.#quietHandle !== null) {
139+
this.#cancelTimeout(this.#quietHandle);
140+
this.#quietHandle = null;
141+
}
142+
if (this.#cooldownHandle !== null) {
143+
this.#cancelTimeout(this.#cooldownHandle);
144+
this.#cooldownHandle = null;
145+
}
146+
}
147+
}

0 commit comments

Comments
 (0)