From 809d89acce82469a3c8c1a84f96b01e194dc7922 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Wed, 10 Jun 2026 15:48:25 -0400 Subject: [PATCH 1/5] =?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/5] 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); + }); }); }); From 25d0f3f5b7bde3d1444db5bcb5c40bc355d3c996 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 6 Aug 2026 08:29:54 -0400 Subject: [PATCH 3/5] test(vrt): certify the Canvas2D baseline for texture-placeholder-image The webgl snapshot was captured before the Canvas2D visual-regression suite landed on main (#125/#126). Without a chromium-ci-canvas baseline the canvas CI job fails the test outright ("snapshot does not exist"). Captured in the Docker runner, twice, byte-identical. --- .../texture-placeholder-image-1.png | Bin 0 -> 40456 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 visual-regression/certified-snapshots/chromium-ci-canvas/texture-placeholder-image-1.png diff --git a/visual-regression/certified-snapshots/chromium-ci-canvas/texture-placeholder-image-1.png b/visual-regression/certified-snapshots/chromium-ci-canvas/texture-placeholder-image-1.png new file mode 100644 index 0000000000000000000000000000000000000000..fe680ff58d23ca499ea5cc6bdc070b679ce8b2e7 GIT binary patch literal 40456 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 Date: Thu, 6 Aug 2026 08:31:31 -0400 Subject: [PATCH 4/5] fix(renderers): keep the placeholder texture pick typed as Texture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Splitting the texture pick into an if/else left `let tx;` / `let texture;` uninitialized, so TypeScript's evolving-let inference widened them to `any` — `tx.type` and `tx.ctxTexture` in the per-quad hot path became unchecked member accesses (eslint no-unsafe-member-access). Annotate the declarations; type-only change, emitted JS is unchanged. --- src/core/renderers/canvas/CanvasRenderer.ts | 2 +- src/core/renderers/webgl/WebGlRenderer.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/renderers/canvas/CanvasRenderer.ts b/src/core/renderers/canvas/CanvasRenderer.ts index 02250ba..cb4dc0f 100644 --- a/src/core/renderers/canvas/CanvasRenderer.ts +++ b/src/core/renderers/canvas/CanvasRenderer.ts @@ -70,7 +70,7 @@ export class CanvasRenderer extends CoreRenderer { // 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; + let texture: Texture; if (node.placeholderActive === true) { texture = node.placeholderTextureLoaded === true diff --git a/src/core/renderers/webgl/WebGlRenderer.ts b/src/core/renderers/webgl/WebGlRenderer.ts index 17cfe70..ba8954b 100644 --- a/src/core/renderers/webgl/WebGlRenderer.ts +++ b/src/core/renderers/webgl/WebGlRenderer.ts @@ -503,7 +503,7 @@ export class WebGlRenderer extends CoreRenderer { // 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; + let tx: Texture; if (node.placeholderActive === true) { tx = node.placeholderTextureLoaded === true From f400540481118dd7345b4ea635b9151f2aadbaa2 Mon Sep 17 00:00:00 2001 From: Chris Lorenzo Date: Thu, 6 Aug 2026 09:13:10 -0400 Subject: [PATCH 5/5] refactor(core): move the placeholder image lifecycle into PlaceholderManager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit placeholderImage attached three per-node listeners to a texture the design deliberately shares across many nodes, so a 500-poster list put 1500 listeners on one emitter. EventEmitter.off is indexOf + splice, making teardown of such a list quadratic — at a page transition, the worst moment for it on a TV SoC. The three handlers were also class-field arrows, so every CoreNode in the graph allocated them whether or not it used a placeholder. Stage now owns a PlaceholderManager keyed by URL. It creates, pins and eagerly loads each shared texture once, subscribes one listener trio per URL, and walks its own subscriber list on a state change. Nodes subscribe and release; release is O(1) via a swap-pop against a stored index, the same trick Texture uses for renderable owners. CoreNode keeps only the two fields the per-quad path already read (placeholderTexture, placeholderTextureLoaded) as manager-written caches, plus two plain bookkeeping slots — and is back to exactly 4 per-instance closures, the pre-feature count. Both renderers are untouched, so the hot path is byte-identical. --- src/core/CoreNode.test.ts | 119 ++++++++++++++++++++--- src/core/CoreNode.ts | 135 +++++++++------------------ src/core/PlaceholderManager.ts | 166 +++++++++++++++++++++++++++++++++ src/core/Stage.ts | 3 + 4 files changed, 316 insertions(+), 107 deletions(-) create mode 100644 src/core/PlaceholderManager.ts diff --git a/src/core/CoreNode.test.ts b/src/core/CoreNode.test.ts index fc23931..660bbde 100644 --- a/src/core/CoreNode.test.ts +++ b/src/core/CoreNode.test.ts @@ -9,6 +9,7 @@ import { ImageTexture } from './textures/ImageTexture.js'; import { Matrix3d } from './lib/Matrix3d.js'; import { EventEmitter } from '../common/EventEmitter.js'; import { premultiplyColorABGR } from '../utils.js'; +import { PlaceholderManager } from './PlaceholderManager.js'; describe('set color()', () => { const defaultProps = (overrides?: Partial): CoreNodeProps => ({ @@ -1573,8 +1574,11 @@ describe('set color()', () => { }); describe('placeholderImage', () => { - // The placeholderImage setter resolves URLs through txManager, so this - // suite uses a stage mock with an explicit txManager stub. + // placeholderImage delegates its whole texture lifecycle to the stage's + // PlaceholderManager, so this suite wires a real manager onto a stage mock + // with a stubbed txManager. Using the real manager (rather than mocking it) + // is the point: the sharing and subscribe/release behavior under test lives + // there, not on CoreNode. function placeholderStage() { const createTexture = vi.fn(); const loadTexture = vi.fn(); @@ -1590,6 +1594,8 @@ describe('set color()', () => { loadTexture, } as unknown as Stage['txManager'], }); + (stage as { placeholderManager: PlaceholderManager }).placeholderManager = + new PlaceholderManager(stage); return { stage, createTexture, loadTexture }; } @@ -1610,6 +1616,18 @@ describe('set color()', () => { }; } + // How many handlers are registered for one event. The manager's whole + // point is that this stays at 1 no matter how many nodes subscribe. + function listenerCount(texture: unknown, event: string): number { + const map = (texture as { eventListeners: Record }) + .eventListeners; + if (map === null || map === undefined) { + return 0; + } + const listeners = map[event]; + return listeners === undefined ? 0 : listeners.length; + } + function visibleNode(stage: Stage): CoreNode { const parent = new CoreNode(stage, defaultProps()); parent.globalTransform = Matrix3d.identity(); @@ -1793,21 +1811,26 @@ describe('set color()', () => { expect(loadTexture).toHaveBeenCalledWith(placeholder, true); }); - it('destroy detaches the node from the shared placeholder texture', () => { + it('destroy unsubscribes the node from the shared placeholder', () => { 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); + expect(node.placeholderEntry).not.toBe(null); node.destroy(); + expect(node.placeholderEntry).toBe(null); - expect(placeholder.hasListeners()).toBe(false); + // The shared texture outlives the node (it stays pinned and cached), so + // the real check is that a later transition no longer reaches it. + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + expect(node.placeholderTextureLoaded).toBe(false); }); - it('swapping placeholderImage moves listeners to the new texture', () => { + it('swapping placeholderImage moves the node to the new entry', () => { const { stage, createTexture } = placeholderStage(); const first = emittingTexture('initial'); const second = emittingTexture('initial'); @@ -1815,16 +1838,22 @@ describe('set color()', () => { const node = visibleNode(stage); node.placeholderImage = 'placeholder-a.png'; - expect(first.hasListeners()).toBe(true); + expect(node.placeholderTexture).toBe(first); node.placeholderImage = 'placeholder-b.png'; - - expect(first.hasListeners()).toBe(false); - expect(second.hasListeners()).toBe(true); expect(node.placeholderTexture).toBe(second); + + // The old entry must no longer drive this node. + (first as { state: string }).state = 'loaded'; + first.emit('loaded', { w: 100, h: 100 }); + expect(node.placeholderTextureLoaded).toBe(false); + + (second as { state: string }).state = 'loaded'; + second.emit('loaded', { w: 100, h: 100 }); + expect(node.placeholderTextureLoaded).toBe(true); }); - it('clearing placeholderImage detaches and deactivates', () => { + it('clearing placeholderImage unsubscribes and deactivates', () => { const { stage, createTexture } = placeholderStage(); const placeholder = emittingTexture('loaded'); createTexture.mockReturnValue(placeholder); @@ -1838,11 +1867,70 @@ describe('set color()', () => { node.placeholderImage = null; node.update(1, clippingRect); - expect(placeholder.hasListeners()).toBe(false); + expect(node.placeholderEntry).toBe(null); + expect(node.placeholderTexture).toBe(null); expect(node.placeholderActive).toBe(false); expect(node.isRenderable).toBe(false); }); + it('subscribes N nodes to one URL with a single listener set', () => { + const { stage, createTexture, loadTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + + const nodes: CoreNode[] = []; + for (let i = 0; i < 50; i++) { + const node = visibleNode(stage); + node.placeholderImage = 'placeholder-poster.png'; + nodes.push(node); + } + + // One texture, one fetch, and — the point of the manager — one listener + // trio total rather than one per node. + expect(createTexture).toHaveBeenCalledTimes(1); + expect(loadTexture).toHaveBeenCalledTimes(1); + expect(listenerCount(placeholder, 'loaded')).toBe(1); + expect(listenerCount(placeholder, 'failed')).toBe(1); + expect(listenerCount(placeholder, 'freed')).toBe(1); + + // Every node is still driven by that single listener. + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + for (let i = 0; i < nodes.length; i++) { + expect(nodes[i]!.placeholderTextureLoaded).toBe(true); + } + }); + + it('releasing out of order keeps every remaining node subscribed', () => { + const { stage, createTexture } = placeholderStage(); + const placeholder = emittingTexture('initial'); + createTexture.mockReturnValue(placeholder); + + const nodes: CoreNode[] = []; + for (let i = 0; i < 6; i++) { + const node = visibleNode(stage); + node.placeholderImage = 'placeholder-poster.png'; + nodes.push(node); + } + + // Release from the middle and from the front: the swap-pop must keep the + // survivors' indices consistent, or a later release corrupts the list. + nodes[2]!.destroy(); + nodes[0]!.destroy(); + nodes[4]!.destroy(); + + const survivors = [nodes[1]!, nodes[3]!, nodes[5]!]; + (placeholder as { state: string }).state = 'loaded'; + placeholder.emit('loaded', { w: 100, h: 100 }); + + for (let i = 0; i < survivors.length; i++) { + expect(survivors[i]!.placeholderTextureLoaded).toBe(true); + } + expect(nodes[2]!.placeholderTextureLoaded).toBe(false); + expect(nodes[0]!.placeholderTextureLoaded).toBe(false); + expect(nodes[4]!.placeholderTextureLoaded).toBe(false); + }); + it('many nodes share one placeholder texture and transition independently', async () => { const { stage, createTexture, loadTexture } = placeholderStage(); const placeholder = emittingTexture('initial'); @@ -1894,10 +1982,11 @@ describe('set color()', () => { 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. + // Node B is destroyed mid-load — C is unaffected and the shared entry + // only loses B. b.destroy(); - expect(placeholder.hasListeners()).toBe(true); + expect(b.placeholderEntry).toBe(null); + expect(c.placeholderEntry).not.toBe(null); expect(c.renderTexture).toBe(placeholder); // C's poster arrives last. diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index c50d2df..14db7cf 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -12,6 +12,7 @@ import type { WebGlCtxTexture } from './renderers/webgl/WebGlCtxTexture.js'; import type { BufferCollection } from './renderers/webgl/internal/BufferCollection.js'; import type { CoreRenderer } from './renderers/CoreRenderer.js'; import type { Stage } from './Stage.js'; +import type { PlaceholderEntry } from './PlaceholderManager.js'; import { Texture, TextureType, @@ -873,16 +874,30 @@ export class CoreNode extends EventEmitter { /** * Shared, pinned (`preventCleanup`) texture for {@link placeholderImage}, - * or `null`. Owned by the placeholderImage setter. + * or `null`. Written by {@link PlaceholderManager}; cached here so the + * per-quad path reads a direct field instead of chasing the entry. */ public placeholderTexture: Texture | null = null; /** * Cached `placeholderTexture.state === 'loaded'` (avoids per-quad string - * compares). Maintained by the placeholder texture event handlers. + * compares). Written by {@link PlaceholderManager}. */ public placeholderTextureLoaded = false; + /** + * The {@link PlaceholderManager} entry this node subscribes to, or `null`. + * Manager-owned bookkeeping — never write it elsewhere. + */ + public placeholderEntry: PlaceholderEntry | null = null; + + /** + * This node's slot in {@link placeholderEntry}'s subscriber list, or `-1`. + * Lets the manager unsubscribe in O(1); without it, tearing down a row of + * nodes sharing one placeholder would be quadratic. + */ + public placeholderIndex = -1; + public updateType = UpdateType.All; public childUpdateType = UpdateType.None; @@ -1082,93 +1097,24 @@ export class CoreNode extends EventEmitter { } /** - * Assign or clear the shared placeholder image texture. + * Called by {@link PlaceholderManager} when the shared placeholder texture + * this node subscribes to finishes loading, fails, or is freed. * * @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); - } - } - + * A plain method, not a bound handler: the manager holds one listener trio + * per URL and walks its subscriber list, so nothing here is allocated per + * node. `loaded` toggling flips the quad between the untinted image and the + * `placeholderColor` rect, which is a vertex-color change — hence + * `PremultipliedColors` (which also marks the quad dirty). + */ + onPlaceholderStateChange(loaded: boolean): void { + this.placeholderTextureLoaded = loaded; 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; @@ -2278,9 +2224,13 @@ 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); + // Unsubscribe from the long-lived, shared placeholder so it does not + // retain this node (the texture itself stays pinned/cached). Guarded so + // the overwhelming majority of nodes — which never set placeholderImage — + // skip the call entirely on teardown. + if (this.placeholderEntry !== null) { + this.stage.placeholderManager.release(this); + } this.isRenderable = false; if (this.hasShaderTimeFn === true) { @@ -2845,16 +2795,17 @@ export class CoreNode extends EventEmitter { p.placeholderImage = value; - if (value === null) { - this.setPlaceholderTexture(null); - return; + this.stage.placeholderManager.release(this); + if (value !== null) { + this.stage.placeholderManager.acquire(value, this); } - // 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 }), - ); + this.updatePlaceholderActive(); + // The shown placeholder may have changed shape (image <-> color rect) + // without toggling active. + if (this.placeholderActive === true) { + this.setUpdateType(UpdateType.PremultipliedColors); + } } get colorTop(): number { diff --git a/src/core/PlaceholderManager.ts b/src/core/PlaceholderManager.ts new file mode 100644 index 0000000..ee90b2f --- /dev/null +++ b/src/core/PlaceholderManager.ts @@ -0,0 +1,166 @@ +import type { CoreNode } from './CoreNode.js'; +import type { Stage } from './Stage.js'; +import type { Texture } from './textures/Texture.js'; + +/** + * One shared, pinned placeholder image, plus every Node currently showing it. + * + * @remarks + * Allocated once per distinct URL — never per Node. The event handlers that + * drive the fallback state machine are created here and subscribe to the + * texture exactly once, so a 500-poster scroll list puts 3 listeners on the + * texture instead of 1500. + */ +export interface PlaceholderEntry { + texture: Texture; + /** + * Cached `texture.state === 'loaded'`. Mirrored onto each subscribed Node as + * `placeholderTextureLoaded` so the per-quad path never chases this pointer. + */ + loaded: boolean; + /** + * Subscribed Nodes, in no particular order. Maintained by swap-pop against + * each Node's `placeholderIndex`, so releasing is O(1) — a plain + * `indexOf`/`splice` would make tearing down a full row quadratic. + */ + nodes: CoreNode[]; +} + +/** + * Owns the texture lifecycle behind {@link CoreNode.placeholderImage}. + * + * @remarks + * Placeholder images are a small, fixed set (typically one per poster size + * class) shared across many Nodes, so the texture is resolved once per URL and + * pinned (`preventCleanup`) for the lifetime of the app: the memory manager + * never frees it and orphan eviction never touches it. The cost is bounded by + * distinct URLs, not by Node count. + * + * Nodes subscribe and unsubscribe; they never touch the texture, its listeners, + * or its load state themselves. Everything a Node keeps is a cache this manager + * writes (`placeholderTexture`, `placeholderTextureLoaded`) plus two slots of + * bookkeeping (`placeholderEntry`, `placeholderIndex`). + */ +export class PlaceholderManager { + private readonly stage: Stage; + private readonly entries: Map = new Map(); + + constructor(stage: Stage) { + this.stage = stage; + } + + /** + * Subscribe `node` to the shared placeholder for `url`, creating and + * eagerly loading it on first use. + */ + acquire(url: string, node: CoreNode): void { + let entry = this.entries.get(url); + if (entry === undefined) { + entry = this.createEntry(url); + } + + const nodes = entry.nodes; + node.placeholderEntry = entry; + node.placeholderIndex = nodes.length; + node.placeholderTexture = entry.texture; + node.placeholderTextureLoaded = entry.loaded; + nodes.push(node); + } + + /** + * Unsubscribe `node`. The entry itself — texture, pin and listeners — stays + * alive for the next Node that asks for the same URL. + */ + release(node: CoreNode): void { + const entry = node.placeholderEntry; + if (entry === null) { + return; + } + + // Swap-pop: move the last subscriber into this node's slot and fix up its + // index. Same O(1) removal trick as Texture's renderable-owner tracking. + const nodes = entry.nodes; + const index = node.placeholderIndex; + const last = nodes.length - 1; + const moved = nodes[last] as CoreNode; + nodes[index] = moved; + moved.placeholderIndex = index; + nodes.length = last; + + node.placeholderEntry = null; + node.placeholderIndex = -1; + node.placeholderTexture = null; + node.placeholderTextureLoaded = false; + } + + private createEntry(url: string): PlaceholderEntry { + // src-only props: every Node using this URL — regardless of Node + // dimensions — resolves to the same cached texture instance. + const texture = this.stage.txManager.createTexture('ImageTexture', { + src: url, + }); + texture.preventCleanup = true; + + const entry: PlaceholderEntry = { + texture, + loaded: texture.state === 'loaded', + nodes: [], + }; + this.entries.set(url, entry); + + // One listener trio per URL, allocated once here. They live for the rest + // of the session, which is exactly as long as the pinned texture does. + texture.on('loaded', () => { + this.notify(entry, true); + // The RAF loop may have stopped while the placeholder loaded. + this.stage.requestRender(); + }); + texture.on('failed', () => { + this.notify(entry, false); + }); + texture.on('freed', () => { + this.notify(entry, false); + // A pinned texture was freed out-of-band — in practice, a Node whose + // `src` happens to match this URL, since `loadTextureTask` writes its + // own `textureOptions.preventCleanup` onto the shared instance. Re-pin + // and reload. (GL context loss is terminal here — the Stage emits + // `contextLost` and the app reloads — so it is not a case this covers.) + texture.preventCleanup = true; + this.load(entry); + }); + + this.load(entry); + return entry; + } + + /** + * Eager priority load, from idle states only. `'loading'`/`'fetching'` means + * a load is already in flight and a second call would start a duplicate + * fetch of the same source; `'failed'` is terminal and never retried. + */ + private load(entry: PlaceholderEntry): void { + const state = entry.texture.state; + if (state === 'initial' || state === 'freed') { + void this.stage.txManager.loadTexture(entry.texture, true); + } + } + + /** + * Push a state change to every subscriber. + * + * @remarks + * Walks the live array with a cached length — no defensive copy, which is + * the whole point of owning the fan-out here rather than paying + * `EventEmitter.emit`'s per-dispatch spread. That is only sound because + * `onPlaceholderStateChange` cannot re-enter the manager: it just sets flags + * and raises an update type. If it ever grows a path that destroys a node or + * reassigns `placeholderImage`, this loop has to become mutation-safe. + */ + private notify(entry: PlaceholderEntry, loaded: boolean): void { + entry.loaded = loaded; + const nodes = entry.nodes; + for (let i = 0, n = nodes.length; i < n; i++) { + nodes[i]!.onPlaceholderStateChange(loaded); + } + } +} diff --git a/src/core/Stage.ts b/src/core/Stage.ts index 633c25d..677171f 100644 --- a/src/core/Stage.ts +++ b/src/core/Stage.ts @@ -17,6 +17,7 @@ import { type TextureOptions, } from './CoreTextureManager.js'; import { CoreShaderManager } from './CoreShaderManager.js'; +import { PlaceholderManager } from './PlaceholderManager.js'; import { type FontHandler, type FontLoadOptions, @@ -94,6 +95,7 @@ export class Stage { public readonly textRenderers: Record = {}; public readonly fontHandlers: Record = {}; public readonly shManager: CoreShaderManager; + public readonly placeholderManager: PlaceholderManager; public readonly renderer: CoreRenderer; public readonly root: CoreNode; public boundsMargin: [number, number, number, number]; @@ -270,6 +272,7 @@ export class Stage { }); this.shManager = new CoreShaderManager(this); + this.placeholderManager = new PlaceholderManager(this); this.defShaderNode = this.renderer.getDefaultShaderNode(); this.calculateTextureCoord = this.renderer.getTextureCoords !== undefined;