@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.
- package/dist/src/common/EventEmitter.d.ts +8 -0
- package/dist/src/common/EventEmitter.js +20 -0
- package/dist/src/common/EventEmitter.js.map +1 -1
- package/dist/src/core/Autosizer.js +3 -1
- package/dist/src/core/Autosizer.js.map +1 -1
- package/dist/src/core/CoreNode.d.ts +39 -0
- package/dist/src/core/CoreNode.js +105 -25
- package/dist/src/core/CoreNode.js.map +1 -1
- package/dist/src/core/CoreTextNode.js +4 -2
- package/dist/src/core/CoreTextNode.js.map +1 -1
- package/dist/src/core/Stage.js +2 -0
- package/dist/src/core/Stage.js.map +1 -1
- package/dist/src/core/TextureMemoryManager.d.ts +39 -2
- package/dist/src/core/TextureMemoryManager.js +53 -1
- package/dist/src/core/TextureMemoryManager.js.map +1 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.js +5 -1
- package/dist/src/core/renderers/canvas/CanvasRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.js +5 -1
- package/dist/src/core/renderers/webgl/WebGlRenderer.js.map +1 -1
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.d.ts +28 -0
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js +70 -10
- package/dist/src/core/renderers/webgl/WebGlShaderProgram.js.map +1 -1
- package/dist/tsconfig.dist.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/common/EventEmitter.ts +21 -0
- package/src/core/Autosizer.ts +3 -1
- package/src/core/CoreNode.test.ts +283 -0
- package/src/core/CoreNode.ts +131 -26
- package/src/core/CoreTextNode.test.ts +1 -0
- package/src/core/CoreTextNode.ts +6 -2
- package/src/core/Stage.ts +2 -0
- package/src/core/TextureMemoryManager.test.ts +272 -5
- package/src/core/TextureMemoryManager.ts +59 -2
- package/src/core/renderers/canvas/CanvasRenderer.ts +7 -1
- package/src/core/renderers/webgl/WebGlRenderer.ts +6 -1
- package/src/core/renderers/webgl/WebGlShaderProgram.ts +78 -15
- package/src/core/renderers/webgl/WebGlShaderProgram.uniformDedup.test.ts +233 -0
package/src/core/CoreNode.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|
package/src/core/CoreTextNode.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
30
|
-
|
|
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
|
+
});
|