Skip to content

Commit 02f35fa

Browse files
authored
Merge pull request #3613 from superdoc-dev/caio/font-late-load-scheduler
feat(fonts): bounded late-load reflow scheduler
2 parents 2294380 + 99b81df commit 02f35fa

6 files changed

Lines changed: 509 additions & 20 deletions

File tree

packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5030,6 +5030,10 @@ export class PresentationEditor extends EventEmitter {
50305030
// header/footer descriptors against the new converter and rerender so the
50315031
// importer tab matches the collaborator tab without waiting for an edit.
50325032
const handleDocumentReplaced = () => {
5033+
// A new document reuses this gate, so drop the old document's pending late-load reflow
5034+
// and required-face state - otherwise a flush armed under the old document fires a
5035+
// spurious full reflow against the new one.
5036+
this.#fontGate?.resetForDocumentChange();
50335037
this.#refreshHeaderFooterStructureThenRerender({ purgeCachedEditors: true });
50345038
};
50355039
this.#editor.on('documentReplaced', handleDocumentReplaced);
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
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('starts a fresh quiet window after the cooldown drains idle', () => {
105+
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, cooldownMs: 2000 });
106+
scheduler.schedule(['a']);
107+
clock.advance(250); // flush 'a'
108+
clock.advance(2000); // cooldown elapses with nothing pending -> idle
109+
expect(flushes).toHaveLength(1);
110+
scheduler.schedule(['b']);
111+
clock.advance(250); // fresh leading flush
112+
expect(flushes).toHaveLength(2);
113+
expect(flushes[1].reason).toBe('quiet');
114+
expect(flushes[1].faceKeys).toEqual(['b']);
115+
});
116+
117+
it('a throwing flush does not escape the timer callback and still arms the cooldown', () => {
118+
const clock = makeClock();
119+
let throwOnce = true;
120+
const reasons: string[] = [];
121+
const scheduler = new FontLateLoadReflowScheduler({
122+
quietMs: 250,
123+
cooldownMs: 2000,
124+
flush: (d) => {
125+
if (throwOnce) {
126+
throwOnce = false;
127+
throw new Error('flush blew up');
128+
}
129+
reasons.push(d.reason);
130+
},
131+
scheduleTimeout: clock.scheduleTimeout,
132+
cancelTimeout: clock.cancelTimeout,
133+
});
134+
135+
scheduler.schedule(['a']);
136+
// The quiet flush throws; advancing the timers must NOT surface an uncaught exception.
137+
expect(() => clock.advance(300)).not.toThrow();
138+
139+
// The cooldown was still armed despite the throw: an arrival now defers to its end and a
140+
// trailing flush drains it, proving the rate bound survived the throwing flush.
141+
scheduler.schedule(['b']);
142+
clock.advance(2100);
143+
expect(reasons).toEqual(['throttle']);
144+
});
145+
});
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
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';
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+
/** Drop pending work + timers without flushing, and reset cooldown (call on teardown / config change). */
100+
cancel(): void {
101+
this.#clearTimers();
102+
this.#pending.clear();
103+
this.#trailing = false;
104+
}
105+
106+
#onQuietElapsed(): void {
107+
this.#quietHandle = null;
108+
this.#doFlush('quiet');
109+
}
110+
111+
#onCooldownElapsed(): void {
112+
this.#cooldownHandle = null;
113+
if (this.#trailing && this.#pending.size > 0) {
114+
this.#doFlush('throttle'); // drain arrivals deferred during the cooldown
115+
}
116+
// else: idle - the next schedule() arms a fresh quiet window.
117+
}
118+
119+
/** Emit one reflow for the current batch, then open a cooldown that bounds the next flush. */
120+
#doFlush(reason: FontReflowFlushReason): void {
121+
this.#trailing = false;
122+
try {
123+
if (this.#pending.size > 0) {
124+
const faceKeys = [...this.#pending];
125+
this.#pending.clear();
126+
this.#flush({ reason, faceKeys });
127+
}
128+
} catch {
129+
// #doFlush runs inside a timer callback, so a throwing flush would surface as an
130+
// uncaught exception. Font readiness must not break layout - swallow it; the correction
131+
// self-heals on the next schedule().
132+
} finally {
133+
// Always arm the cooldown, even when a flush throws, so the flush rate stays bounded.
134+
this.#cooldownHandle = this.#scheduleTimeout(() => this.#onCooldownElapsed(), this.#cooldownMs);
135+
}
136+
}
137+
138+
#clearTimers(): void {
139+
if (this.#quietHandle !== null) {
140+
this.#cancelTimeout(this.#quietHandle);
141+
this.#quietHandle = null;
142+
}
143+
if (this.#cooldownHandle !== null) {
144+
this.#cancelTimeout(this.#cooldownHandle);
145+
this.#cooldownHandle = null;
146+
}
147+
}
148+
}

0 commit comments

Comments
 (0)