@solidtv/renderer 1.6.2 → 1.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,195 @@
1
+ /*
2
+ * Font prefetching — stage-free.
3
+ *
4
+ * Font bytes normally only start moving once a Stage exists, because
5
+ * `Stage.loadFont()` is the only entry point. That serializes the network
6
+ * behind the whole synchronous boot (GL context, buffers, shaders, root node)
7
+ * and, for SDF, behind the texture manager's async init as well.
8
+ *
9
+ * Nothing about *fetching* a font needs a Stage: the SDF atlas is a JSON
10
+ * document plus an image, and a web font is a `FontFace`. This module starts
11
+ * that work as early as an app can call it — typically before the renderer is
12
+ * constructed — and parks the results. The font handlers pick them up from
13
+ * here when `loadFont()` eventually runs, so the round-trip overlaps CPU boot
14
+ * instead of following it.
15
+ *
16
+ * Everything here uses XHR rather than `fetch` to match the rest of the
17
+ * renderer (Chrome 38 / webOS 3 have no `fetch`).
18
+ */
19
+
20
+ import type { FontLoadOptions } from './TextRenderer.js';
21
+ import type { SdfFontData } from './SdfFontHandler.js';
22
+ import { fetchJson } from '../lib/utils.js';
23
+
24
+ /**
25
+ * A prefetched SDF font: the parsed atlas description and the atlas image,
26
+ * fetched concurrently. Either promise resolves to `null` if its request
27
+ * failed — the handler then falls back to its normal load path rather than
28
+ * inheriting the failure.
29
+ */
30
+ export interface PrefetchedSdfFont {
31
+ data: Promise<SdfFontData | null>;
32
+ atlas: Promise<Blob | null>;
33
+ }
34
+
35
+ const sdfPrefetch = new Map<string, PrefetchedSdfFont>();
36
+ const canvasPrefetch = new Map<string, Promise<FontFace[] | null>>();
37
+
38
+ /**
39
+ * Options accepted by {@link prefetchFont}. Identical to the options passed to
40
+ * `Stage.loadFont()`, plus the optional SDF `type` discriminator that
41
+ * framework layers already carry on their font descriptors.
42
+ */
43
+ export type FontPrefetchOptions = FontLoadOptions & {
44
+ type?: 'ssdf' | 'msdf' | 'canvas';
45
+ };
46
+
47
+ /**
48
+ * Swallow a rejection into `null` so a prefetch that fails can never surface
49
+ * as an unhandled rejection. A prefetch is an optimization: when it fails the
50
+ * handler just does the work itself.
51
+ */
52
+ const orNull = <T>(promise: Promise<T>): Promise<T | null> =>
53
+ promise.then(
54
+ (value) => value,
55
+ () => null,
56
+ );
57
+
58
+ /**
59
+ * Normalize an atlas description response into usable font data, or `null` if
60
+ * it is unusable.
61
+ *
62
+ * `fetchJson` resolves rather than rejects on a status of 0 (the file://
63
+ * allowance), so a failed request arrives here as `null`. Engines without
64
+ * `responseType = 'json'` support hand back the raw string instead of a parsed
65
+ * object — the SDF handler tolerates both, so this does too.
66
+ */
67
+ const normalizeSdfData = (response: unknown): SdfFontData | null => {
68
+ let data = response;
69
+
70
+ if (typeof data === 'string') {
71
+ try {
72
+ data = JSON.parse(data);
73
+ } catch (e) {
74
+ return null;
75
+ }
76
+ }
77
+
78
+ if (data === null || typeof data !== 'object') {
79
+ return null;
80
+ }
81
+
82
+ // Reject anything the handler would reject anyway, so a malformed prefetch
83
+ // falls back to a fresh fetch instead of failing the load outright.
84
+ return (data as SdfFontData).chars !== undefined
85
+ ? (data as SdfFontData)
86
+ : null;
87
+ };
88
+
89
+ /**
90
+ * Start downloading a font before a renderer exists.
91
+ *
92
+ * Safe to call more than once for the same font — the first call wins and
93
+ * later ones are ignored. Never throws and never rejects; failures degrade to
94
+ * the handler's normal load path.
95
+ *
96
+ * The descriptor decides what is fetched: an `atlasDataUrl` means an SDF font
97
+ * (atlas JSON + atlas image, in parallel), otherwise a `fontUrl` means a web
98
+ * font (`FontFace.load()`).
99
+ *
100
+ * @param options - The same options later passed to `Stage.loadFont()`
101
+ */
102
+ export const prefetchFont = (options: FontPrefetchOptions): void => {
103
+ const { fontFamily, fontUrl, atlasUrl, atlasDataUrl } = options;
104
+ const names = Array.isArray(fontFamily) ? fontFamily : [fontFamily];
105
+ const primary = names[0];
106
+
107
+ if (primary === undefined) {
108
+ return;
109
+ }
110
+
111
+ // SDF: atlas description and atlas image are independent requests. The
112
+ // handler fetches them sequentially (it needs the JSON before it knows the
113
+ // atlas is usable); here there is no such constraint, so overlap them.
114
+ if (atlasDataUrl !== undefined && options.type !== 'canvas') {
115
+ if (sdfPrefetch.has(primary) === true) {
116
+ return;
117
+ }
118
+
119
+ sdfPrefetch.set(primary, {
120
+ data: orNull(fetchJson(atlasDataUrl, 'json')).then(normalizeSdfData),
121
+ atlas:
122
+ atlasUrl !== undefined
123
+ ? orNull(fetchJson(atlasUrl, 'blob') as Promise<Blob>).then((blob) =>
124
+ blob instanceof Blob ? blob : null,
125
+ )
126
+ : Promise.resolve(null),
127
+ });
128
+ return;
129
+ }
130
+
131
+ // Web font: `FontFace.load()` starts the download immediately. The face is
132
+ // not registered with the document here — that needs the platform, so the
133
+ // handler does it at attach time.
134
+ if (fontUrl !== undefined) {
135
+ if (canvasPrefetch.has(primary) === true) {
136
+ return;
137
+ }
138
+
139
+ if (typeof FontFace === 'undefined') {
140
+ return;
141
+ }
142
+
143
+ canvasPrefetch.set(
144
+ primary,
145
+ orNull(
146
+ Promise.all(
147
+ names.map((name) => new FontFace(name, `url(${fontUrl})`).load()),
148
+ ),
149
+ ),
150
+ );
151
+ }
152
+ };
153
+
154
+ /**
155
+ * Claim a prefetched SDF font, removing it from the pool.
156
+ *
157
+ * One-shot by design: a load that fails and retries must go back to the
158
+ * network rather than replaying a stale (possibly failed) prefetch.
159
+ *
160
+ * @param fontFamily - Primary font family name
161
+ */
162
+ export const takeSdfPrefetch = (
163
+ fontFamily: string,
164
+ ): PrefetchedSdfFont | undefined => {
165
+ const prefetched = sdfPrefetch.get(fontFamily);
166
+ if (prefetched !== undefined) {
167
+ sdfPrefetch.delete(fontFamily);
168
+ }
169
+ return prefetched;
170
+ };
171
+
172
+ /**
173
+ * Claim prefetched web font faces, removing them from the pool. One-shot for
174
+ * the same reason as {@link takeSdfPrefetch}.
175
+ *
176
+ * @param fontFamily - Primary font family name
177
+ */
178
+ export const takeCanvasPrefetch = (
179
+ fontFamily: string,
180
+ ): Promise<FontFace[] | null> | undefined => {
181
+ const prefetched = canvasPrefetch.get(fontFamily);
182
+ if (prefetched !== undefined) {
183
+ canvasPrefetch.delete(fontFamily);
184
+ }
185
+ return prefetched;
186
+ };
187
+
188
+ /**
189
+ * Drop every pending prefetch. Test/teardown helper — in-flight requests are
190
+ * not aborted, their results are simply no longer claimable.
191
+ */
192
+ export const clearFontPrefetch = (): void => {
193
+ sdfPrefetch.clear();
194
+ canvasPrefetch.clear();
195
+ };
@@ -13,6 +13,7 @@ import { hasZeroWidthSpace } from './Utils.js';
13
13
  import { normalizeFontMetrics } from './TextLayoutEngine.js';
14
14
  import { isProductionEnvironment } from '../../utils.js';
15
15
  import type { TextureError } from '../TextureError.js';
16
+ import { takeSdfPrefetch } from './FontPrefetch.js';
16
17
 
17
18
  /**
18
19
  * SDF Font Data structure matching msdf-bmfont-xml output
@@ -315,6 +316,11 @@ export const loadFont = (
315
316
  const names = Array.isArray(fontFamily) ? fontFamily : [fontFamily];
316
317
  const primary = names[0]!;
317
318
 
319
+ // A prefetch started before the renderer existed, if any. Claimed here —
320
+ // ahead of the early returns — so that a font which turns out to need no
321
+ // load still releases the prefetched atlas blob rather than pinning it.
322
+ const prefetched = takeSdfPrefetch(primary);
323
+
318
324
  // Early return if already loaded
319
325
  if (fontCache.get(primary) !== undefined) {
320
326
  return Promise.resolve();
@@ -380,7 +386,15 @@ export const loadFont = (
380
386
  // success it processes + caches the font and wakes parked nodes. On failure
381
387
  // it drops the dead atlas texture (createTexture caches by `src`, so the
382
388
  // next attempt must evict it to build a fresh one) and rejects.
383
- const loadAtlas = (fontData: SdfFontData): Promise<void> => {
389
+ //
390
+ // `atlasBlob` is the prefetched atlas image when one was claimed. Passing it
391
+ // as `src` skips a second download; `key` is set to the atlas URL so the
392
+ // texture cache key is identical to the one the URL path produces — the two
393
+ // paths must not produce two cache entries for the same atlas.
394
+ const loadAtlas = (
395
+ fontData: SdfFontData,
396
+ atlasBlob: Blob | null,
397
+ ): Promise<void> => {
384
398
  if (!fontData || !fontData.chars) {
385
399
  return Promise.reject(new Error('Invalid SDF font data format'));
386
400
  }
@@ -395,7 +409,8 @@ export const loadFont = (
395
409
  return new Promise<void>((resolve, reject) => {
396
410
  // create new atlas texture using ImageTexture
397
411
  const atlasTexture = stage.txManager.createTexture('ImageTexture', {
398
- src: atlasUrl,
412
+ src: atlasBlob !== null ? atlasBlob : atlasUrl,
413
+ key: atlasUrl,
399
414
  premultiplyAlpha: false,
400
415
  });
401
416
 
@@ -454,15 +469,31 @@ export const loadFont = (
454
469
  });
455
470
  };
456
471
 
457
- // Initial attempt plus up to MAX_FONT_LOAD_RETRIES automatic reloads.
472
+ // Initial attempt plus up to MAX_FONT_LOAD_RETRIES automatic reloads. Only
473
+ // the first attempt uses the prefetch; a retry goes back to the network
474
+ // rather than replaying a payload that may be exactly what failed.
458
475
  const loadPromise = (async (): Promise<void> => {
459
476
  let lastError: unknown;
460
477
  for (let attempt = 0; attempt <= MAX_FONT_LOAD_RETRIES; attempt++) {
461
478
  try {
462
- await loadAtlas(await fetchFontData());
463
- // Success: clear the in-flight marker (the font now lives in fontCache)
464
- // parked nodes were already woken inside loadAtlas.
465
- fontLoadPromises.delete(primary);
479
+ // Both halves of a prefetch are already in flight in parallel; the
480
+ // fallbacks below are the original sequential path.
481
+ const fontData =
482
+ (attempt === 0 && prefetched !== undefined
483
+ ? await prefetched.data
484
+ : null) ?? (await fetchFontData());
485
+
486
+ const atlasBlob =
487
+ attempt === 0 && prefetched !== undefined
488
+ ? await prefetched.atlas
489
+ : null;
490
+
491
+ await loadAtlas(fontData, atlasBlob);
492
+ // Success: clear the in-flight markers (the font now lives in
493
+ // fontCache) — parked nodes were already woken inside loadAtlas.
494
+ for (let i = 0; i < names.length; i++) {
495
+ fontLoadPromises.delete(names[i]!);
496
+ }
466
497
  return;
467
498
  } catch (error) {
468
499
  lastError = error;
@@ -477,21 +508,30 @@ export const loadFont = (
477
508
  }
478
509
  }
479
510
 
480
- // Every attempt failed. Clear the in-flight marker so the font can be
511
+ // Every attempt failed. Clear the in-flight markers so the font can be
481
512
  // requested again and drop any partial cache entry. nodesWaitingForFont
482
513
  // is deliberately kept: nodes parked here must survive so a later
483
514
  // loadFont() (which reuses the list) can still wake them if the font
484
515
  // eventually loads. The list shrinks as nodes self-remove via
485
516
  // stopWaitingForFont on destroy.
486
- fontLoadPromises.delete(primary);
487
517
  for (let i = 0; i < names.length; i++) {
518
+ fontLoadPromises.delete(names[i]!);
488
519
  fontCache.delete(names[i]!);
489
520
  }
490
521
  console.error(`Failed to load SDF font: ${primary}`, lastError);
491
522
  throw lastError;
492
523
  })();
493
524
 
494
- fontLoadPromises.set(primary, loadPromise);
525
+ // Mark the load in flight under *every* name, not just the primary.
526
+ // `canRenderFont` treats a name with no cache entry and no in-flight promise
527
+ // as unrenderable, so keying only the primary made an alias unresolvable for
528
+ // the whole load: the stage would either fall through to the Canvas engine
529
+ // (permanently, with a substitute face) or, on an SDF-only engine list,
530
+ // throw "No compatible text renderer found". Registering every name also
531
+ // makes a later loadFont() for an alias dedupe onto this same load.
532
+ for (let i = 0; i < names.length; i++) {
533
+ fontLoadPromises.set(names[i]!, loadPromise);
534
+ }
495
535
  return loadPromise;
496
536
  };
497
537
 
@@ -0,0 +1,341 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import {
3
+ prefetchFont,
4
+ clearFontPrefetch,
5
+ takeSdfPrefetch,
6
+ takeCanvasPrefetch,
7
+ } from '../FontPrefetch.js';
8
+ import { loadFont as loadSdfFont, isFontLoaded } from '../SdfFontHandler.js';
9
+ import {
10
+ loadFont as loadCanvasFont,
11
+ isFontLoaded as isCanvasFontLoaded,
12
+ } from '../CanvasFontHandler.js';
13
+ import { EventEmitter } from '../../../common/EventEmitter.js';
14
+ import type { Stage } from '../../Stage.js';
15
+ import type { ImageTextureProps } from '../../textures/ImageTexture.js';
16
+
17
+ const VALID_FONT_DATA = {
18
+ chars: [{}],
19
+ kernings: [],
20
+ lightningMetrics: {
21
+ ascender: 800,
22
+ descender: -200,
23
+ lineGap: 200,
24
+ unitsPerEm: 1000,
25
+ },
26
+ };
27
+
28
+ // Records every request so tests can assert what was fetched, when, and in
29
+ // what order. Responses are keyed by URL and delivered on demand, letting a
30
+ // test hold a request open to observe concurrency.
31
+ class RecordingXHR {
32
+ static requests: RecordingXHR[] = [];
33
+ static responses: Record<string, unknown> = {};
34
+
35
+ status = 200;
36
+ response: unknown = null;
37
+ responseType = '';
38
+ url = '';
39
+ onload: (() => void) | null = null;
40
+ onerror: (() => void) | null = null;
41
+ onreadystatechange: (() => void) | null = null;
42
+ readyState = 0;
43
+
44
+ open(_method: string, url: string): void {
45
+ this.url = url;
46
+ }
47
+
48
+ send(): void {
49
+ RecordingXHR.requests.push(this);
50
+ }
51
+
52
+ /** Deliver the configured response for this request's URL. */
53
+ respond(status = 200): void {
54
+ this.status = status;
55
+ this.response = RecordingXHR.responses[this.url] ?? null;
56
+ this.readyState = 4;
57
+ if (this.onreadystatechange !== null) this.onreadystatechange();
58
+ if (this.onload !== null) this.onload();
59
+ }
60
+
61
+ /** Deliver every outstanding request. */
62
+ static respondAll(): void {
63
+ RecordingXHR.requests.forEach((r) => r.respond());
64
+ }
65
+
66
+ static reset(): void {
67
+ RecordingXHR.requests = [];
68
+ RecordingXHR.responses = {};
69
+ }
70
+ }
71
+
72
+ // `XMLHttpRequest.DONE` is read by fetchJson; the class stand-in must carry it.
73
+ (RecordingXHR as unknown as { DONE: number }).DONE = 4;
74
+
75
+ function makeFakeTexture() {
76
+ const tex = new EventEmitter() as unknown as EventEmitter & {
77
+ state: string;
78
+ preventCleanup: boolean;
79
+ setRenderableOwner: (owner: string, val: boolean) => void;
80
+ };
81
+ tex.state = 'loading';
82
+ tex.preventCleanup = false;
83
+ tex.setRenderableOwner = () => {};
84
+ return tex;
85
+ }
86
+
87
+ type FakeTexture = ReturnType<typeof makeFakeTexture>;
88
+
89
+ // Stage stub that records the props each atlas texture was created with, so
90
+ // tests can assert whether the prefetched blob (or the URL) was used.
91
+ function makeStage(): {
92
+ stage: Stage;
93
+ textures: FakeTexture[];
94
+ textureProps: ImageTextureProps[];
95
+ } {
96
+ const textures: FakeTexture[] = [];
97
+ const textureProps: ImageTextureProps[] = [];
98
+ const stage = {
99
+ txManager: {
100
+ createTexture: (_type: string, props: ImageTextureProps) => {
101
+ textureProps.push(props);
102
+ const t = makeFakeTexture();
103
+ textures.push(t);
104
+ return t;
105
+ },
106
+ removeTextureFromCache: () => {},
107
+ },
108
+ } as unknown as Stage;
109
+ return { stage, textures, textureProps };
110
+ }
111
+
112
+ const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));
113
+
114
+ const sdfOpts = (fontFamily: string) => ({
115
+ fontFamily,
116
+ atlasUrl: `${fontFamily}.png`,
117
+ atlasDataUrl: `${fontFamily}.json`,
118
+ });
119
+
120
+ describe('FontPrefetch — SDF', () => {
121
+ let originalXHR: unknown;
122
+ let originalBlob: unknown;
123
+
124
+ beforeEach(() => {
125
+ originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
126
+ .XMLHttpRequest;
127
+ (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
128
+ RecordingXHR;
129
+ // jsdom provides Blob; guard for environments that do not.
130
+ originalBlob = (globalThis as unknown as { Blob: unknown }).Blob;
131
+ RecordingXHR.reset();
132
+ clearFontPrefetch();
133
+ });
134
+
135
+ afterEach(() => {
136
+ (globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
137
+ originalXHR;
138
+ (globalThis as unknown as { Blob: unknown }).Blob = originalBlob;
139
+ clearFontPrefetch();
140
+ });
141
+
142
+ it('requests the atlas description and the atlas image concurrently', () => {
143
+ prefetchFont(sdfOpts('ConcurrentFont'));
144
+
145
+ // Both are in flight before either has responded — the handler's own path
146
+ // only starts the image fetch after the JSON resolves.
147
+ expect(RecordingXHR.requests.map((r) => r.url).sort()).toEqual([
148
+ 'ConcurrentFont.json',
149
+ 'ConcurrentFont.png',
150
+ ]);
151
+ });
152
+
153
+ it('starts no request for a font it has already prefetched', () => {
154
+ prefetchFont(sdfOpts('DedupeFont'));
155
+ expect(RecordingXHR.requests.length).toBe(2);
156
+
157
+ prefetchFont(sdfOpts('DedupeFont'));
158
+ expect(RecordingXHR.requests.length).toBe(2);
159
+ });
160
+
161
+ it('hands the prefetched payload to loadFont instead of refetching', async () => {
162
+ const font = sdfOpts('PrefetchHit');
163
+ const blob = new Blob(['atlas-bytes']);
164
+ RecordingXHR.responses['PrefetchHit.json'] = VALID_FONT_DATA;
165
+ RecordingXHR.responses['PrefetchHit.png'] = blob;
166
+
167
+ prefetchFont(font);
168
+ RecordingXHR.respondAll();
169
+ const prefetchRequestCount = RecordingXHR.requests.length;
170
+
171
+ const { stage, textures, textureProps } = makeStage();
172
+ const promise = loadSdfFont(
173
+ stage,
174
+ font as Parameters<typeof loadSdfFont>[1],
175
+ );
176
+ await flush();
177
+ textures[0]!.emit('loaded');
178
+ await promise;
179
+
180
+ // No second trip to the network for either resource.
181
+ expect(RecordingXHR.requests.length).toBe(prefetchRequestCount);
182
+ // The atlas texture was built from the prefetched bytes...
183
+ expect(textureProps[0]!.src).toBe(blob);
184
+ // ...while still keying the texture cache by URL, so the blob path and the
185
+ // URL path resolve to the same cache entry.
186
+ expect(textureProps[0]!.key).toBe('PrefetchHit.png');
187
+ expect(isFontLoaded('PrefetchHit')).toBe(true);
188
+ });
189
+
190
+ it('falls back to fetching when the prefetch failed', async () => {
191
+ const font = sdfOpts('PrefetchFail');
192
+ // No responses registered -> fetchJson resolves null -> unusable prefetch.
193
+ prefetchFont(font);
194
+ RecordingXHR.respondAll();
195
+ RecordingXHR.reset();
196
+
197
+ RecordingXHR.responses['PrefetchFail.json'] = VALID_FONT_DATA;
198
+
199
+ const { stage, textures, textureProps } = makeStage();
200
+ const promise = loadSdfFont(
201
+ stage,
202
+ font as Parameters<typeof loadSdfFont>[1],
203
+ );
204
+ // The handler runs its own XHR for the atlas description.
205
+ await flush();
206
+ RecordingXHR.respondAll();
207
+ await flush();
208
+ textures[0]!.emit('loaded');
209
+ await promise;
210
+
211
+ expect(RecordingXHR.requests.map((r) => r.url)).toEqual([
212
+ 'PrefetchFail.json',
213
+ ]);
214
+ // Atlas falls back to the URL rather than a blob.
215
+ expect(textureProps[0]!.src).toBe('PrefetchFail.png');
216
+ expect(isFontLoaded('PrefetchFail')).toBe(true);
217
+ });
218
+
219
+ it('rejects a malformed atlas description rather than loading it', async () => {
220
+ RecordingXHR.responses['Malformed.json'] = { not: 'font data' };
221
+ prefetchFont(sdfOpts('Malformed'));
222
+ RecordingXHR.respondAll();
223
+
224
+ await expect(takeSdfPrefetch('Malformed')!.data).resolves.toBe(null);
225
+ });
226
+
227
+ it('parses an atlas description that arrives as a string', async () => {
228
+ RecordingXHR.responses['StringJson.json'] = JSON.stringify(VALID_FONT_DATA);
229
+ prefetchFont(sdfOpts('StringJson'));
230
+ RecordingXHR.respondAll();
231
+
232
+ await expect(takeSdfPrefetch('StringJson')!.data).resolves.toMatchObject({
233
+ chars: [{}],
234
+ });
235
+ });
236
+
237
+ it('claims a prefetch only once', () => {
238
+ prefetchFont(sdfOpts('OnceOnly'));
239
+
240
+ expect(takeSdfPrefetch('OnceOnly')).not.toBe(undefined);
241
+ expect(takeSdfPrefetch('OnceOnly')).toBe(undefined);
242
+ });
243
+
244
+ it('ignores a canvas-typed descriptor even when it carries atlas urls', () => {
245
+ prefetchFont({ ...sdfOpts('CanvasTyped'), type: 'canvas' });
246
+
247
+ expect(takeSdfPrefetch('CanvasTyped')).toBe(undefined);
248
+ });
249
+ });
250
+
251
+ describe('FontPrefetch — web fonts', () => {
252
+ class FakeFontFace {
253
+ static created: FakeFontFace[] = [];
254
+ constructor(public family: string, public source: string) {
255
+ FakeFontFace.created.push(this);
256
+ }
257
+ load(): Promise<FakeFontFace> {
258
+ return Promise.resolve(this);
259
+ }
260
+ }
261
+
262
+ let originalFontFace: unknown;
263
+
264
+ beforeEach(() => {
265
+ originalFontFace = (globalThis as unknown as { FontFace: unknown })
266
+ .FontFace;
267
+ (globalThis as unknown as { FontFace: unknown }).FontFace = FakeFontFace;
268
+ FakeFontFace.created = [];
269
+ clearFontPrefetch();
270
+ });
271
+
272
+ afterEach(() => {
273
+ (globalThis as unknown as { FontFace: unknown }).FontFace =
274
+ originalFontFace;
275
+ clearFontPrefetch();
276
+ });
277
+
278
+ it('starts the download for every registered name', async () => {
279
+ prefetchFont({
280
+ fontFamily: ['WebPrimary', 'WebAlias'],
281
+ fontUrl: 'web.woff2',
282
+ });
283
+
284
+ expect(FakeFontFace.created.map((f) => f.family)).toEqual([
285
+ 'WebPrimary',
286
+ 'WebAlias',
287
+ ]);
288
+ await expect(takeCanvasPrefetch('WebPrimary')).resolves.toHaveLength(2);
289
+ });
290
+
291
+ it('resolves to null when the download fails, so the handler can retry', async () => {
292
+ vi.spyOn(FakeFontFace.prototype, 'load').mockRejectedValueOnce(
293
+ new Error('network'),
294
+ );
295
+ prefetchFont({ fontFamily: 'WebFail', fontUrl: 'web.woff2' });
296
+
297
+ await expect(takeCanvasPrefetch('WebFail')).resolves.toBe(null);
298
+ });
299
+
300
+ it('does nothing when the engine has no FontFace', () => {
301
+ (globalThis as unknown as { FontFace: unknown }).FontFace = undefined;
302
+ prefetchFont({ fontFamily: 'NoFontFace', fontUrl: 'web.woff2' });
303
+
304
+ expect(takeCanvasPrefetch('NoFontFace')).toBe(undefined);
305
+ });
306
+
307
+ it('registers prefetched faces with the platform without reloading them', async () => {
308
+ prefetchFont({ fontFamily: 'CanvasHit', fontUrl: 'web.woff2' });
309
+ const createdByPrefetch = FakeFontFace.created.length;
310
+
311
+ const added: FakeFontFace[] = [];
312
+ const stage = {
313
+ platform: { addFont: (f: FakeFontFace) => added.push(f) },
314
+ } as unknown as Stage;
315
+
316
+ await loadCanvasFont(stage, {
317
+ fontFamily: 'CanvasHit',
318
+ fontUrl: 'web.woff2',
319
+ });
320
+
321
+ // No extra FontFace was constructed — the prefetched one was used.
322
+ expect(FakeFontFace.created.length).toBe(createdByPrefetch);
323
+ expect(added).toEqual(FakeFontFace.created);
324
+ expect(isCanvasFontLoaded('CanvasHit')).toBe(true);
325
+ });
326
+
327
+ it('loads normally when there is no prefetch to claim', async () => {
328
+ const added: FakeFontFace[] = [];
329
+ const stage = {
330
+ platform: { addFont: (f: FakeFontFace) => added.push(f) },
331
+ } as unknown as Stage;
332
+
333
+ await loadCanvasFont(stage, {
334
+ fontFamily: 'CanvasMiss',
335
+ fontUrl: 'web.woff2',
336
+ });
337
+
338
+ expect(FakeFontFace.created.map((f) => f.family)).toEqual(['CanvasMiss']);
339
+ expect(isCanvasFontLoaded('CanvasMiss')).toBe(true);
340
+ });
341
+ });