@solidtv/renderer 1.3.1 → 1.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/dist/src/core/CoreNode.d.ts +0 -8
  2. package/dist/src/core/CoreNode.js +7 -14
  3. package/dist/src/core/CoreNode.js.map +1 -1
  4. package/dist/src/core/Stage.d.ts +7 -15
  5. package/dist/src/core/Stage.js +22 -39
  6. package/dist/src/core/Stage.js.map +1 -1
  7. package/dist/src/core/TextureMemoryManager.js +4 -3
  8. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  9. package/dist/src/core/lib/utils.d.ts +0 -1
  10. package/dist/src/core/lib/utils.js +0 -3
  11. package/dist/src/core/lib/utils.js.map +1 -1
  12. package/dist/src/core/platforms/web/WebPlatform.js +1 -0
  13. package/dist/src/core/platforms/web/WebPlatform.js.map +1 -1
  14. package/dist/src/core/renderers/CoreShaderNode.d.ts +16 -0
  15. package/dist/src/core/renderers/CoreShaderNode.js +22 -0
  16. package/dist/src/core/renderers/CoreShaderNode.js.map +1 -1
  17. package/dist/src/core/renderers/canvas/CanvasRenderer.js +23 -3
  18. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  19. package/dist/src/core/renderers/canvas/CanvasShaderNode.d.ts +0 -1
  20. package/dist/src/core/renderers/canvas/CanvasShaderNode.js +0 -1
  21. package/dist/src/core/renderers/canvas/CanvasShaderNode.js.map +1 -1
  22. package/dist/src/core/renderers/canvas/CanvasTexture.d.ts +1 -1
  23. package/dist/src/core/renderers/canvas/CanvasTexture.js +25 -6
  24. package/dist/src/core/renderers/canvas/CanvasTexture.js.map +1 -1
  25. package/dist/src/core/renderers/webgl/WebGlShaderNode.d.ts +0 -1
  26. package/dist/src/core/renderers/webgl/WebGlShaderNode.js +0 -1
  27. package/dist/src/core/renderers/webgl/WebGlShaderNode.js.map +1 -1
  28. package/dist/src/core/text-rendering/CanvasTextRenderer.d.ts +1 -0
  29. package/dist/src/core/text-rendering/CanvasTextRenderer.js +23 -0
  30. package/dist/src/core/text-rendering/CanvasTextRenderer.js.map +1 -1
  31. package/dist/src/core/text-rendering/SdfTextRenderer.d.ts +1 -0
  32. package/dist/src/core/text-rendering/SdfTextRenderer.js +27 -0
  33. package/dist/src/core/text-rendering/SdfTextRenderer.js.map +1 -1
  34. package/dist/src/core/text-rendering/TextRenderer.d.ts +7 -0
  35. package/dist/src/core/textures/ImageTexture.js +11 -0
  36. package/dist/src/core/textures/ImageTexture.js.map +1 -1
  37. package/dist/src/core/textures/SubTexture.d.ts +1 -0
  38. package/dist/src/core/textures/SubTexture.js +12 -0
  39. package/dist/src/core/textures/SubTexture.js.map +1 -1
  40. package/dist/src/core/textures/Texture.js +10 -0
  41. package/dist/src/core/textures/Texture.js.map +1 -1
  42. package/dist/src/main-api/Renderer.d.ts +16 -0
  43. package/dist/src/main-api/Renderer.js +2 -0
  44. package/dist/src/main-api/Renderer.js.map +1 -1
  45. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  46. package/package.json +1 -1
  47. package/src/core/CoreNode.ts +8 -23
  48. package/src/core/Stage.ts +24 -47
  49. package/src/core/TextureMemoryManager.ts +4 -3
  50. package/src/core/lib/utils.ts +0 -4
  51. package/src/core/platforms/web/WebPlatform.outOfMemory.test.ts +1 -0
  52. package/src/core/platforms/web/WebPlatform.ts +1 -0
  53. package/src/core/renderers/CoreShaderNode.test.ts +53 -0
  54. package/src/core/renderers/CoreShaderNode.ts +23 -0
  55. package/src/core/renderers/canvas/CanvasRenderer.test.ts +34 -0
  56. package/src/core/renderers/canvas/CanvasRenderer.ts +33 -4
  57. package/src/core/renderers/canvas/CanvasShaderNode.ts +0 -1
  58. package/src/core/renderers/canvas/CanvasTexture.test.ts +31 -0
  59. package/src/core/renderers/canvas/CanvasTexture.ts +35 -7
  60. package/src/core/renderers/webgl/WebGlShaderNode.ts +0 -1
  61. package/src/core/text-rendering/CanvasTextRenderer.ts +26 -0
  62. package/src/core/text-rendering/SdfTextRenderer.test.ts +133 -0
  63. package/src/core/text-rendering/SdfTextRenderer.ts +30 -0
  64. package/src/core/text-rendering/TextRenderer.ts +7 -0
  65. package/src/core/textures/ImageTexture.ts +18 -0
  66. package/src/core/textures/SubTexture.test.ts +45 -0
  67. package/src/core/textures/SubTexture.ts +13 -0
  68. package/src/core/textures/Texture.ts +12 -0
  69. package/src/main-api/Renderer.ts +19 -0
@@ -63,7 +63,19 @@ export class CanvasRenderer extends CoreRenderer {
63
63
  }
64
64
 
65
65
  const hasTransform = ta !== 1;
66
- const hasClipping = clippingRect.w !== 0 && clippingRect.h !== 0;
66
+ const clippingValid = clippingRect.valid === true;
67
+
68
+ // If the clipping rect is valid but zero-area, the node is fully clipped — skip rendering
69
+ if (
70
+ clippingValid === true &&
71
+ clippingRect.w === 0 &&
72
+ clippingRect.h === 0
73
+ ) {
74
+ return;
75
+ }
76
+
77
+ const hasClipping =
78
+ clippingValid === true && clippingRect.w !== 0 && clippingRect.h !== 0;
67
79
  const shader = node.props.shader;
68
80
  const hasShader = shader !== null;
69
81
 
@@ -124,7 +136,7 @@ export class CanvasRenderer extends CoreRenderer {
124
136
 
125
137
  if (textureType !== TextureType.color) {
126
138
  const tintColor = parseColor(color);
127
- let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement;
139
+ let image: ImageBitmap | HTMLCanvasElement | HTMLImageElement | null;
128
140
 
129
141
  if (textureType === TextureType.subTexture) {
130
142
  image = (
@@ -134,12 +146,29 @@ export class CanvasRenderer extends CoreRenderer {
134
146
  image = (texture.ctxTexture as CanvasTexture).getImage(tintColor);
135
147
  }
136
148
 
149
+ // The texture can disappear while an async load/fetch is racing (e.g. 404);
150
+ // skip drawing this frame instead of dereferencing an invalid image object.
151
+ if (image === null || image === undefined) {
152
+ return;
153
+ }
154
+
155
+ const imageWidth = image.width;
156
+ const imageHeight = image.height;
157
+ if (
158
+ typeof imageWidth !== 'number' ||
159
+ typeof imageHeight !== 'number' ||
160
+ imageWidth <= 0 ||
161
+ imageHeight <= 0
162
+ ) {
163
+ return;
164
+ }
165
+
137
166
  this.context.globalAlpha = tintColor.a ?? node.worldAlpha;
138
167
 
139
168
  const txCoords = node.textureCoords;
140
169
  if (txCoords) {
141
- const ix = image.width;
142
- const iy = image.height;
170
+ const ix = imageWidth;
171
+ const iy = imageHeight;
143
172
 
144
173
  let sx = txCoords.x1 * ix;
145
174
  let sy = txCoords.y1 * iy;
@@ -26,7 +26,6 @@ export class CanvasShaderNode<
26
26
  > extends CoreShaderNode<Props> {
27
27
  private updater: ((node: CoreNode, props?: Props) => void) | undefined =
28
28
  undefined;
29
- private valueKey: string = '';
30
29
  computed: Partial<Computed> = {};
31
30
  applySNR: boolean;
32
31
  render: CanvasShaderType<Props>['render'];
@@ -0,0 +1,31 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { CanvasTexture } from './CanvasTexture.js';
3
+
4
+ describe('CanvasTexture.load', () => {
5
+ it('fails gracefully when textureData.data is null', async () => {
6
+ const textureSource = {
7
+ textureData: { data: null },
8
+ state: 'initial',
9
+ dimensions: null,
10
+ setState(nextState: string) {
11
+ this.state = nextState;
12
+ },
13
+ freeTextureData() {
14
+ // no-op
15
+ },
16
+ } as any;
17
+
18
+ const memManager = {
19
+ setTextureMemUse() {
20
+ // no-op
21
+ },
22
+ } as any;
23
+
24
+ const ctxTexture = new CanvasTexture(memManager, textureSource);
25
+
26
+ await expect(ctxTexture.load()).rejects.toThrow(
27
+ 'CanvasTexture: Texture data is null',
28
+ );
29
+ expect(textureSource.state).toBe('failed');
30
+ });
31
+ });
@@ -2,6 +2,7 @@ import type { Dimensions } from '../../../common/CommonTypes.js';
2
2
  import { assertTruthy } from '../../../utils.js';
3
3
  import { formatRgba, type IParsedColor } from '../../lib/colorParser.js';
4
4
  import { CoreContextTexture } from '../CoreContextTexture.js';
5
+ import type { Texture } from '../../textures/Texture.js';
5
6
 
6
7
  export class CanvasTexture extends CoreContextTexture {
7
8
  protected image:
@@ -17,10 +18,23 @@ export class CanvasTexture extends CoreContextTexture {
17
18
  | undefined;
18
19
 
19
20
  async load(): Promise<void> {
21
+ // Capture textureData synchronously before any await - a pending
22
+ // freeTextureDataTask microtask could null textureSource.textureData
23
+ // during the first async suspension, causing onLoadRequest to fail.
24
+ const textureData = this.textureSource.textureData;
25
+ assertTruthy(textureData?.data, 'Texture data is null before load');
26
+
20
27
  this.textureSource.setState('loading');
21
28
 
22
29
  try {
23
- const size = await this.onLoadRequest();
30
+ const size = await this.onLoadRequest(textureData.data);
31
+
32
+ // Guard against the texture being freed while the load was in flight
33
+ if (this.textureSource.state === 'freed') {
34
+ this.image = undefined;
35
+ return;
36
+ }
37
+
24
38
  this.textureSource.setState('loaded', size);
25
39
  this.textureSource.freeTextureData();
26
40
  this.updateMemSize();
@@ -63,9 +77,11 @@ export class CanvasTexture extends CoreContextTexture {
63
77
 
64
78
  getImage(
65
79
  color: IParsedColor,
66
- ): ImageBitmap | HTMLCanvasElement | HTMLImageElement {
80
+ ): ImageBitmap | HTMLCanvasElement | HTMLImageElement | null {
67
81
  const image = this.image;
68
- assertTruthy(image, 'Attempt to get unloaded image texture');
82
+ if (image === undefined) {
83
+ return null;
84
+ }
69
85
 
70
86
  if (color.isWhite) {
71
87
  if (this.tintCache) {
@@ -114,9 +130,21 @@ export class CanvasTexture extends CoreContextTexture {
114
130
  return canvas;
115
131
  }
116
132
 
117
- private async onLoadRequest(): Promise<Dimensions> {
118
- assertTruthy(this.textureSource?.textureData?.data, 'Texture data is null');
119
- const { data } = this.textureSource.textureData;
133
+ private async onLoadRequest(
134
+ data: NonNullable<Texture['textureData']>['data'],
135
+ ): Promise<Dimensions> {
136
+ if (data === null) {
137
+ throw new Error('CanvasTexture: Texture data is null');
138
+ }
139
+
140
+ // CompressedData objects (KTX, PVR, ASTC) carry GPU-format mipmap buffers
141
+ // that cannot be decoded by Canvas2D. Reject explicitly rather than falling
142
+ // through silently and leaving this.image unassigned.
143
+ if (typeof data === 'object' && 'mipmaps' in data) {
144
+ throw new Error(
145
+ 'CanvasTexture: Compressed texture data is not supported in Canvas2D render mode',
146
+ );
147
+ }
120
148
 
121
149
  // TODO: canvas from text renderer should be able to provide the canvas directly
122
150
  // instead of having to re-draw it into a new canvas...
@@ -125,7 +153,7 @@ export class CanvasTexture extends CoreContextTexture {
125
153
  canvas.width = data.width;
126
154
  canvas.height = data.height;
127
155
  const ctx = canvas.getContext('2d');
128
- if (ctx) ctx.putImageData(data, 0, 0);
156
+ if (ctx !== null) ctx.putImageData(data, 0, 0);
129
157
  this.image = canvas;
130
158
  return { w: data.width, h: data.height };
131
159
  } else if (
@@ -53,7 +53,6 @@ export class WebGlShaderNode<
53
53
  readonly program: WebGlShaderProgram;
54
54
  private updater: ((node: CoreNode, props?: Props) => void) | undefined =
55
55
  undefined;
56
- private valueKey: string = '';
57
56
  uniforms: UniformCollection = {
58
57
  single: {},
59
58
  vec2: {},
@@ -40,10 +40,22 @@ const layoutCache = new Map<
40
40
  }
41
41
  >();
42
42
 
43
+ // Upper bound on layoutCache entries, enforced on idle via `cleanup`.
44
+ // Overridden from stage options in `init`. Note: the Canvas path does not
45
+ // currently populate `layoutCache`, so this is effectively inert today and
46
+ // exists to keep the eviction policy uniform with the SDF backend should
47
+ // Canvas layout caching be wired up later.
48
+ let maxLayoutCacheSize = 250;
49
+
43
50
  // Initialize the Text Renderer
44
51
  const init = (stage: Stage): void => {
45
52
  const dpr = stage.options.devicePhysicalPixelRatio;
46
53
 
54
+ const configuredCacheSize = stage.options.textLayoutCacheSize;
55
+ if (configuredCacheSize !== undefined) {
56
+ maxLayoutCacheSize = configuredCacheSize;
57
+ }
58
+
47
59
  // Drawing canvas and context
48
60
  canvas = stage.platform.createCanvas() as HTMLCanvasElement | OffscreenCanvas;
49
61
  context = canvas.getContext('2d', { willReadFrequently: true }) as
@@ -224,6 +236,19 @@ const clearLayoutCache = (): void => {
224
236
  layoutCache.clear();
225
237
  };
226
238
 
239
+ /**
240
+ * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the
241
+ * least-recently-used entries first. Called when the stage goes idle. The
242
+ * Canvas path does not currently populate `layoutCache`, so this is a no-op in
243
+ * practice today; it mirrors the SDF backend's eviction policy.
244
+ */
245
+ const cleanup = (): void => {
246
+ while (layoutCache.size > maxLayoutCacheSize) {
247
+ const oldest = layoutCache.keys().next().value as string;
248
+ layoutCache.delete(oldest);
249
+ }
250
+ };
251
+
227
252
  /**
228
253
  * Add quads for rendering (Canvas doesn't use quads)
229
254
  */
@@ -252,6 +277,7 @@ const CanvasTextRenderer = {
252
277
  renderQuads,
253
278
  init,
254
279
  clearLayoutCache,
280
+ cleanup,
255
281
  };
256
282
 
257
283
  export default CanvasTextRenderer;
@@ -0,0 +1,133 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import type { CoreTextNodeProps } from '../CoreTextNode.js';
3
+
4
+ // Mock the font handler so renderText/generateTextLayout can run without a
5
+ // real loaded font. getFontData is only invoked on a layout-cache MISS, so the
6
+ // call count is our probe for cache hits vs misses.
7
+ vi.mock('./SdfFontHandler.js', () => {
8
+ const fontData = {
9
+ data: {
10
+ common: { base: 0, scaleW: 512, scaleH: 512, lineHeight: 50 },
11
+ info: { size: 42 },
12
+ distanceField: { distanceRange: 4 },
13
+ },
14
+ glyphMap: new Map(),
15
+ kernings: {},
16
+ atlasTexture: {},
17
+ metrics: {},
18
+ maxCharHeight: 50,
19
+ };
20
+ const metrics = {
21
+ ascender: 40,
22
+ descender: -10,
23
+ lineGap: 0,
24
+ capHeight: 30,
25
+ xHeight: 20,
26
+ };
27
+ return {
28
+ type: 'sdf',
29
+ init: vi.fn(),
30
+ getFontData: vi.fn(() => fontData),
31
+ getFontMetrics: vi.fn(() => metrics),
32
+ measureText: vi.fn((text: string) => text.length * 10),
33
+ getAtlas: vi.fn(() => null),
34
+ };
35
+ });
36
+
37
+ import SdfTextRenderer from './SdfTextRenderer.js';
38
+ import * as SdfFontHandler from './SdfFontHandler.js';
39
+
40
+ const makeProps = (text: string): CoreTextNodeProps =>
41
+ ({
42
+ text,
43
+ fontFamily: 'Test',
44
+ fontStyle: 'normal',
45
+ fontSize: 42,
46
+ letterSpacing: 0,
47
+ lineHeight: 0,
48
+ maxHeight: 0,
49
+ maxWidth: 100000,
50
+ maxLines: 0,
51
+ textAlign: 'left',
52
+ wordBreak: 'normal',
53
+ overflowSuffix: '',
54
+ } as unknown as CoreTextNodeProps);
55
+
56
+ const initRenderer = (cacheSize: number): void => {
57
+ const fakeStage = {
58
+ options: { textLayoutCacheSize: cacheSize },
59
+ shManager: {
60
+ registerShaderType: vi.fn(),
61
+ createShader: vi.fn(() => ({})),
62
+ },
63
+ };
64
+ SdfTextRenderer.init(fakeStage as never);
65
+ };
66
+
67
+ const render = (text: string): void => {
68
+ SdfTextRenderer.renderText(makeProps(text));
69
+ };
70
+
71
+ describe('SdfTextRenderer layout cache', () => {
72
+ beforeEach(() => {
73
+ // Empty the module-level cache between tests, then reset call counts.
74
+ initRenderer(0);
75
+ SdfTextRenderer.cleanup();
76
+ vi.clearAllMocks();
77
+ });
78
+
79
+ it('reuses the cached layout for identical strings', () => {
80
+ initRenderer(10);
81
+
82
+ render('Badge');
83
+ render('Badge');
84
+
85
+ // Second render is a cache hit: no fresh layout generation.
86
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
87
+ });
88
+
89
+ it('caches long strings too (no length-based skip)', () => {
90
+ initRenderer(10);
91
+ const long = 'x'.repeat(500);
92
+
93
+ render(long);
94
+ render(long);
95
+
96
+ // Bounded purely by the LRU cap, not by length.
97
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
98
+ });
99
+
100
+ it('cleanup trims to the cap and evicts least-recently-used first', () => {
101
+ initRenderer(2);
102
+
103
+ render('A');
104
+ render('B');
105
+ render('C');
106
+ // Re-access 'A' so it becomes most-recently-used; 'B' is now the LRU.
107
+ render('A');
108
+
109
+ SdfTextRenderer.cleanup();
110
+
111
+ vi.clearAllMocks();
112
+ render('B'); // evicted -> miss
113
+ render('A'); // survived -> hit
114
+ render('C'); // survived -> hit
115
+
116
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(1);
117
+ });
118
+
119
+ it('cleanup is a no-op while under the cap', () => {
120
+ initRenderer(10);
121
+
122
+ render('one');
123
+ render('two');
124
+
125
+ SdfTextRenderer.cleanup();
126
+
127
+ vi.clearAllMocks();
128
+ render('one'); // still cached -> hit
129
+ render('two'); // still cached -> hit
130
+
131
+ expect(SdfFontHandler.getFontData).toHaveBeenCalledTimes(0);
132
+ });
133
+ });
@@ -24,10 +24,20 @@ const type = 'sdf' as const;
24
24
 
25
25
  let sdfShader: WebGlShaderNode | null = null;
26
26
 
27
+ // Upper bound on layoutCache entries, enforced on idle via `cleanup`.
28
+ // Overridden from stage options in `init`. The cache is allowed to grow past
29
+ // this during active rendering and is trimmed back to it when the stage idles.
30
+ let maxLayoutCacheSize = 250;
31
+
27
32
  // Initialize the SDF text renderer
28
33
  const init = (stage: Stage): void => {
29
34
  SdfFontHandler.init();
30
35
 
36
+ const configuredCacheSize = stage.options.textLayoutCacheSize;
37
+ if (configuredCacheSize !== undefined) {
38
+ maxLayoutCacheSize = configuredCacheSize;
39
+ }
40
+
31
41
  // Register SDF shader with the shader manager
32
42
  stage.shManager.registerShaderType('Sdf', Sdf);
33
43
  sdfShader = stage.shManager.createShader('Sdf') as WebGlShaderNode;
@@ -58,6 +68,11 @@ const renderText = (props: CoreTextNodeProps): TextRenderInfo => {
58
68
  const cacheKey = getLayoutCacheKey(props);
59
69
  let layout = layoutCache.get(cacheKey);
60
70
  if (layout !== undefined) {
71
+ // Refresh LRU recency: re-insert moves the key to the most-recently-used
72
+ // end so idle `cleanup` evicts genuinely cold entries first. renderText
73
+ // runs on text/layout change, not per frame, so this re-insert is cheap.
74
+ layoutCache.delete(cacheKey);
75
+ layoutCache.set(cacheKey, layout);
61
76
  return {
62
77
  remainingLines: 0,
63
78
  hasRemainingText: false,
@@ -357,6 +372,20 @@ const generateTextLayout = (
357
372
  };
358
373
  };
359
374
 
375
+ /**
376
+ * Trim the layout cache back down to `maxLayoutCacheSize`, evicting the
377
+ * least-recently-used entries first. Called when the stage goes idle so this
378
+ * never competes with active rendering. A fresh iterator is taken each step so
379
+ * we always delete the current front (oldest) key without iterator-invalidation
380
+ * concerns; this runs at most once per idle transition and only when over cap.
381
+ */
382
+ const cleanup = (): void => {
383
+ while (layoutCache.size > maxLayoutCacheSize) {
384
+ const oldest = layoutCache.keys().next().value as string;
385
+ layoutCache.delete(oldest);
386
+ }
387
+ };
388
+
360
389
  /**
361
390
  * SDF Text Renderer - implements TextRenderer interface
362
391
  */
@@ -367,6 +396,7 @@ const SdfTextRenderer = {
367
396
  addQuads,
368
397
  renderQuads,
369
398
  init,
399
+ cleanup,
370
400
  };
371
401
 
372
402
  export default SdfTextRenderer;
@@ -435,6 +435,13 @@ export interface TextRenderer {
435
435
  renderProps: TextRenderProps,
436
436
  ) => void | SdfRenderOp | null;
437
437
  init: (stage: Stage) => void;
438
+ /**
439
+ * Trim internal caches back down to their configured limits.
440
+ * Called when the stage goes idle so cache eviction never competes with
441
+ * active rendering. Backends with no bounded cache may implement this as a
442
+ * no-op.
443
+ */
444
+ cleanup: () => void;
438
445
  }
439
446
 
440
447
  /**
@@ -269,6 +269,24 @@ export class ImageTexture extends Texture {
269
269
  }
270
270
 
271
271
  override async getTextureSource(): Promise<TextureData> {
272
+ // Compressed textures are not supported by the Canvas2D renderer.
273
+ // Fail fast here before incurring a network fetch or binary decode.
274
+ if (this.txManager.renderer?.mode === 'canvas') {
275
+ const { src, type } = this.props;
276
+ if (
277
+ type === 'compressed' ||
278
+ (typeof src === 'string' && isCompressedTextureContainer(src) === true)
279
+ ) {
280
+ const err = new Error(
281
+ `ImageTexture: Compressed textures are not supported in Canvas2D render mode (src: ${String(
282
+ src,
283
+ )})`,
284
+ );
285
+ this.setState('failed', err);
286
+ return { data: null };
287
+ }
288
+ }
289
+
272
290
  let resp: TextureData;
273
291
  try {
274
292
  resp = await this.determineImageTypeAndLoadImage();
@@ -0,0 +1,45 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { SubTexture } from './SubTexture.js';
3
+ import { ImageTexture } from './ImageTexture.js';
4
+ import type { CoreTextureManager } from '../CoreTextureManager.js';
5
+
6
+ const flushMicrotasks = () => Promise.resolve();
7
+
8
+ describe('SubTexture lifecycle', () => {
9
+ it('detaches its parent-texture listeners on destroy', async () => {
10
+ // 'initial' state means the constructor's microtask attaches listeners
11
+ // without synchronously firing any of the state handlers.
12
+ const parent = {
13
+ state: 'initial',
14
+ dimensions: null,
15
+ error: null,
16
+ on: vi.fn(),
17
+ off: vi.fn(),
18
+ };
19
+ const txManager = {
20
+ maxRetryCount: 0,
21
+ platform: {},
22
+ resolveParentTexture: () => parent,
23
+ } as unknown as CoreTextureManager;
24
+
25
+ const parentImage = new ImageTexture(txManager, {} as never);
26
+ const sub = new SubTexture(txManager, {
27
+ texture: parentImage,
28
+ x: 0,
29
+ y: 0,
30
+ w: 10,
31
+ h: 10,
32
+ });
33
+
34
+ // Listeners are attached in a microtask after construction.
35
+ await flushMicrotasks();
36
+ expect(parent.on).toHaveBeenCalledTimes(4);
37
+
38
+ sub.destroy();
39
+
40
+ const offEvents = (parent.off.mock.calls as Array<[string]>)
41
+ .map((c) => c[0])
42
+ .sort();
43
+ expect(offEvents).toEqual(['failed', 'freed', 'loaded', 'loading']);
44
+ });
45
+ });
@@ -133,6 +133,19 @@ export class SubTexture extends Texture {
133
133
  this.parentTexture.setRenderableOwner(this.subtextureId, isRenderable);
134
134
  }
135
135
 
136
+ override destroy(): void {
137
+ // Detach from the parent texture's event emitter. The parent is typically a
138
+ // long-lived shared atlas (preventCleanup), so without this each destroyed
139
+ // SubTexture — and everything its handlers capture — would be retained by
140
+ // the parent's listener lists for the rest of the session.
141
+ const parentTx = this.parentTexture;
142
+ parentTx.off('loading', this.onParentTxLoading);
143
+ parentTx.off('loaded', this.onParentTxLoaded);
144
+ parentTx.off('failed', this.onParentTxFailed);
145
+ parentTx.off('freed', this.onParentTxFreed);
146
+ super.destroy();
147
+ }
148
+
136
149
  override async getTextureSource(): Promise<TextureData> {
137
150
  // Check if parent texture is loaded
138
151
  return new Promise((resolve, reject) => {
@@ -240,6 +240,11 @@ export abstract class Texture extends EventEmitter {
240
240
  return false;
241
241
  }
242
242
 
243
+ // Don't cleanup a texture that is in the process of loading
244
+ if (this.state === 'loading') {
245
+ return false;
246
+ }
247
+
243
248
  // Don't cleanup if not renderable
244
249
  if (this.renderable === true) {
245
250
  return false;
@@ -341,6 +346,9 @@ export abstract class Texture extends EventEmitter {
341
346
  */
342
347
  free(): void {
343
348
  this.ctxTexture?.free();
349
+ // Null out the freed ctxTexture so a subsequent reload re-creates it via
350
+ // getCtxTexture() instead of reusing a stale, already-freed reference.
351
+ this.ctxTexture = undefined;
344
352
  }
345
353
 
346
354
  /**
@@ -370,6 +378,10 @@ export abstract class Texture extends EventEmitter {
370
378
 
371
379
  // Always free texture data regardless of state
372
380
  this.freeTextureData();
381
+
382
+ // Drop any remaining subscribers so a texture destroyed while something
383
+ // still holds a listener does not retain it (and its captures).
384
+ this.removeAllListeners();
373
385
  }
374
386
 
375
387
  /**
@@ -393,6 +393,23 @@ export interface RendererRuntimeSettings {
393
393
  * Configuration settings for {@link RendererMain}
394
394
  */
395
395
  export type RendererMainSettings = RendererRuntimeSettings & {
396
+ /**
397
+ * Maximum number of entries kept in the SDF text layout cache
398
+ *
399
+ * @remarks
400
+ * The SDF text renderer caches the computed glyph layout for a given
401
+ * `text` + font + layout-prop combination so that identical strings (e.g.
402
+ * repeated badges/labels) are not re-laid-out. The cache is content-keyed
403
+ * and shared across nodes, and is trimmed down to this many (most recently
404
+ * used) entries whenever the stage goes idle.
405
+ *
406
+ * Set this higher for content-dense UIs with many simultaneous unique
407
+ * strings, or lower to cap memory more aggressively.
408
+ *
409
+ * @defaultValue `250`
410
+ */
411
+ textLayoutCacheSize: number;
412
+
396
413
  /**
397
414
  * Include context call (i.e. WebGL) information in FPS updates
398
415
  *
@@ -678,6 +695,7 @@ export class RendererMain extends EventEmitter {
678
695
  fpsUpdateInterval: settings.fpsUpdateInterval || 0,
679
696
  enableClear: settings.enableClear ?? true,
680
697
  targetFPS: settings.targetFPS || 0,
698
+ textLayoutCacheSize: settings.textLayoutCacheSize ?? 250,
681
699
  numImageWorkers:
682
700
  settings.numImageWorkers !== undefined ? settings.numImageWorkers : 2,
683
701
  enableContextSpy: settings.enableContextSpy ?? false,
@@ -755,6 +773,7 @@ export class RendererMain extends EventEmitter {
755
773
  textBaselineMode: settings.textBaselineMode!,
756
774
  inspector: settings.inspector !== null,
757
775
  targetFPS: settings.targetFPS!,
776
+ textLayoutCacheSize: settings.textLayoutCacheSize!,
758
777
  textureProcessingTimeLimit: settings.textureProcessingTimeLimit!,
759
778
  createImageBitmapSupport: settings.createImageBitmapSupport!,
760
779
  premultiplyAlphaHonored: settings.premultiplyAlphaHonored,