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 new file mode 100644 index 0000000000..a7f189a5c3 --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.test.ts @@ -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 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']); + }); +}); 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..67e080edcc --- /dev/null +++ b/packages/super-editor/src/editors/v1/core/presentation-editor/fonts/FontLateLoadReflowScheduler.ts @@ -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(); + /** 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); + } + + /** 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; + } + } +} 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 3a60db74ee..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 @@ -77,17 +77,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[]) { @@ -99,6 +130,8 @@ describe('FontReadinessGate', () => { invalidateCaches, getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), timeoutMs: 1000, + scheduleTimeout: clock.scheduleTimeout, + cancelTimeout: clock.cancelTimeout, }); } @@ -144,11 +177,35 @@ 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(); + // 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); // but only ONE (expensive) reflow for both + }); + 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']); @@ -158,6 +215,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); @@ -170,11 +228,27 @@ describe('FontReadinessGate', () => { await gate.ensureReadyForMeasure(); fontSet.fire('loadingdone', { fontfaces: [{ family: 'Carlito' }] }); + clock.advance(300); expect(requestReflow).not.toHaveBeenCalled(); }); - it('notifyFontConfigChanged bumps the epoch, invalidates, and reflows', () => { + 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' }] }); // 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(); + }); + + it('notifyFontConfigChanged bumps the epoch, invalidates, and reflows immediately (not batched)', () => { const gate = makeGate(['Calibri']); gate.notifyFontConfigChanged(); @@ -184,6 +258,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'); @@ -220,6 +308,8 @@ describe('FontReadinessGate', () => { invalidateCaches, getFontEnvironment: () => ({ fontSet: fontSet.asFontSet(), FontFaceCtor: fakeCtor }), timeoutMs: 1000, + scheduleTimeout: clock.scheduleTimeout, + cancelTimeout: clock.cancelTimeout, }); } @@ -263,17 +353,24 @@ 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). + // 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(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 () => { @@ -300,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 8275eb2687..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 @@ -15,6 +15,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. @@ -69,6 +70,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; } /** @@ -110,6 +118,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; @@ -121,6 +131,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, + }); } /** @@ -255,20 +272,46 @@ 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 = ''; + // 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(); } - /** Remove the late-load listener. Call on editor teardown. */ + /** + * 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, 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. */ 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. */ @@ -306,12 +349,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 + @@ -324,7 +369,7 @@ export class FontReadinessGate { if (this.#seenAvailableFaces.has(key)) continue; if (loadedFaceKeys.has(key)) { this.#seenAvailableFaces.add(key); - changed = true; + changedKeys.push(key); } } } else { @@ -334,15 +379,29 @@ 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; + // 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 measure/paint reuse signatures bust + bumpFontConfigVersion(); // bump the global epoch so paint reuse signatures bust this.#invalidateCaches(); + this.#lateLoadScheduler.schedule(changedKeys); + } + + /** + * 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.#requestReflow(); } } @@ -394,7 +453,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