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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/core/text-rendering/SdfFontHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { UpdateType } from '../CoreNode.js';
import { hasZeroWidthSpace } from './Utils.js';
import { normalizeFontMetrics } from './TextLayoutEngine.js';
import { isProductionEnvironment } from '../../utils.js';
import type { TextureError } from '../TextureError.js';

/**
* SDF Font Data structure matching msdf-bmfont-xml output
Expand Down Expand Up @@ -397,7 +398,11 @@ export const loadFont = (
resolve();
});

atlasTexture.on('failed', (error: Error) => {
// EventEmitter invokes listeners as (target, data), so the error payload
// is the SECOND argument. The first arg is the Texture that emitted the
// event. Reading it as the only param (the previous behavior) rejected
// and logged the Texture instead of the actual TextureError.
atlasTexture.on('failed', (_target, error: TextureError) => {
// Cleanup on error
fontLoadPromises.delete(fontFamily);
if (fontCache[fontFamily]) {
Expand Down
112 changes: 112 additions & 0 deletions src/core/text-rendering/tests/SdfFontHandler.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { loadFont } from '../SdfFontHandler.js';
import { EventEmitter } from '../../../common/EventEmitter.js';
import { TextureError, TextureErrorCode } from '../../TextureError.js';
import type { Stage } from '../../Stage.js';

// Minimal XHR stand-in: synchronously delivers valid SDF font JSON so loadFont
// proceeds to create the atlas texture and attach its event listeners.
class FakeXHR {
status = 200;
response: unknown = null;
responseType = '';
onload: (() => void) | null = null;
onerror: (() => void) | null = null;
open(): void {}
send(): void {
this.response = { chars: [{}] };
if (this.onload !== null) {
this.onload();
}
}
}

// A texture is just an EventEmitter to loadFont; stub the few props it reads.
function makeFakeTexture() {
const tex = new EventEmitter() as unknown as EventEmitter & {
state: string;
preventCleanup: boolean;
setRenderableOwner: (owner: string, val: boolean) => void;
};
tex.state = 'loading'; // not 'loaded' -> goes through the listener path
tex.preventCleanup = false;
tex.setRenderableOwner = () => {};
return tex;
}

// Drain microtasks + one macrotask so the async loader reaches listener setup.
const flush = (): Promise<void> => new Promise((r) => setTimeout(r, 0));

describe('SdfFontHandler loadFont — failed event argument', () => {
let errSpy: ReturnType<typeof vi.spyOn>;
let originalXHR: unknown;

beforeEach(() => {
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
originalXHR = (globalThis as unknown as { XMLHttpRequest: unknown })
.XMLHttpRequest;
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
FakeXHR;
});

afterEach(() => {
errSpy.mockRestore();
(globalThis as unknown as { XMLHttpRequest: unknown }).XMLHttpRequest =
originalXHR;
});

it('rejects with the TextureError (second emit arg), not the emitting texture', async () => {
const tex = makeFakeTexture();
const stage = {
txManager: { createTexture: () => tex },
} as unknown as Stage;

const promise = loadFont(stage, {
fontFamily: 'TestSdfFailFont',
atlasUrl: 'atlas.png',
atlasDataUrl: 'atlas.json',
} as Parameters<typeof loadFont>[1]);

await flush();

const error = new TextureError(
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
'boom',
);

// Attach the rejection assertion before emitting so the handler is ready.
const assertion = expect(promise).rejects.toBe(error);

// EventEmitter calls listeners as (target, data) -> (tex, error).
tex.emit('failed', error);

await assertion;
});

it('logs the error, not the texture, on failure', async () => {
const tex = makeFakeTexture();
const stage = {
txManager: { createTexture: () => tex },
} as unknown as Stage;

const promise = loadFont(stage, {
fontFamily: 'TestSdfFailFontLog',
atlasUrl: 'atlas.png',
atlasDataUrl: 'atlas.json',
} as Parameters<typeof loadFont>[1]);

await flush();

const error = new TextureError(
TextureErrorCode.TEXTURE_UPLOAD_FAILED,
'boom',
);
const rejected = promise.catch(() => {});
tex.emit('failed', error);
await rejected;

const lastCall = errSpy.mock.calls[errSpy.mock.calls.length - 1]!;
expect(lastCall[1]).toBe(error);
expect(lastCall[1]).not.toBe(tex);
});
});
Loading