Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { describe, it, expect } from 'vitest';
import { FontLateLoadReflowScheduler, type FontReflowFlushDetails } from './FontLateLoadReflowScheduler';

/** Virtual clock: fires injected timers in due order as the test advances time. */
function makeClock() {
let nowMs = 0;
let seq = 0;
const timers = new Map<number, { due: number; cb: () => void }>();
return {
scheduleTimeout: (cb: () => void, ms: number) => {
const id = ++seq;
timers.set(id, { due: nowMs + ms, cb });
return id;
},
cancelTimeout: (handle: unknown) => {
timers.delete(handle as number);
},
advance: (ms: number) => {
const target = nowMs + ms;
for (;;) {
const due = [...timers.entries()].filter(([, t]) => t.due <= target).sort((a, b) => a[1].due - b[1].due);
if (due.length === 0) break;
const [id, t] = due[0];
timers.delete(id);
nowMs = t.due;
t.cb();
}
nowMs = target;
},
};
}

function makeScheduler(overrides: { quietMs?: number; cooldownMs?: number } = {}) {
const clock = makeClock();
const flushes: FontReflowFlushDetails[] = [];
const scheduler = new FontLateLoadReflowScheduler({
quietMs: overrides.quietMs ?? 250,
cooldownMs: overrides.cooldownMs ?? 2000,
flush: (d) => flushes.push(d),
scheduleTimeout: clock.scheduleTimeout,
cancelTimeout: clock.cancelTimeout,
});
return { scheduler, clock, flushes };
}

describe('FontLateLoadReflowScheduler', () => {
it('coalesces a burst into a single leading flush', () => {
const { scheduler, clock, flushes } = makeScheduler();
scheduler.schedule(['a']);
scheduler.schedule(['b']);
scheduler.schedule(['c']);
expect(flushes).toHaveLength(0);
clock.advance(250);
expect(flushes).toHaveLength(1);
expect(flushes[0].reason).toBe('quiet');
expect(new Set(flushes[0].faceKeys)).toEqual(new Set(['a', 'b', 'c']));
});

it('bounds SPACED-OUT waves: 40 arrivals 500ms apart produce far fewer than 40 flushes', () => {
// The slow-network case: waves farther apart than the quiet window. A plain debounce
// would flush once per wave (40); the cooldown throttle bounds it to ~total/cooldown.
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, cooldownMs: 2000 });
for (let i = 0; i < 40; i++) {
scheduler.schedule([`f${i}`]);
clock.advance(500);
}
clock.advance(2500); // let the final cooldown drain
expect(flushes.length).toBeGreaterThan(1);
expect(flushes.length).toBeLessThan(15); // ~ 20s / 2s cooldown, NOT 40
});

it('defers arrivals during a cooldown into one trailing flush', () => {
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, cooldownMs: 2000 });
scheduler.schedule(['a']);
clock.advance(250); // leading flush of 'a'
expect(flushes).toHaveLength(1);
scheduler.schedule(['b']); // during cooldown -> deferred
scheduler.schedule(['c']);
clock.advance(100);
expect(flushes).toHaveLength(1); // still deferred
clock.advance(2000); // cooldown ends -> one trailing flush
expect(flushes).toHaveLength(2);
expect(flushes[1].reason).toBe('throttle');
expect(new Set(flushes[1].faceKeys)).toEqual(new Set(['b', 'c']));
});

it('does not flush twice for a repeated same face key', () => {
const { scheduler, clock, flushes } = makeScheduler();
scheduler.schedule(['a']);
scheduler.schedule(['a']);
clock.advance(250);
expect(flushes).toHaveLength(1);
expect(flushes[0].faceKeys).toEqual(['a']);
});

it('cancel() drops pending work without flushing', () => {
const { scheduler, clock, flushes } = makeScheduler();
scheduler.schedule(['a', 'b']);
scheduler.cancel();
clock.advance(10000);
expect(flushes).toHaveLength(0);
});

it('flushNow flushes immediately; the cleared quiet timer does not double-fire', () => {
const { scheduler, clock, flushes } = makeScheduler();
scheduler.schedule(['a']);
scheduler.flushNow();
expect(flushes).toHaveLength(1);
expect(flushes[0].reason).toBe('manual');
clock.advance(250);
expect(flushes).toHaveLength(1);
});

it('flushNow is a no-op when nothing is pending', () => {
const { scheduler, flushes } = makeScheduler();
scheduler.flushNow();
expect(flushes).toHaveLength(0);
});

it('starts a fresh quiet window after the cooldown drains idle', () => {
const { scheduler, clock, flushes } = makeScheduler({ quietMs: 250, cooldownMs: 2000 });
scheduler.schedule(['a']);
clock.advance(250); // flush 'a'
clock.advance(2000); // cooldown elapses with nothing pending -> idle
expect(flushes).toHaveLength(1);
scheduler.schedule(['b']);
clock.advance(250); // fresh leading flush
expect(flushes).toHaveLength(2);
expect(flushes[1].reason).toBe('quiet');
expect(flushes[1].faceKeys).toEqual(['b']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* Bounded late-load reflow scheduler.
*
* When a required font face loads after the readiness gate's first-paint timeout, the
* document must re-measure + reflow so it stops rendering against a fallback. On a slow
* network a font-heavy document's faces arrive in many waves over tens of seconds (a probe
* measured ~38 waves over ~103s for 40 fonts on Slow 3G). Reflowing on every wave is a
* full-document re-measure storm.
*
* Policy: leading flush + throttled trailing (a cooldown). The FIRST late face flushes
* after a short quiet window (coalescing the initial parallel batch). After ANY flush a
* `cooldownMs` window opens during which further arrivals are deferred; at the cooldown's
* end a single trailing flush drains them, then the cooldown reopens if more arrive. This
* bounds the flush RATE to ~once per cooldown REGARDLESS of arrival spacing - unlike a
* plain debounce (which fires once per wave when waves are farther apart than the window)
* or a per-batch max-wait (which the quiet flush resets before it can bite).
*
* Honest floor: arrivals spaced WIDER than `cooldownMs` reflow per arrival - you cannot
* coalesce a wave that lands after the document already corrected without delaying every
* correction by at least the gap. `cooldownMs` is therefore the max correction lag.
*
* First paint is untouched - the gate's per-font timeout bounds that; this only governs
* the after-the-fact corrections. Timer hooks are injectable so the policy is unit-testable
* without real time.
*/

export type FontReflowFlushReason = 'quiet' | 'throttle' | 'manual';

export interface FontReflowFlushDetails {
reason: FontReflowFlushReason;
/** The face keys batched into this flush (diagnostic; the gate reflows the whole doc). */
faceKeys: string[];
}

export interface FontLateLoadReflowSchedulerOptions {
/** Quiet window before the FIRST flush of an idle scheduler (coalesces the initial burst). */
quietMs?: number;
/** Minimum interval between flushes; the max correction lag for deferred arrivals. */
cooldownMs?: number;
/** Perform the actual one-shot reflow (bump epoch + invalidate caches + request reflow). */
flush: (details: FontReflowFlushDetails) => void;
/** Timer hooks (injectable for tests); default to the globals. */
scheduleTimeout?: (cb: () => void, ms: number) => unknown;
cancelTimeout?: (handle: unknown) => void;
}

export const DEFAULT_REFLOW_QUIET_MS = 250;
export const DEFAULT_REFLOW_COOLDOWN_MS = 2000;

export class FontLateLoadReflowScheduler {
readonly #quietMs: number;
readonly #cooldownMs: number;
readonly #flush: (details: FontReflowFlushDetails) => void;
readonly #scheduleTimeout: (cb: () => void, ms: number) => unknown;
readonly #cancelTimeout: (handle: unknown) => void;

readonly #pending = new Set<string>();
/** Pending leading flush (idle -> quiet window). */
#quietHandle: unknown = null;
/** Active cooldown after a flush; arrivals during it are deferred to its end. */
#cooldownHandle: unknown = null;
/** A face arrived during the cooldown, so a trailing flush is owed at cooldown end. */
#trailing = false;

constructor(options: FontLateLoadReflowSchedulerOptions) {
this.#quietMs = options.quietMs ?? DEFAULT_REFLOW_QUIET_MS;
this.#cooldownMs = options.cooldownMs ?? DEFAULT_REFLOW_COOLDOWN_MS;
this.#flush = options.flush;
this.#scheduleTimeout = options.scheduleTimeout ?? ((cb, ms) => globalThis.setTimeout(cb, ms));
this.#cancelTimeout =
options.cancelTimeout ?? ((handle) => globalThis.clearTimeout(handle as ReturnType<typeof setTimeout>));
}

/**
* Record newly-available required face keys. A call adding no new key is a no-op. If a
* cooldown is active, the arrival is deferred to its end (rate stays bounded); otherwise
* a quiet-window leading flush is armed. Repeated `loadingdone` for the same face cannot
* open a new batch or cause an extra flush.
*/
schedule(changedFaceKeys: Iterable<string>): void {
let added = false;
for (const key of changedFaceKeys) {
if (!this.#pending.has(key)) {
this.#pending.add(key);
added = true;
}
}
if (!added) return;

if (this.#cooldownHandle !== null) {
// In cooldown: defer to its end so the flush rate stays bounded.
this.#trailing = true;
return;
}
if (this.#quietHandle !== null) return; // leading flush already armed
this.#quietHandle = this.#scheduleTimeout(() => this.#onQuietElapsed(), this.#quietMs);
}

/** Flush any pending batch immediately (no-op if nothing pending), then open a cooldown. */
flushNow(reason: FontReflowFlushReason = 'manual'): void {
if (this.#pending.size === 0) return;
this.#clearTimers();
this.#doFlush(reason);
}

/** Drop pending work + timers without flushing, and reset cooldown (call on teardown / config change). */
cancel(): void {
this.#clearTimers();
this.#pending.clear();
this.#trailing = false;
}

#onQuietElapsed(): void {
this.#quietHandle = null;
this.#doFlush('quiet');
}

#onCooldownElapsed(): void {
this.#cooldownHandle = null;
if (this.#trailing && this.#pending.size > 0) {
this.#doFlush('throttle'); // drain arrivals deferred during the cooldown
}
// else: idle - the next schedule() arms a fresh quiet window.
}

/** Emit one reflow for the current batch, then open a cooldown that bounds the next flush. */
#doFlush(reason: FontReflowFlushReason): void {
this.#trailing = false;
if (this.#pending.size > 0) {
const faceKeys = [...this.#pending];
this.#pending.clear();
this.#flush({ reason, faceKeys });
}
this.#cooldownHandle = this.#scheduleTimeout(() => this.#onCooldownElapsed(), this.#cooldownMs);
}

#clearTimers(): void {
if (this.#quietHandle !== null) {
this.#cancelTimeout(this.#quietHandle);
this.#quietHandle = null;
}
if (this.#cooldownHandle !== null) {
this.#cancelTimeout(this.#cooldownHandle);
this.#cooldownHandle = null;
}
}
}
Loading
Loading