From 69ff711952b72fb74ac80361228355e9dcd924fa Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 31 May 2026 17:01:36 -0400 Subject: [PATCH 1/3] fix(text): clear canvas texture when text set to empty string Setting a canvas text node's text to '' produced no imageData but left the previously-rendered texture assigned, so CoreNode.updateIsRenderable re-marked the node renderable on the next frame and the old text lingered. Clear the stale texture in the empty-text branch. Co-Authored-By: Claude Opus 4.7 --- src/core/CoreTextNode.test.ts | 145 ++++++++++++++++++++++++++++++++++ src/core/CoreTextNode.ts | 4 +- 2 files changed, 148 insertions(+), 1 deletion(-) create mode 100644 src/core/CoreTextNode.test.ts diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts new file mode 100644 index 0000000..cc3fb83 --- /dev/null +++ b/src/core/CoreTextNode.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it, vi } from 'vitest'; +import { mock } from 'vitest-mock-extended'; +import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js'; +import { Stage } from './Stage.js'; +import { CoreRenderer } from './renderers/CoreRenderer.js'; +import { createBound } from './lib/utils.js'; +import type { + FontHandler, + TextRenderer, + TextRenderInfo, +} from './text-rendering/TextRenderer.js'; +import type { Texture } from './textures/Texture.js'; + +const defaultProps = ( + overrides?: Partial, +): CoreTextNodeProps => ({ + // CoreNodeProps + alpha: 1, + autosize: false, + boundsMargin: null, + clipping: false, + color: 0xffffffff, + colorBl: 0xffffffff, + colorBottom: 0xffffffff, + colorBr: 0xffffffff, + colorLeft: 0xffffffff, + colorRight: 0xffffffff, + colorTl: 0xffffffff, + colorTop: 0xffffffff, + colorTr: 0xffffffff, + h: 0, + mount: 0, + mountX: 0, + mountY: 0, + parent: null, + pivot: 0, + pivotX: 0, + pivotY: 0, + rotation: 0, + rtt: false, + scale: 1, + scaleX: 1, + scaleY: 1, + shader: null, + src: '', + texture: null, + textureOptions: {} as never, + w: 0, + x: 0, + y: 0, + zIndex: 0, + preventDestroy: false, + // TrProps + text: '', + textAlign: 'left', + fontFamily: 'Ubuntu', + fontStyle: 'normal', + fontSize: 100, + maxWidth: 0, + maxHeight: 0, + offsetY: 0, + letterSpacing: 0, + lineHeight: 0, + maxLines: 0, + verticalAlign: 'top', + overflowSuffix: '...', + wordBreak: 'break-word', + contain: 'none', + // CoreTextNodeProps + textRendererOverride: null, + forceLoad: false, + ...overrides, +}); + +const makeStage = () => + mock({ + strictBound: createBound(0, 0, 200, 200), + preloadBound: createBound(0, 0, 200, 200), + defaultTexture: { state: 'loaded' } as never, + defShaderNode: null as never, + renderer: mock() as CoreRenderer, + }); + +const makeCanvasRenderer = (): TextRenderer => { + const font = mock({ type: 'canvas' }); + return { + type: 'canvas', + font, + renderText: vi.fn(), + addQuads: vi.fn(), + renderQuads: vi.fn(), + init: vi.fn(), + } as unknown as TextRenderer; +}; + +const makeLoadedTexture = () => + ({ + state: 'loaded', + dimensions: { w: 100, h: 50 }, + retryCount: 0, + maxRetryCount: 0, + on: vi.fn(), + off: vi.fn(), + once: vi.fn(), + setRenderableOwner: vi.fn(), + } as unknown as Texture); + +describe('CoreTextNode (canvas) clearing text', () => { + it('clears the stale texture when text becomes empty', () => { + const stage = makeStage(); + const renderer = makeCanvasRenderer(); + const texture = makeLoadedTexture(); + stage.txManager = { createTexture: vi.fn(() => texture) } as never; + + const node = new CoreTextNode( + stage, + defaultProps({ text: 'Hello' }), + renderer, + ); + + // Simulate "Hello" having been rendered: canvas returns ImageData and a + // texture is created/assigned. + const helloResult: TextRenderInfo = { + imageData: {} as ImageData, + width: 100, + height: 50, + }; + ( + node as unknown as { handleRenderResult(r: TextRenderInfo): void } + ).handleRenderResult(helloResult); + + expect(node.texture).toBe(texture); + + // Now set text to empty. The canvas renderer returns no imageData. + const emptyResult: TextRenderInfo = { width: 0, height: 0 }; + ( + node as unknown as { handleRenderResult(r: TextRenderInfo): void } + ).handleRenderResult(emptyResult); + + // The previously-rendered texture must be cleared so the old text does not + // linger and get re-marked renderable by CoreNode.updateIsRenderable. + expect(node.texture).toBe(null); + expect(node.isRenderable).toBe(false); + }); +}); diff --git a/src/core/CoreTextNode.ts b/src/core/CoreTextNode.ts index 0fe5b2d..fca319b 100644 --- a/src/core/CoreTextNode.ts +++ b/src/core/CoreTextNode.ts @@ -245,8 +245,10 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps { // Handle Canvas renderer (uses ImageData) if (textRendererType === 'canvas') { if (result.imageData === undefined) { - // Empty text returns no imageData — mark not renderable and continue + // Empty text returns no imageData — clear the stale texture so the + // previous text doesn't linger, then mark not renderable and continue // to update dimensions (w=0, h=0) rather than emitting a failure. + this.texture = null; this.setRenderable(false); } else { this.texture = this.stage.txManager.createTexture('ImageTexture', { From ded87359de8e4b8c3750bae68e20532f1d9238ce Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 31 May 2026 17:10:52 -0400 Subject: [PATCH 2/3] test(text): provide txManager via Stage mock to fix tsc build txManager is a read-only property on Stage; assigning it post-construction broke the build. Pass it through the mock constructor instead. Co-Authored-By: Claude Opus 4.7 --- src/core/CoreTextNode.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index cc3fb83..8031a6b 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -72,13 +72,14 @@ const defaultProps = ( ...overrides, }); -const makeStage = () => +const makeStage = (texture: Texture) => mock({ strictBound: createBound(0, 0, 200, 200), preloadBound: createBound(0, 0, 200, 200), defaultTexture: { state: 'loaded' } as never, defShaderNode: null as never, renderer: mock() as CoreRenderer, + txManager: { createTexture: vi.fn(() => texture) } as never, }); const makeCanvasRenderer = (): TextRenderer => { @@ -107,10 +108,9 @@ const makeLoadedTexture = () => describe('CoreTextNode (canvas) clearing text', () => { it('clears the stale texture when text becomes empty', () => { - const stage = makeStage(); const renderer = makeCanvasRenderer(); const texture = makeLoadedTexture(); - stage.txManager = { createTexture: vi.fn(() => texture) } as never; + const stage = makeStage(texture); const node = new CoreTextNode( stage, From 994338db75279131c95afd27148433f92e49777c Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Sun, 31 May 2026 17:23:11 -0400 Subject: [PATCH 3/3] test(vrt): wait for renderer idle before quads-rendered snapshots The displayed quad count is driven by the async renderUpdate event and only settles once all image textures / SDF layouts have loaded and the count text (itself made of quads) converges. Snapshotting after a fixed 200ms captured a nondeterministic mid-load frame, so the rendered node count varied between runs. Wait for the renderer 'idle' event before each snapshot to capture the stable final state. Co-Authored-By: Claude Opus 4.7 --- examples/tests/quads-rendered.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/examples/tests/quads-rendered.ts b/examples/tests/quads-rendered.ts index 0b05473..718f222 100644 --- a/examples/tests/quads-rendered.ts +++ b/examples/tests/quads-rendered.ts @@ -1,11 +1,19 @@ import { type INode } from '@lightningjs/renderer'; import logo from '../assets/lightning.png'; import type { ExampleSettings } from '../common/ExampleSettings.js'; +import { waitUntilIdle } from '../common/utils.js'; export async function automation(settings: ExampleSettings) { const destroy = await test(settings); + // The displayed quad count is driven by the async `renderUpdate` event and + // only settles once every image texture / SDF layout has loaded (and the + // count text, itself made of quads, has converged). Wait for the renderer to + // go idle so the snapshot captures that stable final state instead of a + // mid-load frame at a fixed timeout. + await waitUntilIdle(settings.renderer); await settings.snapshot(); destroy(120); + await waitUntilIdle(settings.renderer); await settings.snapshot(); }