From 6fe87a9923db22c88abcb9a87549ce0115bea6ad Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Tue, 2 Jun 2026 21:05:59 -0300 Subject: [PATCH 1/3] 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. --- .../fonts/FontLateLoadReflowScheduler.test.ts | 132 ++++++++++++++++ .../fonts/FontLateLoadReflowScheduler.ts | 147 ++++++++++++++++++ .../fonts/FontReadinessGate.test.ts | 94 ++++++++++- .../fonts/FontReadinessGate.ts | 45 +++++- 4 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts create mode 100644 packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts new file mode 100644 index 0000000000..8451f342f9 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts @@ -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 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']); + }); +}); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts new file mode 100644 index 0000000000..b5ee0bccd8 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts @@ -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(); + /** 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)); + } + + /** + * 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): 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; + } + } +} diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts index fe01f7bad0..6ff6d1e0a1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -72,17 +72,48 @@ class FakeFontSet { const calibriToCarlito = (families: string[]) => families.map((f) => (f === 'Calibri' ? 'Carlito' : f)); +/** Virtual clock so tests can advance past the late-load scheduler's quiet/cooldown windows. */ +function makeClock() { + let nowMs = 0; + let seq = 0; + const timers = new Map 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; + }, + }; +} + describe('FontReadinessGate', () => { let registry: FakeRegistry; let fontSet: FakeFontSet; let requestReflow: ReturnType; let invalidateCaches: ReturnType; + let clock: ReturnType; beforeEach(() => { registry = new FakeRegistry(); fontSet = new FakeFontSet(); requestReflow = vi.fn(); invalidateCaches = vi.fn(); + clock = makeClock(); }); function makeGate(documentFonts: string[]) { @@ -94,6 +125,8 @@ describe('FontReadinessGate', () => { invalidateCaches, getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), timeoutMs: 1000, + scheduleTimeout: clock.scheduleTimeout, + cancelTimeout: clock.cancelTimeout, }); } @@ -139,11 +172,32 @@ describe('FontReadinessGate', () => { registry.available.add('Carlito'); fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + // Reflow is batched: nothing until the scheduler's quiet window elapses. + expect(requestReflow).not.toHaveBeenCalled(); + clock.advance(300); expect(invalidateCaches).toHaveBeenCalledTimes(1); expect(requestReflow).toHaveBeenCalledTimes(1); expect(gate.fontConfigVersion).toBe(1); }); + it('batches several late faces into one reflow within the quiet window', async () => { + registry.statuses.set('Carlito', 'timed_out'); + registry.statuses.set('Caladea', 'timed_out'); + const gate = makeGate(['Carlito', 'Caladea']); + await gate.ensureReadyForMeasure(); + + registry.statuses.set('Carlito', 'loaded'); + registry.statuses.set('Caladea', 'loaded'); + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + clock.advance(100); // still within the quiet window + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Caladea' }] }); + expect(requestReflow).not.toHaveBeenCalled(); + + clock.advance(300); + expect(requestReflow).toHaveBeenCalledTimes(1); // one reflow for both faces + expect(gate.fontConfigVersion).toBe(1); + }); + it('does not reflow again on a second loadingdone for the same face (no loop)', async () => { registry.statuses.set('Carlito', 'timed_out'); const gate = makeGate(['Calibri']); @@ -153,6 +207,7 @@ describe('FontReadinessGate', () => { registry.available.add('Carlito'); fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + clock.advance(300); expect(invalidateCaches).toHaveBeenCalledTimes(1); expect(requestReflow).toHaveBeenCalledTimes(1); @@ -165,11 +220,26 @@ describe('FontReadinessGate', () => { await gate.ensureReadyForMeasure(); fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + clock.advance(300); + + expect(requestReflow).not.toHaveBeenCalled(); + }); + + it('dispose cancels a pending batched reflow (no reflow after teardown)', async () => { + registry.statuses.set('Carlito', 'timed_out'); + const gate = makeGate(['Calibri']); + await gate.ensureReadyForMeasure(); + + registry.statuses.set('Carlito', 'loaded'); + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); // schedules a flush + gate.dispose(); + clock.advance(5000); expect(requestReflow).not.toHaveBeenCalled(); + expect(invalidateCaches).not.toHaveBeenCalled(); }); - it('notifyFontConfigChanged bumps the epoch, invalidates, and reflows', () => { + it('notifyFontConfigChanged bumps the epoch, invalidates, and reflows immediately (not batched)', () => { const gate = makeGate(['Calibri']); gate.notifyFontConfigChanged(); @@ -179,6 +249,20 @@ describe('FontReadinessGate', () => { expect(requestReflow).toHaveBeenCalledTimes(1); }); + it('notifyFontConfigChanged cancels a pending batched late-load (no double reflow)', async () => { + registry.statuses.set('Carlito', 'timed_out'); + const gate = makeGate(['Calibri']); + await gate.ensureReadyForMeasure(); + + registry.statuses.set('Carlito', 'loaded'); + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); // schedules a batched reflow + gate.notifyFontConfigChanged(); // immediate reflow; must also cancel the pending batch + expect(requestReflow).toHaveBeenCalledTimes(1); + + clock.advance(300); // the cancelled quiet timer must NOT fire a second reflow + expect(requestReflow).toHaveBeenCalledTimes(1); + }); + it('exposes the last summary as diagnostics', async () => { registry.statuses.set('Carlito', 'loaded'); registry.available.add('Carlito'); @@ -215,6 +299,8 @@ describe('FontReadinessGate', () => { invalidateCaches, getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), timeoutMs: 1000, + scheduleTimeout: clock.scheduleTimeout, + cancelTimeout: clock.cancelTimeout, }); } @@ -234,16 +320,20 @@ describe('FontReadinessGate', () => { // A REGULAR Carlito face finishing must NOT reflow - it is not a required face. fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'normal', style: 'normal' }] }); + clock.advance(300); expect(requestReflow).not.toHaveBeenCalled(); - // The required BOLD face finishing DOES reflow, exactly once. + // The required BOLD face finishing DOES reflow (batched), exactly once after the window. registry.faceStatuses.set(faceKey(BOLD), 'loaded'); fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] }); + expect(requestReflow).not.toHaveBeenCalled(); // batched, not yet flushed + clock.advance(300); expect(requestReflow).toHaveBeenCalledTimes(1); expect(invalidateCaches).toHaveBeenCalledTimes(1); // A second loadingdone for the same face does not reflow again (no loop). fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] }); + clock.advance(300); expect(requestReflow).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index 16f4d1a203..e585aa39ea 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -14,6 +14,7 @@ import { export type { FontLoadSummary } from '@superdoc/font-system'; import { clearTextMeasurementCaches } from '@superdoc/measuring-dom'; import { measureCache } from '@superdoc/layout-bridge'; +import { FontLateLoadReflowScheduler } from './FontLateLoadReflowScheduler'; /** * The font set the gate operates on plus the constructor for new managed faces. @@ -68,6 +69,13 @@ export interface FontReadinessGateOptions { * (text/font-metric/table caches + the block-measure cache). Injectable for tests. */ invalidateCaches?: () => void; + /** Late-load reflow batching: quiet window before the leading flush. Defaults to the scheduler default. */ + reflowQuietMs?: number; + /** Late-load reflow batching: cooldown (min interval between flushes). Defaults to the scheduler default. */ + reflowCooldownMs?: number; + /** Timer hooks for the late-load scheduler (injectable for tests); default to the globals. */ + scheduleTimeout?: (cb: () => void, ms: number) => unknown; + cancelTimeout?: (handle: unknown) => void; } /** @@ -109,6 +117,8 @@ export class FontReadinessGate { readonly #seenAvailableFaces = new Set(); #lastSummary: FontLoadSummary | null = null; #loadingDoneHandler: ((event: FontFaceSetLoadEvent) => void) | null = null; + /** Batches late-load reflows so many font arrivals coalesce into bounded re-measures. */ + readonly #lateLoadScheduler: FontLateLoadReflowScheduler; constructor(options: FontReadinessGateOptions) { this.#getDocumentFonts = options.getDocumentFonts; @@ -120,6 +130,13 @@ export class FontReadinessGate { this.#onRegistryResolved = options.onRegistryResolved ?? null; this.#timeoutMs = options.timeoutMs ?? DEFAULT_FONT_LOAD_TIMEOUT_MS; this.#invalidateCaches = options.invalidateCaches ?? defaultInvalidate; + this.#lateLoadScheduler = new FontLateLoadReflowScheduler({ + quietMs: options.reflowQuietMs, + cooldownMs: options.reflowCooldownMs, + flush: () => this.#flushLateFontLoads(), + scheduleTimeout: options.scheduleTimeout, + cancelTimeout: options.cancelTimeout, + }); } /** @@ -253,17 +270,22 @@ export class FontReadinessGate { this.#seenAvailable.clear(); this.#seenAvailableFaces.clear(); this.#requiredSignature = ''; + // Drop any pending batched late-load reflow: this immediate reflow supersedes it, so a + // stale batch must not fire a second reflow just after. + this.#lateLoadScheduler.cancel(); this.#invalidateCaches(); this.#requestReflow(); } - /** Remove the late-load listener. Call on editor teardown. */ + /** Remove the late-load listener and cancel any pending batched reflow. Call on teardown. */ dispose(): void { const fontSet = this.#context?.fontSet ?? null; if (fontSet && this.#loadingDoneHandler && typeof fontSet.removeEventListener === 'function') { fontSet.removeEventListener('loadingdone', this.#loadingDoneHandler); } this.#loadingDoneHandler = null; + // Cancel pending batched reflow so a destroyed editor never reflows after teardown. + this.#lateLoadScheduler.cancel(); } /** Resolve (and cache) the watched font set + its paired registry. */ @@ -301,12 +323,14 @@ export class FontReadinessGate { #onLoadingDone(event: FontFaceSetLoadEvent): void { // A required face/family that the last measure could not use just finished loading -> - // that paint used a fallback, so invalidate and reflow. We key off the faces the event - // actually reports as loaded (reliable), NOT FontFaceSet.check() (which lies for - // unregistered bare families). The seen-set fires this at most once per face. + // that paint used a fallback, so it must invalidate and reflow. We key off the faces the + // event actually reports as loaded (reliable), NOT FontFaceSet.check() (which lies for + // unregistered bare families). The seen-set records each at most once. The actual reflow + // is BATCHED through the late-load scheduler so many arrival waves coalesce into bounded + // re-measures instead of one full reflow per wave. const faces = event?.fontfaces ?? []; if (faces.length === 0) return; - let changed = false; + const changedKeys: string[] = []; if (this.#requiredFaceKeys.size > 0) { // Face path: reflow only when a loaded face matches a REQUIRED face key (family + @@ -319,7 +343,7 @@ export class FontReadinessGate { if (this.#seenAvailableFaces.has(key)) continue; if (loadedFaceKeys.has(key)) { this.#seenAvailableFaces.add(key); - changed = true; + changedKeys.push(key); } } } else { @@ -329,12 +353,17 @@ export class FontReadinessGate { if (this.#seenAvailable.has(family)) continue; if (loadedFamilies.has(normalizeFamilyKey(family))) { this.#seenAvailable.add(family); - changed = true; + changedKeys.push(normalizeFamilyKey(family)); } } } - if (!changed) return; + if (changedKeys.length === 0) return; + this.#lateLoadScheduler.schedule(changedKeys); + } + + /** One batched correction for late-loaded faces: bump epoch, invalidate caches, reflow. */ + #flushLateFontLoads(): void { this.#fontConfigVersion += 1; bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust this.#invalidateCaches(); From c3c57c3826a2c43be6fa9fa63aad9ff5cefa2f32 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 13:21:03 -0300 Subject: [PATCH 2/3] fix(fonts): invalidate caches immediately on late load; reset gate on document swap Review follow-ups on the bounded late-load reflow scheduler: - Move the epoch bump + cache invalidation from the batched flush to the loadingdone handler so they fire immediately. Measure caches are keyed without the font epoch (fontMetricsCache: family|size|bold|italic), so deferring the explicit clear left a window where a concurrent re-measure could cache fallback metrics. Only the expensive reflow stays batched. - Reset the gate's late-load state on documentReplaced (new resetForDocumentChange) so a flush armed under the old document cannot fire a spurious reflow against the new one. - notifyFontConfigChanged now also clears the required face/family sets (shared #resetRequiredAndSeen), closing a latent redundant-reflow race. - Wrap the scheduler flush in try/finally so the cooldown always arms and a timer callback never leaks an uncaught exception. - Trim the unused flushNow / 'manual' surface; correct the epoch doc (measure caches are not epoch-keyed); make the "no loop" test drain the cooldown so it is not vacuous. --- .../presentation-editor/PresentationEditor.ts | 4 ++ .../fonts/FontLateLoadReflowScheduler.test.ts | 16 ------ .../fonts/FontLateLoadReflowScheduler.ts | 24 ++++----- .../fonts/FontReadinessGate.test.ts | 19 ++++--- .../fonts/FontReadinessGate.ts | 54 +++++++++++++++---- shared/font-system/src/epoch.ts | 9 ++-- 6 files changed, 77 insertions(+), 49 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts index 55b2ebdd70..cd1f83370e 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/PresentationEditor.ts @@ -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); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts index 8451f342f9..7122e846a0 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts @@ -101,22 +101,6 @@ describe('FontLateLoadReflowScheduler', () => { 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']); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts index b5ee0bccd8..cd98f816d1 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts @@ -24,7 +24,7 @@ * without real time. */ -export type FontReflowFlushReason = 'quiet' | 'throttle' | 'manual'; +export type FontReflowFlushReason = 'quiet' | 'throttle'; export interface FontReflowFlushDetails { reason: FontReflowFlushReason; @@ -96,13 +96,6 @@ export class FontLateLoadReflowScheduler { 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(); @@ -126,12 +119,17 @@ export class FontLateLoadReflowScheduler { /** 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 }); + try { + if (this.#pending.size > 0) { + const faceKeys = [...this.#pending]; + this.#pending.clear(); + this.#flush({ reason, faceKeys }); + } + } finally { + // Always arm the cooldown, even if #flush throws, so the flush rate stays bounded and a + // timer callback never leaks an uncaught exception (font readiness must not break layout). + this.#cooldownHandle = this.#scheduleTimeout(() => this.#onCooldownElapsed(), this.#cooldownMs); } - this.#cooldownHandle = this.#scheduleTimeout(() => this.#onCooldownElapsed(), this.#cooldownMs); } #clearTimers(): void { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts index 83fe5238b3..ecacaef04f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -197,10 +197,13 @@ describe('FontReadinessGate', () => { clock.advance(100); // still within the quiet window fontSet.fire('loadingdone', { fontfaces: [{ family: 'Caladea' }] }); expect(requestReflow).not.toHaveBeenCalled(); + // Caches + epoch are cleared immediately as each face arrives (measure caches are not + // epoch-keyed), so a re-measure in the quiet window already sees the loaded font. + expect(invalidateCaches).toHaveBeenCalledTimes(2); + expect(gate.fontConfigVersion).toBe(2); clock.advance(300); - expect(requestReflow).toHaveBeenCalledTimes(1); // one reflow for both faces - expect(gate.fontConfigVersion).toBe(1); + expect(requestReflow).toHaveBeenCalledTimes(1); // but only ONE (expensive) reflow for both }); it('does not reflow again on a second loadingdone for the same face (no loop)', async () => { @@ -236,12 +239,13 @@ describe('FontReadinessGate', () => { await gate.ensureReadyForMeasure(); registry.statuses.set('Carlito', 'loaded'); - fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); // schedules a flush + fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); // invalidates now; schedules the reflow gate.dispose(); clock.advance(5000); + // The cache clear is immediate (on load, before dispose); dispose cancels the pending reflow. + expect(invalidateCaches).toHaveBeenCalledTimes(1); expect(requestReflow).not.toHaveBeenCalled(); - expect(invalidateCaches).not.toHaveBeenCalled(); }); it('notifyFontConfigChanged bumps the epoch, invalidates, and reflows immediately (not batched)', () => { @@ -360,10 +364,13 @@ describe('FontReadinessGate', () => { expect(requestReflow).toHaveBeenCalledTimes(1); expect(invalidateCaches).toHaveBeenCalledTimes(1); - // A second loadingdone for the same face does not reflow again (no loop). + // A second loadingdone for the SAME face must not reflow again. Drain the full cooldown: + // a broken dedup would re-invalidate immediately AND flush a trailing reflow at cooldown + // end, so advancing past it (not just 300ms inside it) is what makes this assertion real. fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito', weight: 'bold', style: 'normal' }] }); - clock.advance(300); + clock.advance(2500); // past the post-flush cooldown (2000ms) expect(requestReflow).toHaveBeenCalledTimes(1); + expect(invalidateCaches).toHaveBeenCalledTimes(1); }); it('falls back to the family path when face planning throws', async () => { diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index 94e56dd3a3..16f8a9ffa6 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -272,16 +272,34 @@ export class FontReadinessGate { notifyFontConfigChanged(): void { this.#fontConfigVersion += 1; bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust - this.#seenAvailable.clear(); - this.#seenAvailableFaces.clear(); - this.#requiredSignature = ''; - // Drop any pending batched late-load reflow: this immediate reflow supersedes it, so a - // stale batch must not fire a second reflow just after. + // Reset the required + seen sets so an in-flight `loadingdone` can't re-arm a reflow for a + // face this immediate reflow already corrects; the next pass re-plans from scratch. + this.#resetRequiredAndSeen(); + // Drop any pending batched late-load reflow: this immediate reflow supersedes it. this.#lateLoadScheduler.cancel(); this.#invalidateCaches(); this.#requestReflow(); } + /** + * Reset late-load state for a document swap: cancel the pending batched reflow and drop the + * prior document's required/seen sets, so a flush armed under the old document cannot fire a + * spurious reflow against the new one. The new document's own render re-plans and invalidates. + */ + resetForDocumentChange(): void { + this.#lateLoadScheduler.cancel(); + this.#resetRequiredAndSeen(); + } + + /** Clear the per-document required + seen face/family sets and the required-set signature. */ + #resetRequiredAndSeen(): void { + this.#requiredSignature = ''; + this.#requiredFaceKeys = new Set(); + this.#requiredFamilies = new Set(); + this.#seenAvailable.clear(); + this.#seenAvailableFaces.clear(); + } + /** Remove the late-load listener and cancel any pending batched reflow. Call on teardown. */ dispose(): void { const fontSet = this.#context?.fontSet ?? null; @@ -364,14 +382,23 @@ export class FontReadinessGate { } if (changedKeys.length === 0) return; + // The available-font picture changed NOW, so bump the epoch and clear the measurement + // caches immediately - measure caches are keyed without the epoch (fontMetricsCache is + // `family|size|bold|italic`), so this explicit clear is the only thing that busts them, + // and any re-measure/paint before the batched reflow must already see the loaded font. + // Only the expensive full reflow is deferred to the scheduler so arrival waves coalesce. + this.#fontConfigVersion += 1; + bumpFontConfigVersion(); // bump the global epoch so paint reuse signatures bust + this.#invalidateCaches(); this.#lateLoadScheduler.schedule(changedKeys); } - /** One batched correction for late-loaded faces: bump epoch, invalidate caches, reflow. */ + /** + * The batched late-load correction: only the expensive re-measure/reflow. The epoch bump and + * cache invalidation already fired synchronously in `#onLoadingDone`, so the document is never + * left measuring against stale caches while the reflow waits out the scheduler's window. + */ #flushLateFontLoads(): void { - this.#fontConfigVersion += 1; - bumpFontConfigVersion(); // bump the global epoch so measure/paint reuse signatures bust - this.#invalidateCaches(); this.#requestReflow(); } } @@ -423,7 +450,14 @@ function summarize(results: FontLoadResult[]): FontLoadSummary { // Status precedence for rolling per-face outcomes up to a family: a settled failure must // never be masked by a loaded sibling. Mirrors FontRegistry.getStatus's rollup order. -const FACE_STATUS_PRIORITY: FontLoadStatus[] = ['failed', 'timed_out', 'fallback_used', 'loaded', 'loading', 'unloaded']; +const FACE_STATUS_PRIORITY: FontLoadStatus[] = [ + 'failed', + 'timed_out', + 'fallback_used', + 'loaded', + 'loading', + 'unloaded', +]; function summarizeFaces(results: FontFaceLoadResult[]): FontLoadSummary { // FontLoadSummary's counts are documented as distinct physical FAMILIES and ride the diff --git a/shared/font-system/src/epoch.ts b/shared/font-system/src/epoch.ts index 48cd443ac7..e6ba87e3c6 100644 --- a/shared/font-system/src/epoch.ts +++ b/shared/font-system/src/epoch.ts @@ -2,10 +2,11 @@ * Global font-configuration epoch. * * Increments whenever the available-font picture changes: a bundled/customer face - * finishes loading, or a mapping is added/removed. Reuse signatures (measure and paint) - * fold this value in so a font change busts stale reuse - a fragment measured or painted - * before a font loaded carries the old epoch, so once the epoch bumps its signature no - * longer matches and it is re-measured / repainted with the now-available font. + * finishes loading, or a mapping is added/removed. PAINT reuse signatures fold this value + * in (see versionSignature), so a fragment painted before a font loaded carries the old + * epoch and repaints once it bumps. Measurement caches are NOT keyed by the epoch + * (fontMetricsCache keys on `family|size|bold|italic`); the readiness gate instead clears + * them explicitly when a font loads, so a stale measurement never survives a font change. * * It is deliberately a single global, not per-document: font changes are rare and a * cross-document repaint is cheap and never wrong. A per-document epoch is a future From e143cc5c2ac328357cd66b4b885a4075a372cc38 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Wed, 3 Jun 2026 13:42:07 -0300 Subject: [PATCH 3/3] fix(fonts): catch throwing late-load flush; clear cached summary on document reset Two follow-ups on the scheduler review: - The scheduler flush ran in a try/finally, which armed the cooldown but still let a throwing flush escape the timer callback as an uncaught exception. Wrap it in try/catch/finally so a throw is swallowed (font readiness must not break layout) and the cooldown still arms. Add a test driving a throwing flush. - resetForDocumentChange left #lastSummary set, so an empty/no-text new document would short-circuit to the prior document's load summary. Clear it in the reset helper; add a test. --- .../fonts/FontLateLoadReflowScheduler.test.ts | 29 +++++++++++++++++++ .../fonts/FontLateLoadReflowScheduler.ts | 7 +++-- .../fonts/FontReadinessGate.test.ts | 28 ++++++++++++++++++ .../fonts/FontReadinessGate.ts | 5 +++- 4 files changed, 66 insertions(+), 3 deletions(-) diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts index 7122e846a0..a7f189a5c3 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts @@ -113,4 +113,33 @@ describe('FontLateLoadReflowScheduler', () => { 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']); + }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts index cd98f816d1..67e080edcc 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts @@ -125,9 +125,12 @@ export class FontLateLoadReflowScheduler { 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 if #flush throws, so the flush rate stays bounded and a - // timer callback never leaks an uncaught exception (font readiness must not break layout). + // Always arm the cooldown, even when a flush throws, so the flush rate stays bounded. this.#cooldownHandle = this.#scheduleTimeout(() => this.#onCooldownElapsed(), this.#cooldownMs); } } diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts index ecacaef04f..764806729f 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.test.ts @@ -397,5 +397,33 @@ describe('FontReadinessGate', () => { expect(registry.awaitCalls).toEqual([['Carlito']]); expect(summary.loaded).toBe(1); }); + + it('resetForDocumentChange clears the cached summary so an empty new document does not reuse it', async () => { + const REGULAR: FontFaceRequest = { family: 'Carlito', weight: '400', style: 'normal' }; + registry.faceStatuses.set(faceKey(REGULAR), 'loaded'); + let faces: FontFaceRequest[] = [REGULAR]; + const gate = new FontReadinessGate({ + registry: registry.asRegistry(), + getDocumentFonts: () => [], + getRequiredFaces: () => faces, + requestReflow, + invalidateCaches, + getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), + timeoutMs: 1000, + scheduleTimeout: clock.scheduleTimeout, + cancelTimeout: clock.cancelTimeout, + }); + + const first = await gate.ensureReadyForMeasure(); + expect(first.loaded).toBe(1); // Carlito loaded for the first document + + // Swap to a document with no required faces. With #lastSummary uncleared, the empty + // plan short-circuits to the prior summary; the reset must prevent that. + gate.resetForDocumentChange(); + faces = []; + const second = await gate.ensureReadyForMeasure(); + expect(second.loaded).toBe(0); + expect(second.results).toEqual([]); + }); }); }); diff --git a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts index 16f8a9ffa6..2586213dec 100644 --- a/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontReadinessGate.ts @@ -291,13 +291,16 @@ export class FontReadinessGate { this.#resetRequiredAndSeen(); } - /** Clear the per-document required + seen face/family sets and the required-set signature. */ + /** Clear the per-document required + seen face/family sets, the signature, and the cached + * summary, so the next readiness pass cannot reuse the prior document's diagnostics (an + * empty/no-text new document would otherwise short-circuit to the stale summary). */ #resetRequiredAndSeen(): void { this.#requiredSignature = ''; this.#requiredFaceKeys = new Set(); this.#requiredFamilies = new Set(); this.#seenAvailable.clear(); this.#seenAvailableFaces.clear(); + this.#lastSummary = null; } /** Remove the late-load listener and cancel any pending batched reflow. Call on teardown. */