@solidtv/renderer 1.5.0 → 1.5.1

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 (37) hide show
  1. package/dist/src/common/EventEmitter.d.ts +8 -0
  2. package/dist/src/common/EventEmitter.js +20 -0
  3. package/dist/src/common/EventEmitter.js.map +1 -1
  4. package/dist/src/core/Autosizer.js +3 -1
  5. package/dist/src/core/Autosizer.js.map +1 -1
  6. package/dist/src/core/CoreNode.d.ts +39 -0
  7. package/dist/src/core/CoreNode.js +105 -25
  8. package/dist/src/core/CoreNode.js.map +1 -1
  9. package/dist/src/core/CoreTextNode.js +4 -2
  10. package/dist/src/core/CoreTextNode.js.map +1 -1
  11. package/dist/src/core/Stage.js +2 -0
  12. package/dist/src/core/Stage.js.map +1 -1
  13. package/dist/src/core/TextureMemoryManager.d.ts +39 -2
  14. package/dist/src/core/TextureMemoryManager.js +53 -1
  15. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  16. package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
  17. package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
  18. package/dist/src/core/renderers/webgl/WebGlRenderer.js +5 -1
  19. package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
  20. package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
  21. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
  22. package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
  23. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  24. package/package.json +1 -1
  25. package/src/common/EventEmitter.ts +21 -0
  26. package/src/core/Autosizer.ts +3 -1
  27. package/src/core/CoreNode.test.ts +283 -0
  28. package/src/core/CoreNode.ts +131 -26
  29. package/src/core/CoreTextNode.test.ts +1 -0
  30. package/src/core/CoreTextNode.ts +6 -2
  31. package/src/core/Stage.ts +2 -0
  32. package/src/core/TextureMemoryManager.test.ts +272 -5
  33. package/src/core/TextureMemoryManager.ts +59 -2
  34. package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
  35. package/src/core/renderers/webgl/WebGlRenderer.ts +6 -1
  36. package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
  37. package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
@@ -392,6 +392,23 @@ export interface CoreNodeProps {
392
392
  * rendering.
393
393
  */
394
394
  colorBl: number;
395
+ /**
396
+ * Placeholder color shown while the Node's texture is not yet loaded.
397
+ *
398
+ * @remarks
399
+ * When set to a non-zero color and the Node has a texture (e.g. {@link src}),
400
+ * the Node renders a solid rectangle of this color until the texture
401
+ * finishes loading, instead of rendering nothing. The placeholder renders
402
+ * through the Node's shader, so rounded corners and borders apply to it.
403
+ * It also shows again if the texture is freed under memory pressure (until
404
+ * the reload completes) and remains if the texture permanently fails.
405
+ *
406
+ * The color value is a number in the format 0xRRGGBBAA. Set to `0` to
407
+ * disable (default). Has no effect on Nodes without a texture.
408
+ *
409
+ * @default `0`
410
+ */
411
+ placeholderColor: number;
395
412
  /**
396
413
  * The Node's parent Node.
397
414
  *
@@ -793,6 +810,15 @@ export class CoreNode extends EventEmitter {
793
810
  private hasColorProps = false;
794
811
  public textureLoaded = false;
795
812
 
813
+ /**
814
+ * True while this node should render its `placeholderColor` instead of its
815
+ * texture: `placeholderColor` is non-zero, a texture is set, and that
816
+ * texture is not loaded. Read by the renderers' quad path to substitute the
817
+ * stage's default (1x1 white) texture. Maintained by
818
+ * {@link updatePlaceholderActive} — never written elsewhere.
819
+ */
820
+ public placeholderActive = false;
821
+
796
822
  public updateType = UpdateType.All;
797
823
  public childUpdateType = UpdateType.None;
798
824
 
@@ -953,6 +979,31 @@ export class CoreNode extends EventEmitter {
953
979
  }
954
980
 
955
981
  //#region Textures
982
+ /**
983
+ * Recompute {@link placeholderActive} after any of its inputs changed
984
+ * (placeholderColor, texture, textureLoaded).
985
+ *
986
+ * @remarks
987
+ * On a toggle this raises `PremultipliedColors` (the quad's vertex colors
988
+ * switch between the placeholder color and the regular color props — this
989
+ * also marks the quad dirty) and `IsRenderable` (a loading texture with a
990
+ * placeholder is renderable). Both are processed in the same frame's update
991
+ * pass, before quads are submitted.
992
+ */
993
+ private updatePlaceholderActive(): void {
994
+ const active =
995
+ this.props.placeholderColor !== 0 &&
996
+ this.props.texture !== null &&
997
+ this.textureLoaded === false;
998
+
999
+ if (active !== this.placeholderActive) {
1000
+ this.placeholderActive = active;
1001
+ this.setUpdateType(
1002
+ UpdateType.PremultipliedColors | UpdateType.IsRenderable,
1003
+ );
1004
+ }
1005
+ }
1006
+
956
1007
  loadTexture(): void {
957
1008
  if (this.props.texture === null) {
958
1009
  return;
@@ -1023,6 +1074,7 @@ export class CoreNode extends EventEmitter {
1023
1074
  }
1024
1075
 
1025
1076
  this.textureLoaded = true;
1077
+ this.updatePlaceholderActive();
1026
1078
  this.setUpdateType(UpdateType.IsRenderable);
1027
1079
 
1028
1080
  // Texture was loaded. In case the RAF loop has already stopped, we request
@@ -1057,9 +1109,12 @@ export class CoreNode extends EventEmitter {
1057
1109
 
1058
1110
  private onTextureFailed: TextureFailedEventHandler = (_, error) => {
1059
1111
  // immediately set isRenderable to false, so that we handle the error
1060
- // without waiting for the next frame loop
1112
+ // without waiting for the next frame loop. With a placeholder set, the
1113
+ // same frame's update pass recomputes this to true and renders the
1114
+ // placeholder instead.
1061
1115
  this.textureLoaded = false;
1062
1116
  this.isRenderable = false;
1117
+ this.updatePlaceholderActive();
1063
1118
  this.updateTextureOwnership(false);
1064
1119
  this.setUpdateType(UpdateType.IsRenderable);
1065
1120
 
@@ -1081,9 +1136,12 @@ export class CoreNode extends EventEmitter {
1081
1136
 
1082
1137
  private onTextureFreed: TextureFreedEventHandler = () => {
1083
1138
  // immediately set isRenderable to false, so that we handle the error
1084
- // without waiting for the next frame loop
1139
+ // without waiting for the next frame loop. With a placeholder set, the
1140
+ // same frame's update pass recomputes this to true and renders the
1141
+ // placeholder while the texture reloads.
1085
1142
  this.textureLoaded = false;
1086
1143
  this.isRenderable = false;
1144
+ this.updatePlaceholderActive();
1087
1145
  this.updateTextureOwnership(false);
1088
1146
  this.setUpdateType(UpdateType.IsRenderable);
1089
1147
 
@@ -1355,7 +1413,13 @@ export class CoreNode extends EventEmitter {
1355
1413
  this.calculateRenderCoords();
1356
1414
  this.updateBoundingRect();
1357
1415
 
1358
- updateType |= UpdateType.RenderState | UpdateType.RecalcUniforms;
1416
+ // RecalcUniforms is intentionally NOT set here: shader uniforms are a
1417
+ // function of resolvedProps + w/h only (that is exactly the shader
1418
+ // value-key cache key), so pure transform changes (translate, scale,
1419
+ // rotate) cannot affect them. The flag is raised where w/h actually
1420
+ // change: the w/h setters, Autosizer.applyDimensions, text layout
1421
+ // application, and the shader setter itself.
1422
+ updateType |= UpdateType.RenderState;
1359
1423
 
1360
1424
  //only propagate children updates if not autosizing
1361
1425
  if ((updateType & UpdateType.Autosize) === 0) {
@@ -1420,27 +1484,39 @@ export class CoreNode extends EventEmitter {
1420
1484
  if (updateType & UpdateType.PremultipliedColors) {
1421
1485
  const alpha = this.worldAlpha;
1422
1486
 
1423
- const tl = props.colorTl;
1424
- const tr = props.colorTr;
1425
- const bl = props.colorBl;
1426
- const br = props.colorBr;
1427
-
1428
- // Fast equality check (covers all 4 corners)
1429
- const same = tl === tr && tl === bl && tl === br;
1430
-
1431
- const merged = premultiplyColorABGR(tl, alpha);
1432
-
1433
- this.premultipliedColorTl = merged;
1434
-
1435
- if (same === true) {
1436
- this.premultipliedColorTr =
1487
+ if (this.placeholderActive === true) {
1488
+ // Placeholder rendering: all four corners take the placeholder color.
1489
+ // The quad samples the stage's default 1x1 white texture, so this is
1490
+ // exactly the color-rect path.
1491
+ const merged = premultiplyColorABGR(props.placeholderColor, alpha);
1492
+ this.premultipliedColorTl =
1493
+ this.premultipliedColorTr =
1437
1494
  this.premultipliedColorBl =
1438
1495
  this.premultipliedColorBr =
1439
1496
  merged;
1440
1497
  } else {
1441
- this.premultipliedColorTr = premultiplyColorABGR(tr, alpha);
1442
- this.premultipliedColorBl = premultiplyColorABGR(bl, alpha);
1443
- this.premultipliedColorBr = premultiplyColorABGR(br, alpha);
1498
+ const tl = props.colorTl;
1499
+ const tr = props.colorTr;
1500
+ const bl = props.colorBl;
1501
+ const br = props.colorBr;
1502
+
1503
+ // Fast equality check (covers all 4 corners)
1504
+ const same = tl === tr && tl === bl && tl === br;
1505
+
1506
+ const merged = premultiplyColorABGR(tl, alpha);
1507
+
1508
+ this.premultipliedColorTl = merged;
1509
+
1510
+ if (same === true) {
1511
+ this.premultipliedColorTr =
1512
+ this.premultipliedColorBl =
1513
+ this.premultipliedColorBr =
1514
+ merged;
1515
+ } else {
1516
+ this.premultipliedColorTr = premultiplyColorABGR(tr, alpha);
1517
+ this.premultipliedColorBl = premultiplyColorABGR(bl, alpha);
1518
+ this.premultipliedColorBr = premultiplyColorABGR(br, alpha);
1519
+ }
1444
1520
  }
1445
1521
  }
1446
1522
 
@@ -1754,15 +1830,18 @@ export class CoreNode extends EventEmitter {
1754
1830
  // preemptive check for failed textures this will mark the current node as non-renderable
1755
1831
  // and will prevent further checks until the texture is reloaded or retry is reset on the texture
1756
1832
  if (this.texture.retryCount > this.texture.maxRetryCount) {
1757
- // texture has failed to load, we cannot render
1833
+ // texture has failed to load, we cannot render the texture itself —
1834
+ // but a placeholder color still renders in its place
1758
1835
  this.updateTextureOwnership(false);
1759
- this.setRenderable(false);
1836
+ this.setRenderable(this.placeholderActive);
1760
1837
  return;
1761
1838
  }
1762
1839
 
1763
1840
  needsTextureOwnership = true;
1764
- // Use cached boolean instead of string comparison
1765
- newIsRenderable = this.textureLoaded;
1841
+ // Use cached boolean instead of string comparison; a placeholder
1842
+ // renders while the texture is loading
1843
+ newIsRenderable =
1844
+ this.textureLoaded === true || this.placeholderActive === true;
1766
1845
  } else if (
1767
1846
  // check shader
1768
1847
  (this.props.shader !== this.stage.renderer.getDefaultShaderNode() ||
@@ -2049,6 +2128,9 @@ export class CoreNode extends EventEmitter {
2049
2128
  }
2050
2129
 
2051
2130
  get renderTexture(): Texture | null {
2131
+ if (this.placeholderActive === true) {
2132
+ return this.stage.defaultTexture;
2133
+ }
2052
2134
  return this.props.texture || this.stage.defaultTexture;
2053
2135
  }
2054
2136
 
@@ -2232,7 +2314,9 @@ export class CoreNode extends EventEmitter {
2232
2314
  const props = this.props;
2233
2315
  if (props.w !== value) {
2234
2316
  props.w = value;
2235
- let updateType = UpdateType.Local;
2317
+ // Dimensions feed shader uniforms (e.g. factored corner radius), so a
2318
+ // resize must recompute them; see the Global-update branch in update().
2319
+ let updateType = UpdateType.Local | UpdateType.RecalcUniforms;
2236
2320
 
2237
2321
  if (
2238
2322
  props.texture !== null &&
@@ -2262,7 +2346,9 @@ export class CoreNode extends EventEmitter {
2262
2346
  const props = this.props;
2263
2347
  if (props.h !== value) {
2264
2348
  props.h = value;
2265
- let updateType = UpdateType.Local;
2349
+ // Dimensions feed shader uniforms (e.g. factored corner radius), so a
2350
+ // resize must recompute them; see the Global-update branch in update().
2351
+ let updateType = UpdateType.Local | UpdateType.RecalcUniforms;
2266
2352
 
2267
2353
  if (
2268
2354
  props.texture !== null &&
@@ -2514,6 +2600,24 @@ export class CoreNode extends EventEmitter {
2514
2600
  this.setUpdateType(UpdateType.PremultipliedColors);
2515
2601
  }
2516
2602
 
2603
+ get placeholderColor(): number {
2604
+ return this.props.placeholderColor;
2605
+ }
2606
+
2607
+ set placeholderColor(value: number) {
2608
+ const p = this.props;
2609
+ if (p.placeholderColor === value) return;
2610
+
2611
+ p.placeholderColor = value;
2612
+ this.updatePlaceholderActive();
2613
+
2614
+ // If the placeholder is (still) showing, the new color must reach the
2615
+ // quad buffer even though the active state did not toggle.
2616
+ if (this.placeholderActive === true) {
2617
+ this.setUpdateType(UpdateType.PremultipliedColors);
2618
+ }
2619
+ }
2620
+
2517
2621
  get colorTop(): number {
2518
2622
  return this.props.colorTop;
2519
2623
  }
@@ -2934,6 +3038,7 @@ export class CoreNode extends EventEmitter {
2934
3038
  this.textureCoords = undefined;
2935
3039
  this.props.texture = value;
2936
3040
  this.textureLoaded = value !== null && value.state === 'loaded';
3041
+ this.updatePlaceholderActive();
2937
3042
 
2938
3043
  if (value !== null) {
2939
3044
  if (this.autosizer !== null) {
@@ -28,6 +28,7 @@ const defaultProps = (
28
28
  colorTl: 0xffffffff,
29
29
  colorTop: 0xffffffff,
30
30
  colorTr: 0xffffffff,
31
+ placeholderColor: 0,
31
32
  h: 0,
32
33
  mount: 0,
33
34
  mountX: 0,
@@ -271,8 +271,12 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
271
271
  this.props.w = width;
272
272
  this.props.h = height;
273
273
 
274
- // Text dimensions may have changed, recalculate transforms and bounds
275
- this.setUpdateType(UpdateType.Local | UpdateType.RenderBounds);
274
+ // Text dimensions may have changed, recalculate transforms and bounds.
275
+ // RecalcUniforms because the direct props.w/h write above bypasses the
276
+ // w/h setters and dimensions feed shader uniforms.
277
+ this.setUpdateType(
278
+ UpdateType.Local | UpdateType.RenderBounds | UpdateType.RecalcUniforms,
279
+ );
276
280
 
277
281
  // Handle SDF renderer (uses layout caching)
278
282
  if (textRendererType === 'sdf') {
package/src/core/Stage.ts CHANGED
@@ -382,6 +382,7 @@ export class Stage {
382
382
  colorRight: 0x00000000,
383
383
  colorTl: 0x00000000,
384
384
  colorTr: 0x00000000,
385
+ placeholderColor: 0x00000000,
385
386
  colorBl: 0x00000000,
386
387
  colorBr: 0x00000000,
387
388
  zIndex: 0,
@@ -1043,6 +1044,7 @@ export class Stage {
1043
1044
  colorTr,
1044
1045
  colorBl,
1045
1046
  colorBr,
1047
+ placeholderColor: props.placeholderColor ?? 0,
1046
1048
  zIndex: props.zIndex ?? 0,
1047
1049
  parent: props.parent ?? null,
1048
1050
  texture: props.texture ?? null,
@@ -5,6 +5,7 @@ import {
5
5
  } from './TextureMemoryManager.js';
6
6
  import type { Stage } from './Stage.js';
7
7
  import { TextureType, type Texture } from './textures/Texture.js';
8
+ import { EventEmitter } from '../common/EventEmitter.js';
8
9
 
9
10
  function makeSettings(
10
11
  overrides: Partial<TextureMemoryManagerSettings> = {},
@@ -20,23 +21,38 @@ function makeSettings(
20
21
  };
21
22
  }
22
23
 
23
- // The only Stage method the OOM path touches is queueFrameEvent.
24
+ // The OOM path touches queueFrameEvent; cleanup() additionally sweeps the
25
+ // texture manager's keyCache and evicts orphans via removeTextureFromCache.
24
26
  function makeStage(): {
25
27
  stage: Stage;
26
28
  queueFrameEvent: ReturnType<typeof vi.fn>;
29
+ keyCache: Map<string, Texture>;
27
30
  } {
28
31
  const queueFrameEvent = vi.fn();
29
- const stage = { queueFrameEvent } as unknown as Stage;
30
- return { stage, queueFrameEvent };
32
+ const keyCache = new Map<string, Texture>();
33
+ const txManager = {
34
+ keyCache,
35
+ removeTextureFromCache: (texture: Texture) => {
36
+ // Mirrors CoreTextureManager.removeTextureFromCache
37
+ const cacheKey = texture.cacheKey;
38
+ if (cacheKey !== null) {
39
+ keyCache.delete(cacheKey);
40
+ texture.cacheKey = null;
41
+ }
42
+ },
43
+ };
44
+ const stage = { queueFrameEvent, txManager } as unknown as Stage;
45
+ return { stage, queueFrameEvent, keyCache };
31
46
  }
32
47
 
33
48
  function makeManager(overrides: Partial<TextureMemoryManagerSettings> = {}): {
34
49
  mgr: TextureMemoryManager;
35
50
  queueFrameEvent: ReturnType<typeof vi.fn>;
51
+ keyCache: Map<string, Texture>;
36
52
  } {
37
- const { stage, queueFrameEvent } = makeStage();
53
+ const { stage, queueFrameEvent, keyCache } = makeStage();
38
54
  const mgr = new TextureMemoryManager(stage, makeSettings(overrides));
39
- return { mgr, queueFrameEvent };
55
+ return { mgr, queueFrameEvent, keyCache };
40
56
  }
41
57
 
42
58
  // setTextureMemUse expects a Texture with a mutable memUsed field; nothing else
@@ -145,3 +161,254 @@ describe('TextureMemoryManager — cleanup is reversible', () => {
145
161
  expect(texture.memUsed).toBe(0);
146
162
  });
147
163
  });
164
+
165
+ // A cached, already-freed texture as left behind by a prior cleanup(): GPU and
166
+ // CPU data released (memUsed 0, not in loadedTextures), but the Texture object
167
+ // still sits in the keyCache. Built on a real EventEmitter so hasListeners()
168
+ // reflects actual on()/off() subscriptions, exactly like CoreNode's
169
+ // loadTextureTask/unloadTexture.
170
+ function freedCachedTexture(cacheKey: string): Texture & {
171
+ destroy: ReturnType<typeof vi.fn>;
172
+ } {
173
+ const texture = Object.assign(new EventEmitter(), {
174
+ memUsed: 0,
175
+ state: 'freed',
176
+ type: TextureType.image,
177
+ preventCleanup: false,
178
+ renderableOwners: [],
179
+ cacheKey,
180
+ free: vi.fn(),
181
+ destroy: vi.fn(),
182
+ canBeCleanedUp: () => true,
183
+ // Fresh texture by default — within the 2s startup grace period.
184
+ isWithinStartupGracePeriod: () => true,
185
+ });
186
+ return texture as unknown as Texture & {
187
+ destroy: ReturnType<typeof vi.fn>;
188
+ };
189
+ }
190
+
191
+ describe('TextureMemoryManager — orphaned freed texture eviction', () => {
192
+ it('keeps a freed texture that a node still references via listeners', () => {
193
+ const { mgr, keyCache } = makeManager();
194
+ const texture = freedCachedTexture('img:poster.png');
195
+ keyCache.set('img:poster.png', texture);
196
+ // CoreNode.loadTextureTask subscribes; the node is alive but offscreen.
197
+ texture.on('freed', () => {});
198
+
199
+ mgr.cleanup();
200
+
201
+ expect(texture.destroy).not.toHaveBeenCalled();
202
+ expect(keyCache.get('img:poster.png')).toBe(texture);
203
+ expect(texture.cacheKey).toBe('img:poster.png');
204
+ });
205
+
206
+ it('keeps a freed texture that still has renderable owners', () => {
207
+ const { mgr, keyCache } = makeManager();
208
+ const texture = freedCachedTexture('img:poster.png');
209
+ (texture.renderableOwners as unknown[]).push(1);
210
+ keyCache.set('img:poster.png', texture);
211
+
212
+ mgr.cleanup();
213
+
214
+ expect(texture.destroy).not.toHaveBeenCalled();
215
+ expect(keyCache.get('img:poster.png')).toBe(texture);
216
+ });
217
+
218
+ it('keeps a freed texture marked preventCleanup', () => {
219
+ const { mgr, keyCache } = makeManager();
220
+ const texture = freedCachedTexture('img:poster.png');
221
+ (texture as { preventCleanup: boolean }).preventCleanup = true;
222
+ keyCache.set('img:poster.png', texture);
223
+
224
+ mgr.cleanup();
225
+
226
+ expect(texture.destroy).not.toHaveBeenCalled();
227
+ expect(keyCache.get('img:poster.png')).toBe(texture);
228
+ });
229
+
230
+ it('does not evict loaded or in-flight cached textures, even aged orphans', () => {
231
+ const { mgr, keyCache } = makeManager();
232
+ const states = ['loaded', 'fetching', 'fetched', 'loading'];
233
+ const textures: ReturnType<typeof freedCachedTexture>[] = [];
234
+ for (const state of states) {
235
+ const texture = freedCachedTexture(`img:${state}.png`);
236
+ (texture as { state: string }).state = state;
237
+ // Aged out — eviction must be blocked by state alone.
238
+ (
239
+ texture as { isWithinStartupGracePeriod: () => boolean }
240
+ ).isWithinStartupGracePeriod = () => false;
241
+ keyCache.set(`img:${state}.png`, texture);
242
+ textures.push(texture);
243
+ }
244
+
245
+ mgr.cleanup();
246
+
247
+ for (const texture of textures) {
248
+ expect(texture.destroy).not.toHaveBeenCalled();
249
+ }
250
+ expect(keyCache.size).toBe(states.length);
251
+ });
252
+
253
+ it('keeps a fresh initial texture during the startup grace period', () => {
254
+ // A node created this same frame subscribes in a queued microtask, so a
255
+ // brand-new texture can look orphaned during a same-frame cleanup. The
256
+ // grace period is the race guard.
257
+ const { mgr, keyCache } = makeManager();
258
+ const texture = freedCachedTexture('img:fresh.png');
259
+ (texture as { state: string }).state = 'initial';
260
+
261
+ keyCache.set('img:fresh.png', texture);
262
+ mgr.cleanup();
263
+
264
+ expect(texture.destroy).not.toHaveBeenCalled();
265
+ expect(keyCache.get('img:fresh.png')).toBe(texture);
266
+ });
267
+
268
+ it('evicts an orphaned initial texture once the grace period expires', () => {
269
+ const { mgr, keyCache } = makeManager();
270
+ const texture = freedCachedTexture('img:never-loaded.png');
271
+ (texture as { state: string }).state = 'initial';
272
+ (
273
+ texture as { isWithinStartupGracePeriod: () => boolean }
274
+ ).isWithinStartupGracePeriod = () => false;
275
+
276
+ keyCache.set('img:never-loaded.png', texture);
277
+ mgr.cleanup();
278
+
279
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
280
+ expect(keyCache.has('img:never-loaded.png')).toBe(false);
281
+ expect(texture.cacheKey).toBe(null);
282
+ });
283
+
284
+ it('evicts an orphaned failed texture once the grace period expires', () => {
285
+ // Retry only happens through a node's 'failed' listener — with no
286
+ // listeners nothing will ever retry it.
287
+ const { mgr, keyCache } = makeManager();
288
+ const texture = freedCachedTexture('img:404.png');
289
+ (texture as { state: string }).state = 'failed';
290
+ (
291
+ texture as { isWithinStartupGracePeriod: () => boolean }
292
+ ).isWithinStartupGracePeriod = () => false;
293
+
294
+ keyCache.set('img:404.png', texture);
295
+ mgr.cleanup();
296
+
297
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
298
+ expect(keyCache.has('img:404.png')).toBe(false);
299
+ });
300
+
301
+ it('keeps an aged initial texture that a node references via listeners', () => {
302
+ const { mgr, keyCache } = makeManager();
303
+ const texture = freedCachedTexture('img:queued.png');
304
+ (texture as { state: string }).state = 'initial';
305
+ (
306
+ texture as { isWithinStartupGracePeriod: () => boolean }
307
+ ).isWithinStartupGracePeriod = () => false;
308
+ texture.on('loaded', () => {});
309
+
310
+ keyCache.set('img:queued.png', texture);
311
+ mgr.cleanup();
312
+
313
+ expect(texture.destroy).not.toHaveBeenCalled();
314
+ expect(keyCache.get('img:queued.png')).toBe(texture);
315
+ });
316
+
317
+ it('destroys and evicts an orphaned freed texture', () => {
318
+ const { mgr, keyCache } = makeManager();
319
+ const texture = freedCachedTexture('img:gone.png');
320
+ keyCache.set('img:gone.png', texture);
321
+
322
+ mgr.cleanup();
323
+
324
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
325
+ expect(keyCache.has('img:gone.png')).toBe(false);
326
+ expect(texture.cacheKey).toBe(null);
327
+ });
328
+
329
+ it('evicts only once the last listener is removed (node destroyed)', () => {
330
+ const { mgr, keyCache } = makeManager();
331
+ const texture = freedCachedTexture('img:row-item.png');
332
+ keyCache.set('img:row-item.png', texture);
333
+ const onFreed = () => {};
334
+ texture.on('freed', onFreed);
335
+
336
+ mgr.cleanup();
337
+ expect(texture.destroy).not.toHaveBeenCalled();
338
+ expect(keyCache.has('img:row-item.png')).toBe(true);
339
+
340
+ // Node destroyed: CoreNode.unloadTexture removes its subscriptions.
341
+ texture.off('freed', onFreed);
342
+
343
+ mgr.cleanup();
344
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
345
+ expect(keyCache.has('img:row-item.png')).toBe(false);
346
+ });
347
+
348
+ it('evicts orphans freed by the same cleanup pass', () => {
349
+ const { mgr, keyCache } = makeManager({ criticalThreshold: 200e6 });
350
+ // A loaded, cleanable texture whose free() transitions it to 'freed',
351
+ // mirroring ctxTexture.free() -> setState('freed'). No listeners and no
352
+ // owners: its node was already destroyed.
353
+ const texture = freedCachedTexture('img:orphan.png');
354
+ (texture as { state: string }).state = 'loaded';
355
+ (texture as { free: () => void }).free = () => {
356
+ (texture as { state: string }).state = 'freed';
357
+ };
358
+ keyCache.set('img:orphan.png', texture);
359
+ mgr.setTextureMemUse(texture, 100e6);
360
+
361
+ mgr.cleanup(true);
362
+
363
+ expect(texture.destroy).toHaveBeenCalledTimes(1);
364
+ expect(keyCache.has('img:orphan.png')).toBe(false);
365
+ expect(mgr.getMemoryInfo().memUsed).toBe(26e6); // baseline only
366
+ });
367
+ });
368
+
369
+ describe('EventEmitter — hasListeners', () => {
370
+ it('is false before any listener is registered', () => {
371
+ const emitter = new EventEmitter();
372
+ expect(emitter.hasListeners()).toBe(false);
373
+ });
374
+
375
+ it('is true while a listener is registered and false after off()', () => {
376
+ const emitter = new EventEmitter();
377
+ const listener = () => {};
378
+ emitter.on('loaded', listener);
379
+ expect(emitter.hasListeners()).toBe(true);
380
+
381
+ emitter.off('loaded', listener);
382
+ expect(emitter.hasListeners()).toBe(false);
383
+ });
384
+
385
+ it('is false after off() removes all listeners for an event by name', () => {
386
+ const emitter = new EventEmitter();
387
+ emitter.on('loaded', () => {});
388
+ emitter.off('loaded');
389
+ expect(emitter.hasListeners()).toBe(false);
390
+ });
391
+
392
+ it('is true if any one of several events still has a listener', () => {
393
+ const emitter = new EventEmitter();
394
+ const a = () => {};
395
+ emitter.on('loaded', a);
396
+ emitter.on('freed', () => {});
397
+ emitter.off('loaded', a);
398
+ expect(emitter.hasListeners()).toBe(true);
399
+ });
400
+
401
+ it('is false after a once() listener has fired', () => {
402
+ const emitter = new EventEmitter();
403
+ emitter.once('loaded', () => {});
404
+ emitter.emit('loaded');
405
+ expect(emitter.hasListeners()).toBe(false);
406
+ });
407
+
408
+ it('is false after removeAllListeners()', () => {
409
+ const emitter = new EventEmitter();
410
+ emitter.on('loaded', () => {});
411
+ emitter.removeAllListeners();
412
+ expect(emitter.hasListeners()).toBe(false);
413
+ });
414
+ });