From 809d89acce82469a3c8c1a84f96b01e194dc7922 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 10 Jun 2026 15:48:25 -0400 Subject: [PATCH 1/2] =?UTF-8?q?feat(core):=20placeholderImage=20=E2=80=94?= =?UTF-8?q?=20shared=20pinned=20image=20placeholder=20while=20a=20texture?= =?UTF-8?q?=20loads?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends placeholderColor (#96) with a per-node placeholder image. While the node's texture is not loaded (initial load, freed-texture reload, permanent failure) the quad renders the placeholder image through the node's own shader, stretched to the node's dimensions. Fallback chain per frame: main texture -> placeholder image -> placeholderColor rect -> nothing. Lifecycle is pin-once instead of per-node ownership: the setter resolves the URL through the texture keyCache (src-only props, so every node using the same URL shares one instance — e.g. one image per poster size), sets preventCleanup, and eagerly priority-loads from idle states only (a state guard keeps N nodes from starting N duplicate fetches). Per-node loaded/failed/freed listeners drive the fallback state machine and are detached on swap, clear, and destroy so nodes never leak through the long-lived texture. The freed handler self-heals out-of-band frees (context loss, another node unpinning the shared URL) by re-pinning and reloading. Renderers additionally stop applying node.textureCoords while a placeholder shows — those coords belong to the main texture (resizeMode, flips) and would mis-crop a real placeholder image (the 1x1 white texture masked this). Once the image is showing it renders untinted; placeholderColor only colors the rect fallback. Co-Authored-By: Claude Fable 5 --- examples/tests/texture-placeholder-image.ts | 167 +++++++++++ src/core/CoreNode.test.ts | 273 ++++++++++++++++++ src/core/CoreNode.ts | 193 ++++++++++++- src/core/CoreTextNode.test.ts | 1 + src/core/Stage.ts | 2 + src/core/renderers/canvas/CanvasRenderer.ts | 24 +- src/core/renderers/webgl/WebGlRenderer.ts | 25 +- .../texture-placeholder-image-1.png | Bin 0 -> 40304 bytes 8 files changed, 659 insertions(+), 26 deletions(-) create mode 100644 examples/tests/texture-placeholder-image.ts create mode 100644 visual-regression/certified-snapshots/chromium-ci/texture-placeholder-image-1.png diff --git a/examples/tests/texture-placeholder-image.ts b/examples/tests/texture-placeholder-image.ts new file mode 100644 index 0000000..fe25d26 --- /dev/null +++ b/examples/tests/texture-placeholder-image.ts @@ -0,0 +1,167 @@ +import type { INode, Texture } from '@lightningjs/renderer'; +import type { ExampleSettings } from '../common/ExampleSettings.js'; + +import rockoPng from '../assets/rocko.png'; +import lightningPng from '../assets/lightning.png'; + +/** + * Visual test for `placeholderImage`: a node with a texture renders a shared, + * pinned placeholder image (through its shader) until the texture loads. + * + * Deterministic states captured in the snapshot: + * 1. Placeholder image for a permanently failed src, rounded. + * 2. Same shared placeholder image under RoundedWithBorder. + * 3. Placeholder image that itself 404s -> placeholderColor rect fallback. + * 4. A loaded image with placeholderImage set — the image shows. + * 5. Control: failed src + failed placeholder + no color renders nothing. + */ + +const MISSING_SRC = '/does-not-exist-placeholder-test.png'; +const MISSING_PLACEHOLDER = '/does-not-exist-placeholder-image.png'; + +function waitForNodeEvent( + node: INode, + event: 'loaded' | 'failed', + timeoutMs: number, +): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + node.once(event, () => { + clearTimeout(timeout); + resolve(true); + }); + }); +} + +function waitForTextureState( + texture: Texture, + state: 'loaded' | 'failed', + timeoutMs: number, +): Promise { + if (texture.state === state) { + return Promise.resolve(true); + } + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(false), timeoutMs); + texture.once(state, () => { + clearTimeout(timeout); + resolve(true); + }); + }); +} + +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export async function automation(settings: ExampleSettings) { + await test(settings); + // The scene settled inside test() (events already awaited) — force a final + // frame instead of waiting for an 'idle' that may have already fired. + settings.renderer.rerender(); + await delay(100); + await settings.snapshot(); +} + +export default async function test({ renderer, testRoot }: ExampleSettings) { + renderer.createTextNode({ + fontFamily: 'Ubuntu', + text: 'placeholderImage', + fontSize: 30, + color: 0xffffffff, + x: 20, + y: 20, + parent: testRoot, + }); + + // Fail fast and permanently: maxRetryCount 0 = one attempt, no retries. + const missingTexture = renderer.createTexture('ImageTexture', { + src: MISSING_SRC, + maxRetryCount: 0, + }); + + // 1. Placeholder image shows for a permanently failed src (rounded). + const failedRounded = renderer.createNode({ + x: 20, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: lightningPng, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 2. Same shared placeholder image, border shader. + const failedBordered = renderer.createNode({ + x: 250, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: lightningPng, + shader: renderer.createShader('RoundedWithBorder', { + radius: [20], + 'border-w': 8, + }), + parent: testRoot, + }); + + // 3. Placeholder image that itself 404s -> placeholderColor rect fallback. + const fallbackRect = renderer.createNode({ + x: 480, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: MISSING_PLACEHOLDER, + placeholderColor: 0x993311ff, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 4. A successfully loaded image with a placeholder configured must show + // the image. + const loadedImage = renderer.createNode({ + x: 710, + y: 80, + w: 181, + h: 218, + src: rockoPng, + placeholderImage: lightningPng, + shader: renderer.createShader('Rounded', { radius: [20] }), + parent: testRoot, + }); + + // 5. Control: failed src + failed placeholder + no color renders nothing. + renderer.createNode({ + x: 940, + y: 80, + w: 200, + h: 280, + texture: missingTexture, + placeholderImage: MISSING_PLACEHOLDER, + parent: testRoot, + }); + + const placeholderTexture = failedRounded.placeholderTexture as Texture; + const fallbackPlaceholderTexture = fallbackRect.placeholderTexture as Texture; + + const settled = await Promise.all([ + waitForNodeEvent(failedRounded, 'failed', 10000), + waitForNodeEvent(failedBordered, 'failed', 10000), + waitForNodeEvent(loadedImage, 'loaded', 10000), + waitForTextureState(placeholderTexture, 'loaded', 10000), + waitForTextureState(fallbackPlaceholderTexture, 'failed', 10000), + ]); + + for (let i = 0; i < settled.length; i++) { + if (settled[i] === false) { + console.error('[texture-placeholder-image] did not settle', settled); + return false; + } + } + + console.log('[texture-placeholder-image] scene settled'); + return true; +} diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 7411be1..1baa124 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -26,6 +26,7 @@ describe('set color()', () => { colorTop: 0, colorTr: 0, placeholderColor: 0, + placeholderImage: null, h: 0, mount: 0, mountX: 0, @@ -1463,4 +1464,276 @@ describe('set color()', () => { expect(node.renderTexture).toBe(texture); }); }); + + describe('placeholderImage', () => { + // The placeholderImage setter resolves URLs through txManager, so this + // suite uses a stage mock with an explicit txManager stub. + function placeholderStage() { + const createTexture = vi.fn(); + const loadTexture = vi.fn(); + const stage = mock({ + strictBound: createBound(0, 0, 200, 200), + preloadBound: createBound(0, 0, 200, 200), + defaultTexture: { + state: 'loaded', + }, + renderer: mock() as CoreRenderer, + txManager: { + createTexture, + loadTexture, + } as unknown as Stage['txManager'], + }); + return { stage, createTexture, loadTexture }; + } + + function emittingTexture(state: string): ImageTexture & { + emit: (event: string, data?: unknown) => void; + preventCleanup: boolean; + } { + return Object.assign(new EventEmitter(), { + state, + preventCleanup: false, + retryCount: 0, + maxRetryCount: 1, + dimensions: { w: 100, h: 100 }, + setRenderableOwner: vi.fn(), + }) as unknown as ImageTexture & { + emit: (event: string, data?: unknown) => void; + preventCleanup: boolean; + }; + } + + function visibleNode(stage: Stage): CoreNode { + const parent = new CoreNode(stage, defaultProps()); + parent.globalTransform = Matrix3d.identity(); + parent.worldAlpha = 1; + + const node = new CoreNode(stage, defaultProps({ parent })); + node.alpha = 1; + node.x = 0; + node.y = 0; + node.w = 100; + node.h = 100; + return node; + } + + it('pins and eagerly loads the placeholder image on assignment', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + + expect(createTexture).toHaveBeenCalledWith('ImageTexture', { + src: 'placeholder-poster.png', + }); + expect(placeholder.preventCleanup).toBe(true); + expect(loadTexture).toHaveBeenCalledWith(placeholder, true); + expect(node.placeholderTextureLoaded).toBe(false); + }); + + it('uses an already-loaded shared placeholder immediately, untinted', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + + expect(loadTexture).not.toHaveBeenCalled(); + expect(node.placeholderActive).toBe(true); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(placeholder); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0xffffffff, 1), + ); + }); + + it('falls back to the placeholderColor rect until the image loads', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.placeholderColor = 0x336699ff; + + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + + expect(node.renderTexture).toBe(stage.defaultTexture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x336699ff, 1), + ); + + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + + expect(node.renderTexture).toBe(placeholder); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0xffffffff, 1), + ); + }); + + it('renders nothing until an image-only placeholder loads', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + expect(node.isRenderable).toBe(false); + + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(placeholder); + }); + + it('the loaded main texture wins over the placeholder', async () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.color = 0xffffffff; + + node.placeholderImage = 'placeholder-poster.png'; + const main = emittingTexture('initial'); + node.texture = main; + node.update(0, clippingRect); + expect(node.renderTexture).toBe(placeholder); + + await Promise.resolve(); // flush loadTextureTask so listeners attach + (main as { state: string }).state = 'loaded'; + main.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + + expect(node.placeholderActive).toBe(false); + expect(node.renderTexture).toBe(main); + }); + + it('shows the placeholder image again while a freed main texture reloads', async () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + const main = emittingTexture('initial'); + node.texture = main; + node.update(0, clippingRect); + + await Promise.resolve(); + (main as { state: string }).state = 'loaded'; + main.emit('loaded', { w: 100, h: 100 }); + node.update(1, clippingRect); + expect(node.placeholderActive).toBe(false); + + (main as { state: string }).state = 'freed'; + main.emit('freed'); + node.update(2, clippingRect); + + expect(node.placeholderActive).toBe(true); + expect(node.renderTexture).toBe(placeholder); + }); + + it('a failed placeholder image falls back to the color rect', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + node.placeholderColor = 0x336699ff; + + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + + (placeholder as { state: string }).state = 'failed'; + placeholder.emit('failed', new Error('404')); + node.update(1, clippingRect); + + expect(node.placeholderTextureLoaded).toBe(false); + expect(node.isRenderable).toBe(true); + expect(node.renderTexture).toBe(stage.defaultTexture); + expect(node.premultipliedColorTl).toBe( + premultiplyColorABGR(0x336699ff, 1), + ); + }); + + it('self-heals an out-of-band freed placeholder: re-pins and reloads', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + expect(node.placeholderTextureLoaded).toBe(true); + + // e.g. context loss, or another node's textureOptions unpinned it + placeholder.preventCleanup = false; + (placeholder as { state: string }).state = 'freed'; + placeholder.emit('freed'); + + expect(node.placeholderTextureLoaded).toBe(false); + expect(placeholder.preventCleanup).toBe(true); + expect(loadTexture).toHaveBeenCalledWith(placeholder, true); + }); + + it('destroy detaches the node from the shared placeholder texture', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + expect(placeholder.hasListeners()).toBe(true); + + node.destroy(); + + expect(placeholder.hasListeners()).toBe(false); + }); + + it('swapping placeholderImage moves listeners to the new texture', () => { + const { stage, createTexture } = placeholderStage(); + const first = emittingTexture('initial'); + const second = emittingTexture('initial'); + createTexture.mockReturnValueOnce(first).mockReturnValueOnce(second); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-a.png'; + expect(first.hasListeners()).toBe(true); + + node.placeholderImage = 'placeholder-b.png'; + + expect(first.hasListeners()).toBe(false); + expect(second.hasListeners()).toBe(true); + expect(node.placeholderTexture).toBe(second); + }); + + it('clearing placeholderImage detaches and deactivates', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('loaded'); + createTexture.mockReturnValue(placeholder); + const node = visibleNode(stage); + + node.placeholderImage = 'placeholder-poster.png'; + node.texture = emittingTexture('initial'); + node.update(0, clippingRect); + expect(node.placeholderActive).toBe(true); + + node.placeholderImage = null; + node.update(1, clippingRect); + + expect(placeholder.hasListeners()).toBe(false); + expect(node.placeholderActive).toBe(false); + expect(node.isRenderable).toBe(false); + }); + }); }); diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 321076c..60f7d9c 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -409,6 +409,29 @@ export interface CoreNodeProps { * @default `0` */ placeholderColor: number; + /** + * Placeholder image shown while the Node's texture is not yet loaded. + * + * @remarks + * Like {@link placeholderColor}, but renders an image instead of a solid + * rectangle while the Node's texture loads (and while a freed texture + * reloads, and after a permanent failure). The image is stretched to the + * Node's dimensions and renders through the Node's shader, so rounded + * corners and borders apply. + * + * The image is loaded once, shared by every Node using the same URL, and + * pinned in memory (`preventCleanup`) so it is always available — use a + * small number of distinct placeholder images (e.g. one per poster size), + * not per-item artwork. + * + * While the placeholder image itself is still loading, the Node falls back + * to {@link placeholderColor} if set, otherwise renders nothing. Once the + * placeholder image is showing it is rendered untinted; `placeholderColor` + * only colors the fallback rectangle. + * + * @default `null` + */ + placeholderImage: string | null; /** * The Node's parent Node. * @@ -811,14 +834,28 @@ export class CoreNode extends EventEmitter { public textureLoaded = false; /** - * True while this node should render its `placeholderColor` instead of its - * texture: `placeholderColor` is non-zero, a texture is set, and that - * texture is not loaded. Read by the renderers' quad path to substitute the - * stage's default (1x1 white) texture. Maintained by + * True while this node should render a placeholder instead of its texture: + * a texture is set, it is not loaded, and a placeholder is available + * (non-zero `placeholderColor`, or a loaded `placeholderImage`). Read by + * the renderers' quad path to substitute the placeholder texture — the + * loaded placeholder image, or the stage's default (1x1 white) texture + * tinted by `placeholderColor`. Maintained by * {@link updatePlaceholderActive} — never written elsewhere. */ public placeholderActive = false; + /** + * Shared, pinned (`preventCleanup`) texture for {@link placeholderImage}, + * or `null`. Owned by the placeholderImage setter. + */ + public placeholderTexture: Texture | null = null; + + /** + * Cached `placeholderTexture.state === 'loaded'` (avoids per-quad string + * compares). Maintained by the placeholder texture event handlers. + */ + public placeholderTextureLoaded = false; + public updateType = UpdateType.All; public childUpdateType = UpdateType.None; @@ -904,7 +941,15 @@ export class CoreNode extends EventEmitter { // creates a fresh object with a consistent shape. Save fields that are // re-applied through setters, then null them on props so the setters // detect the change. - const { texture, shader, src, rtt, boundsMargin, parent } = props; + const { + texture, + shader, + src, + rtt, + boundsMargin, + parent, + placeholderImage, + } = props; const p = (this.props = props); p.texture = null; p.shader = null; @@ -912,6 +957,7 @@ export class CoreNode extends EventEmitter { p.rtt = false; p.boundsMargin = null; p.scale = null; + p.placeholderImage = null; //check if any color props are set for premultiplied color updates if ( @@ -955,6 +1001,9 @@ export class CoreNode extends EventEmitter { if (src !== null) { this.src = src; } + if (placeholderImage !== null && placeholderImage !== undefined) { + this.placeholderImage = placeholderImage; + } if (rtt !== false) { this.rtt = rtt; } @@ -992,9 +1041,10 @@ export class CoreNode extends EventEmitter { */ private updatePlaceholderActive(): void { const active = - this.props.placeholderColor !== 0 && this.props.texture !== null && - this.textureLoaded === false; + this.textureLoaded === false && + (this.props.placeholderColor !== 0 || + this.placeholderTextureLoaded === true); if (active !== this.placeholderActive) { this.placeholderActive = active; @@ -1004,6 +1054,94 @@ export class CoreNode extends EventEmitter { } } + /** + * Assign or clear the shared placeholder image texture. + * + * @remarks + * The texture is pinned (`preventCleanup`) so the memory manager never + * frees it, and loaded eagerly with priority so it is available before the + * first poster needs it. Listeners stay attached for the lifetime of the + * assignment: `loaded`/`failed` drive the fallback state machine, and + * `freed` self-heals the rare out-of-band free (context loss, or another + * node's textureOptions unpinning the shared texture) by re-pinning and + * reloading. They are removed on swap and in {@link destroy} so a + * destroyed node does not leak via the long-lived texture. + */ + private setPlaceholderTexture(value: Texture | null): void { + const old = this.placeholderTexture; + if (old === value) { + return; + } + + if (old !== null) { + old.off('loaded', this.onPlaceholderTexLoaded); + old.off('failed', this.onPlaceholderTexFailed); + old.off('freed', this.onPlaceholderTexFreed); + } + + this.placeholderTexture = value; + this.placeholderTextureLoaded = value !== null && value.state === 'loaded'; + + if (value !== null) { + value.preventCleanup = true; + value.on('loaded', this.onPlaceholderTexLoaded); + value.on('failed', this.onPlaceholderTexFailed); + value.on('freed', this.onPlaceholderTexFreed); + + // Eager priority load. Only from idle states — 'loading'/'fetching' + // means another node already kicked it off and a duplicate call would + // start a second fetch of the same source. + const state = value.state; + if (state === 'initial' || state === 'freed') { + void this.stage.txManager.loadTexture(value, true); + } + } + + this.updatePlaceholderActive(); + // The shown placeholder may have changed shape (image <-> color rect) + // without toggling active. + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + } + + private onPlaceholderTexLoaded: TextureLoadedEventHandler = () => { + this.placeholderTextureLoaded = true; + this.updatePlaceholderActive(); + if (this.placeholderActive === true) { + // Switch from the color-rect fallback to the image: vertex colors go + // to untinted white and the quad's texture changes. + this.setUpdateType(UpdateType.PremultipliedColors); + // The RAF loop may have stopped while the placeholder loaded. + this.stage.requestRender(); + } + }; + + private onPlaceholderTexFailed: TextureFailedEventHandler = () => { + this.placeholderTextureLoaded = false; + this.updatePlaceholderActive(); + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + }; + + private onPlaceholderTexFreed: TextureFreedEventHandler = () => { + this.placeholderTextureLoaded = false; + this.updatePlaceholderActive(); + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } + + // A pinned texture was freed out-of-band — re-pin and reload. The state + // guard makes only the first notified node start the reload; the rest + // see 'loading'. + const texture = this.placeholderTexture; + if (texture !== null && texture.state === 'freed') { + texture.preventCleanup = true; + void this.stage.txManager.loadTexture(texture, true); + } + }; + loadTexture(): void { if (this.props.texture === null) { return; @@ -1485,10 +1623,15 @@ export class CoreNode extends EventEmitter { const alpha = this.worldAlpha; if (this.placeholderActive === true) { - // Placeholder rendering: all four corners take the placeholder color. - // The quad samples the stage's default 1x1 white texture, so this is - // exactly the color-rect path. - const merged = premultiplyColorABGR(props.placeholderColor, alpha); + // Placeholder rendering: all four corners take the same color. With + // the placeholder image loaded, the image renders untinted (white); + // otherwise the quad samples the stage's default 1x1 white texture + // tinted by placeholderColor — exactly the color-rect path. + const color = + this.placeholderTextureLoaded === true + ? 0xffffffff + : props.placeholderColor; + const merged = premultiplyColorABGR(color, alpha); this.premultipliedColorTl = this.premultipliedColorTr = this.premultipliedColorBl = @@ -2085,6 +2228,9 @@ export class CoreNode extends EventEmitter { this.removeAllListeners(); this.unloadTexture(); + // Detach from the long-lived, shared placeholder texture so it does not + // retain this node's handlers (the texture itself stays pinned/cached). + this.setPlaceholderTexture(null); this.isRenderable = false; if (this.hasShaderTimeFn === true) { @@ -2129,6 +2275,9 @@ export class CoreNode extends EventEmitter { get renderTexture(): Texture | null { if (this.placeholderActive === true) { + if (this.placeholderTextureLoaded === true) { + return this.placeholderTexture; + } return this.stage.defaultTexture; } return this.props.texture || this.stage.defaultTexture; @@ -2618,6 +2767,28 @@ export class CoreNode extends EventEmitter { } } + get placeholderImage(): string | null { + return this.props.placeholderImage; + } + + set placeholderImage(value: string | null) { + const p = this.props; + if (p.placeholderImage === value) return; + + p.placeholderImage = value; + + if (value === null) { + this.setPlaceholderTexture(null); + return; + } + + // src-only props: every node using the same URL — regardless of node + // dimensions — resolves to the same cached, shared texture instance. + this.setPlaceholderTexture( + this.stage.txManager.createTexture('ImageTexture', { src: value }), + ); + } + get colorTop(): number { return this.props.colorTop; } diff --git a/src/core/CoreTextNode.test.ts b/src/core/CoreTextNode.test.ts index c1edaf6..03917e8 100644 --- a/src/core/CoreTextNode.test.ts +++ b/src/core/CoreTextNode.test.ts @@ -29,6 +29,7 @@ const defaultProps = ( colorTop: 0xffffffff, colorTr: 0xffffffff, placeholderColor: 0, + placeholderImage: null, h: 0, mount: 0, mountX: 0, diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 39941e2..264a08a 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -383,6 +383,7 @@ export class Stage { colorTl: 0x00000000, colorTr: 0x00000000, placeholderColor: 0x00000000, + placeholderImage: null, colorBl: 0x00000000, colorBr: 0x00000000, zIndex: 0, @@ -1045,6 +1046,7 @@ export class Stage { colorBl, colorBr, placeholderColor: props.placeholderColor ?? 0, + placeholderImage: props.placeholderImage ?? null, zIndex: props.zIndex ?? 0, parent: props.parent ?? null, texture: props.texture ?? null, diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index c995611..04b51f5 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -52,13 +52,18 @@ export class CanvasRenderer extends CoreRenderer { const ctx = this.context; const { tx, ty, ta, tb, tc, td } = node.globalTransform!; const clippingRect = node.clippingRect; - // While a placeholder is showing, render the color-rect path (the default - // ColorTexture) tinted by the node's premultiplied placeholder color. - let texture = ( - node.placeholderActive === true - ? this.stage.defaultTexture - : node.props.texture || this.stage.defaultTexture - ) as Texture; + // While a placeholder is showing, render the node's loaded placeholder + // image, or the color-rect path (the default ColorTexture) tinted by the + // node's premultiplied placeholder color. + let texture; + if (node.placeholderActive === true) { + texture = + node.placeholderTextureLoaded === true + ? (node.placeholderTexture as Texture) + : (this.stage.defaultTexture as Texture); + } else { + texture = (node.props.texture || this.stage.defaultTexture) as Texture; + } // The Canvas2D renderer only supports image textures, no textures are used for color blocks if (texture !== null) { const textureType = texture.type; @@ -175,7 +180,10 @@ export class CanvasRenderer extends CoreRenderer { this.context.globalAlpha = tintColor.a ?? node.worldAlpha; - const txCoords = node.textureCoords; + // node.textureCoords belongs to the main texture (resizeMode, flips) — + // a placeholder image must be drawn whole. + const txCoords = + node.placeholderActive === true ? undefined : node.textureCoords; if (txCoords) { const ix = imageWidth; const iy = imageHeight; diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index 8fbaccc..6ee0c09 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -471,12 +471,18 @@ export class WebGlRenderer extends CoreRenderer { } const props = node.props; - // While a placeholder is showing, the quad samples the shared 1x1 white - // texture tinted by the node's premultiplied placeholder color. - let tx = - node.placeholderActive === true - ? this.stage.defaultTexture! - : props.texture || this.stage.defaultTexture!; + // While a placeholder is showing, the quad samples the node's loaded + // placeholder image, or the shared 1x1 white texture tinted by the + // node's premultiplied placeholder color. + let tx; + if (node.placeholderActive === true) { + tx = + node.placeholderTextureLoaded === true + ? node.placeholderTexture! + : this.stage.defaultTexture!; + } else { + tx = props.texture || this.stage.defaultTexture!; + } if (tx.type === TextureType.subTexture) { tx = (tx as SubTexture).parentTexture; @@ -535,7 +541,12 @@ export class WebGlRenderer extends CoreRenderer { } const rc = node.renderCoords!; - const tc = node.textureCoords || this.defaultTextureCoords; + // node.textureCoords belongs to the main texture (resizeMode, flips) — + // a placeholder must sample its full texture. + const tc = + node.placeholderActive === true + ? this.defaultTextureCoords + : node.textureCoords || this.defaultTextureCoords; const cTl = node.premultipliedColorTl; const cTr = node.premultipliedColorTr; diff --git a/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-image-1.png b/visual-regression/certified-snapshots/chromium-ci/texture-placeholder-image-1.png new file mode 100644 index 0000000000000000000000000000000000000000..371440f8805c6180fb478b849bfc8651215092a0 GIT binary patch literal 40304 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 literal 0 HcmV?d00001 From dae7af902d7bb4cf3d82d4b0df116615d079c209 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 10 Jun 2026 15:56:17 -0400 Subject: [PATCH 2/2] test(core): nodes sharing one placeholder texture transition independently Covers the shared-placeholder contract explicitly: one cached instance and a single fetch across N nodes, every node notified on load, each switching to its own main texture independently, and one node's destroy leaving the others' subscriptions intact. Co-Authored-By: Claude Fable 5 --- src/core/CoreNode.test.ts | 64 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index 1baa124..56fe3bb 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -1735,5 +1735,69 @@ describe('set color()', () => { expect(node.placeholderActive).toBe(false); expect(node.isRenderable).toBe(false); }); + + it('many nodes share one placeholder texture and transition independently', async () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + // Mimic the real loadTexture: setState('loading') happens synchronously + // before the first await, which is what dedupes subsequent callers. + loadTexture.mockImplementation(() => { + (placeholder as { state: string }).state = 'loading'; + }); + + const a = visibleNode(stage); + const b = visibleNode(stage); + const c = visibleNode(stage); + a.placeholderImage = 'placeholder-poster.png'; + b.placeholderImage = 'placeholder-poster.png'; + c.placeholderImage = 'placeholder-poster.png'; + + // One shared instance, one fetch. + expect(a.placeholderTexture).toBe(placeholder); + expect(b.placeholderTexture).toBe(placeholder); + expect(c.placeholderTexture).toBe(placeholder); + expect(loadTexture).toHaveBeenCalledTimes(1); + + const mainA = emittingTexture('initial'); + const mainB = emittingTexture('initial'); + const mainC = emittingTexture('initial'); + a.texture = mainA; + b.texture = mainB; + c.texture = mainC; + + // The shared placeholder loads: every node is notified and shows it. + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + a.update(0, clippingRect); + b.update(0, clippingRect); + c.update(0, clippingRect); + expect(a.renderTexture).toBe(placeholder); + expect(b.renderTexture).toBe(placeholder); + expect(c.renderTexture).toBe(placeholder); + + // Node A's poster arrives — A switches, B and C keep the placeholder. + await Promise.resolve(); // flush loadTextureTask so main listeners attach + (mainA as { state: string }).state = 'loaded'; + mainA.emit('loaded', { w: 100, h: 100 }); + a.update(1, clippingRect); + b.update(1, clippingRect); + expect(a.placeholderActive).toBe(false); + expect(a.renderTexture).toBe(mainA); + expect(b.renderTexture).toBe(placeholder); + expect(c.renderTexture).toBe(placeholder); + + // Node B is destroyed mid-load — C is unaffected and the texture only + // loses B's listeners. + b.destroy(); + expect(placeholder.hasListeners()).toBe(true); + expect(c.renderTexture).toBe(placeholder); + + // C's poster arrives last. + (mainC as { state: string }).state = 'loaded'; + mainC.emit('loaded', { w: 100, h: 100 }); + c.update(2, clippingRect); + expect(c.renderTexture).toBe(mainC); + }); }); });