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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
165 changes: 165 additions & 0 deletions src/core/CoreNode.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1712,4 +1712,169 @@ describe('set color()', () => {
expect(node.isRenderable).toBe(true);
});
});

describe('texture ownership cache', () => {
// Same shape as the placeholderColor texture fake: a real EventEmitter so
// loadTextureTask subscribes and we can drive freed/loaded by emitting.
function emittingTexture(state: string): ImageTexture & {
emit: (event: string, data?: unknown) => void;
} {
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;
};
}

function visibleNode(): 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;
}

const flushMicrotasks = () => Promise.resolve();

it('repeated updates with unchanged state call setRenderableOwner once', () => {
const node = visibleNode();
const texture = emittingTexture('loaded');
node.texture = texture;
node.textureLoaded = true;

// The texture setter registers ownership with the node's current
// isRenderable (false here), so the cache starts false.
expect(texture.setRenderableOwner).toHaveBeenCalledTimes(1);
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
false,
);

node.update(0, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenCalledTimes(2);
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
true,
);

// Steady-state scroll: ownership unchanged, no further calls.
node.update(1, clippingRect);
node.update(2, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenCalledTimes(2);
});

it('moving out of bounds releases ownership once, returning re-adds', () => {
const node = visibleNode();
const texture = emittingTexture('loaded');
node.texture = texture;
node.textureLoaded = true;

node.update(0, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
true,
);
const callsAfterFirstUpdate = (
texture.setRenderableOwner as ReturnType<typeof vi.fn>
).mock.calls.length;

// Out of the 200x200 stage bounds entirely.
node.x = 1000;
node.update(1, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenCalledTimes(
callsAfterFirstUpdate + 1,
);
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
false,
);

// Still out of bounds: no repeated release.
node.update(2, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenCalledTimes(
callsAfterFirstUpdate + 1,
);

// Back in view: re-registered exactly once.
node.x = 0;
node.update(3, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenCalledTimes(
callsAfterFirstUpdate + 2,
);
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
true,
);
});

it('swapping textures releases the old owner and registers the new one', () => {
const node = visibleNode();
const textureA = emittingTexture('loaded');
node.texture = textureA;
node.textureLoaded = true;
node.update(0, clippingRect);
expect(textureA.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
true,
);

const textureB = emittingTexture('loaded');
node.texture = textureB;

// A is released via unloadTexture.
expect(textureA.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
false,
);
// B is registered with the node's current renderable state (true),
// proving the cache reset on swap — a stale cache would skip this.
expect(textureB.setRenderableOwner).toHaveBeenCalledWith(
expect.anything(),
true,
);
});

it('freed texture is re-registered on the next update (reload trigger)', async () => {
const node = visibleNode();
const texture = emittingTexture('initial');
node.texture = texture;
node.update(0, clippingRect);

await flushMicrotasks();
(texture as { state: string }).state = 'loaded';
texture.emit('loaded', { w: 100, h: 100 });
node.update(1, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
true,
);

// Memory manager frees the texture: the node must drop ownership...
(texture as { state: string }).state = 'freed';
texture.emit('freed');
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
false,
);

// ...and the next update pass re-adds it, which is what triggers
// Texture.load() for the reload. A stale cache would skip this call.
node.update(2, clippingRect);
expect(texture.setRenderableOwner).toHaveBeenLastCalledWith(
expect.anything(),
true,
);
});
});
});
14 changes: 14 additions & 0 deletions src/core/CoreNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -829,6 +829,14 @@ export class CoreNode extends EventEmitter {
private hasColorProps = false;
public textureLoaded = false;

/**
* Last ownership value sent to the current texture via
* {@link updateTextureOwnership}. Per (node, texture) pair — must reset to
* `false` whenever the texture is swapped or released, or a stale `true`
* would skip the re-registration that triggers `Texture.load()`.
*/
private textureOwnership = false;

/**
* True while this node should render its `placeholderColor` instead of its
* texture: `placeholderColor` is non-zero, a texture is set, and that
Expand Down Expand Up @@ -1085,6 +1093,7 @@ export class CoreNode extends EventEmitter {
texture.off('failed', this.onTextureFailed);
texture.off('freed', this.onTextureFreed);
texture.setRenderableOwner(this._id, false);
this.textureOwnership = false;
}

protected onTextureLoaded: TextureLoadedEventHandler = (_, dimensions) => {
Expand Down Expand Up @@ -1918,6 +1927,10 @@ export class CoreNode extends EventEmitter {
* Changes the renderable state of the node.
*/
updateTextureOwnership(isRenderable: boolean) {
if (this.textureOwnership === isRenderable) {
return;
}
this.textureOwnership = isRenderable;
this.texture?.setRenderableOwner(this._id, isRenderable);
}

Expand Down Expand Up @@ -3100,6 +3113,7 @@ export class CoreNode extends EventEmitter {
this.autosizer.setMode(AutosizeMode.Texture); // Set to texture size mode
}
value.setRenderableOwner(this._id, this.isRenderable);
this.textureOwnership = this.isRenderable;
this.loadTexture();
}

Expand Down
5 changes: 3 additions & 2 deletions src/core/CoreTextNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,8 +266,9 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
this.setRenderable(false);

if (this.renderState > CoreNodeRenderState.OutOfBounds) {
// We do want the texture to load immediately
this.texture.setRenderableOwner(this._id, true);
// We do want the texture to load immediately. Routed through
// updateTextureOwnership so the ownership cache stays in sync.
this.updateTextureOwnership(true);
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions src/core/TextureMemoryManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ function freedCachedTexture(cacheKey: string): Texture & {
state: 'freed',
type: TextureType.image,
preventCleanup: false,
renderableOwners: [],
renderableOwners: new Set<string | number>(),
cacheKey,
free: vi.fn(),
destroy: vi.fn(),
Expand Down Expand Up @@ -206,7 +206,7 @@ describe('TextureMemoryManager — orphaned freed texture eviction', () => {
it('keeps a freed texture that still has renderable owners', () => {
const { mgr, keyCache } = makeManager();
const texture = freedCachedTexture('img:poster.png');
(texture.renderableOwners as unknown[]).push(1);
texture.renderableOwners.add(1);
keyCache.set('img:poster.png', texture);

mgr.cleanup();
Expand Down
2 changes: 1 addition & 1 deletion src/core/TextureMemoryManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -349,7 +349,7 @@ export class TextureMemoryManager {
if (
evictable === true &&
texture.preventCleanup === false &&
texture.renderableOwners.length === 0 &&
texture.renderableOwners.size === 0 &&
texture.hasListeners() === false
) {
this.destroyTexture(texture);
Expand Down
2 changes: 1 addition & 1 deletion src/core/textures/SubTexture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export class SubTexture extends Texture {
// Resolve parent texture from cache or fallback to provided texture
this.parentTexture = txManager.resolveParentTexture(this.props.texture);

if (this.renderableOwners.length > 0) {
if (this.renderableOwners.size > 0) {
this.parentTexture.setRenderableOwner(this.subtextureId, true);
}

Expand Down
124 changes: 124 additions & 0 deletions src/core/textures/Texture.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, it, expect, vi } from 'vitest';
import { Texture, type TextureData } from './Texture.js';
import type { CoreTextureManager } from '../CoreTextureManager.js';

class TestTexture extends Texture {
override async getTextureSource(): Promise<TextureData> {
return { data: null };
}
}

function makeTexture(): {
texture: TestTexture;
loadTexture: ReturnType<typeof vi.fn>;
onChangeIsRenderable: ReturnType<typeof vi.fn>;
} {
const loadTexture = vi.fn();
const txManager = {
maxRetryCount: 3,
loadTexture,
} as unknown as CoreTextureManager;
const texture = new TestTexture(txManager);
const onChangeIsRenderable = vi.fn();
texture.onChangeIsRenderable = onChangeIsRenderable;
return { texture, loadTexture, onChangeIsRenderable };
}

describe('Texture.setRenderableOwner', () => {
it('adding an owner fires the 0→1 transition exactly once and loads', () => {
const { texture, loadTexture, onChangeIsRenderable } = makeTexture();

texture.setRenderableOwner(1, true);

expect(texture.renderableOwners.size).toBe(1);
expect(texture.renderable).toBe(true);
expect(onChangeIsRenderable).toHaveBeenCalledTimes(1);
expect(onChangeIsRenderable).toHaveBeenCalledWith(true);
expect(loadTexture).toHaveBeenCalledTimes(1);
});

it('double-add of the same owner is a no-op (size stays 1, one event)', () => {
const { texture, loadTexture, onChangeIsRenderable } = makeTexture();

texture.setRenderableOwner(1, true);
texture.setRenderableOwner(1, true);

expect(texture.renderableOwners.size).toBe(1);
expect(onChangeIsRenderable).toHaveBeenCalledTimes(1);
expect(loadTexture).toHaveBeenCalledTimes(1);
});

it('a second distinct owner does not re-fire the transition', () => {
const { texture, loadTexture, onChangeIsRenderable } = makeTexture();

texture.setRenderableOwner(1, true);
texture.setRenderableOwner(2, true);

expect(texture.renderableOwners.size).toBe(2);
expect(onChangeIsRenderable).toHaveBeenCalledTimes(1);
expect(loadTexture).toHaveBeenCalledTimes(1);
});

it('removing a non-member owner does not fire a transition', () => {
const { texture, onChangeIsRenderable } = makeTexture();

texture.setRenderableOwner(1, false);

expect(texture.renderableOwners.size).toBe(0);
expect(texture.renderable).toBe(false);
expect(onChangeIsRenderable).not.toHaveBeenCalled();
});

it('1→0 fires onChangeIsRenderable(false) exactly once', () => {
const { texture, onChangeIsRenderable } = makeTexture();

texture.setRenderableOwner(1, true);
texture.setRenderableOwner(1, false);
texture.setRenderableOwner(1, false);

expect(texture.renderableOwners.size).toBe(0);
expect(texture.renderable).toBe(false);
expect(onChangeIsRenderable).toHaveBeenCalledTimes(2);
expect(onChangeIsRenderable).toHaveBeenLastCalledWith(false);
});

it('re-adding an owner after 1→0 triggers load again (freed→reload cycle)', () => {
const { texture, loadTexture } = makeTexture();

texture.setRenderableOwner(1, true);
texture.setRenderableOwner(1, false);
texture.setRenderableOwner(1, true);

expect(texture.renderable).toBe(true);
expect(loadTexture).toHaveBeenCalledTimes(2);
});

it('string and number owners coexist (node ids vs subtexture/font keys)', () => {
const { texture } = makeTexture();

texture.setRenderableOwner(7, true);
texture.setRenderableOwner('subtexture-7', true);

expect(texture.renderableOwners.size).toBe(2);

texture.setRenderableOwner(7, false);
expect(texture.renderableOwners.size).toBe(1);
expect(texture.renderable).toBe(true);

texture.setRenderableOwner('subtexture-7', false);
expect(texture.renderableOwners.size).toBe(0);
expect(texture.renderable).toBe(false);
});

it('canBeCleanedUp respects remaining owners', () => {
const { texture } = makeTexture();
// Skip the startup grace period so owners are the deciding factor.
texture.isWithinStartupGracePeriod = () => false;

texture.setRenderableOwner(1, true);
expect(texture.canBeCleanedUp()).toBe(false);

texture.setRenderableOwner(1, false);
expect(texture.canBeCleanedUp()).toBe(true);
});
});
Loading
Loading