Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
Expand Up @@ -5030,6 +5030,10 @@ export class PresentationEditor extends EventEmitter {
// header/footer descriptors against the new converter and rerender so the
// importer tab matches the collaborator tab without waiting for an edit.
const handleDocumentReplaced = () => {
// A new document reuses this gate, so drop the old document's pending late-load reflow
// and required-face state - otherwise a flush armed under the old document fires a
// spurious full reflow against the new one.
this.#fontGate?.resetForDocumentChange();
this.#refreshHeaderFooterStructureThenRerender({ purgeCachedEditors: true });
};
this.#editor.on('documentReplaced', handleDocumentReplaced);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
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('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']);
});

it('a throwing flush does not escape the timer callback and still arms the cooldown', () => {
const clock = makeClock();
let throwOnce = true;
const reasons: string[] = [];
const scheduler = new FontLateLoadReflowScheduler({
quietMs: 250,
cooldownMs: 2000,
flush: (d) => {
if (throwOnce) {
throwOnce = false;
throw new Error('flush blew up');
}
reasons.push(d.reason);
},
scheduleTimeout: clock.scheduleTimeout,
cancelTimeout: clock.cancelTimeout,
});

scheduler.schedule(['a']);
// The quiet flush throws; advancing the timers must NOT surface an uncaught exception.
expect(() => clock.advance(300)).not.toThrow();

// The cooldown was still armed despite the throw: an arrival now defers to its end and a
// trailing flush drains it, proving the rate bound survived the throwing flush.
scheduler.schedule(['b']);
clock.advance(2100);
expect(reasons).toEqual(['throttle']);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
/**
* 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';

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);
}

/** 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;
try {
if (this.#pending.size > 0) {
const faceKeys = [...this.#pending];
this.#pending.clear();
this.#flush({ reason, faceKeys });
}
} catch {
// #doFlush runs inside a timer callback, so a throwing flush would surface as an
// uncaught exception. Font readiness must not break layout - swallow it; the correction
// self-heals on the next schedule().
} finally {
// Always arm the cooldown, even when a flush throws, so the flush rate stays bounded.
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