diff --git a/src/core/renderers/canvas/CanvasRenderer.test.ts b/src/core/renderers/canvas/CanvasRenderer.test.ts new file mode 100644 index 0000000..95c69e1 --- /dev/null +++ b/src/core/renderers/canvas/CanvasRenderer.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest'; +import { CanvasRenderer } from './CanvasRenderer.js'; +import { TextureType } from '../../textures/Texture.js'; + +describe('CanvasRenderer.renderContext', () => { + it('skips drawing when canvas texture image is undefined', () => { + const drawImage = vi.fn(); + + const renderer = Object.create(CanvasRenderer.prototype) as CanvasRenderer; + (renderer as any).context = { + drawImage, + globalAlpha: 1, + }; + + const node = { + premultipliedColorTl: 0xffffffff, + globalTransform: { tx: 10, ty: 20 }, + props: { w: 100, h: 50 }, + worldAlpha: 1, + textureCoords: { x1: 0, y1: 0, x2: 1, y2: 1 }, + } as any; + + const texture = { + type: TextureType.image, + ctxTexture: { + getImage: () => undefined, + }, + } as any; + + expect(() => renderer.renderContext(node, texture)).not.toThrow(); + expect(drawImage).not.toHaveBeenCalled(); + expect((renderer as any).context.globalAlpha).toBe(1); + }); +}); diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index 0a9cd74..b0ac326 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -146,9 +146,20 @@ export class CanvasRenderer extends CoreRenderer { image = (texture.ctxTexture as CanvasTexture).getImage(tintColor); } - // getImage returns null when the underlying image was freed mid-load; - // skip drawing this frame rather than crashing. - if (image === null) { + // The texture can disappear while an async load/fetch is racing (e.g. 404); + // skip drawing this frame instead of dereferencing an invalid image object. + if (image === null || image === undefined) { + return; + } + + const imageWidth = image.width; + const imageHeight = image.height; + if ( + typeof imageWidth !== 'number' || + typeof imageHeight !== 'number' || + imageWidth <= 0 || + imageHeight <= 0 + ) { return; } @@ -156,8 +167,8 @@ export class CanvasRenderer extends CoreRenderer { const txCoords = node.textureCoords; if (txCoords) { - const ix = image.width; - const iy = image.height; + const ix = imageWidth; + const iy = imageHeight; let sx = txCoords.x1 * ix; let sy = txCoords.y1 * iy; diff --git a/src/core/renderers/canvas/CanvasTexture.test.ts b/src/core/renderers/canvas/CanvasTexture.test.ts new file mode 100644 index 0000000..5e869a3 --- /dev/null +++ b/src/core/renderers/canvas/CanvasTexture.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from 'vitest'; +import { CanvasTexture } from './CanvasTexture.js'; + +describe('CanvasTexture.load', () => { + it('fails gracefully when textureData.data is null', async () => { + const textureSource = { + textureData: { data: null }, + state: 'initial', + dimensions: null, + setState(nextState: string) { + this.state = nextState; + }, + freeTextureData() { + // no-op + }, + } as any; + + const memManager = { + setTextureMemUse() { + // no-op + }, + } as any; + + const ctxTexture = new CanvasTexture(memManager, textureSource); + + await expect(ctxTexture.load()).rejects.toThrow( + 'CanvasTexture: Texture data is null', + ); + expect(textureSource.state).toBe('failed'); + }); +}); diff --git a/src/core/renderers/canvas/CanvasTexture.ts b/src/core/renderers/canvas/CanvasTexture.ts index 72a5c0f..a7ff911 100644 --- a/src/core/renderers/canvas/CanvasTexture.ts +++ b/src/core/renderers/canvas/CanvasTexture.ts @@ -131,8 +131,12 @@ export class CanvasTexture extends CoreContextTexture { } private async onLoadRequest( - data: NonNullable['data']>, + data: NonNullable['data'], ): Promise { + if (data === null) { + throw new Error('CanvasTexture: Texture data is null'); + } + // CompressedData objects (KTX, PVR, ASTC) carry GPU-format mipmap buffers // that cannot be decoded by Canvas2D. Reject explicitly rather than falling // through silently and leaving this.image unassigned.