@solidtv/renderer 1.5.3 → 1.5.4

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 (35) hide show
  1. package/dist/src/core/CoreNode.d.ts +7 -0
  2. package/dist/src/core/CoreNode.js +13 -0
  3. package/dist/src/core/CoreNode.js.map +1 -1
  4. package/dist/src/core/CoreTextNode.js +3 -2
  5. package/dist/src/core/CoreTextNode.js.map +1 -1
  6. package/dist/src/core/CoreTextureManager.d.ts +39 -3
  7. package/dist/src/core/CoreTextureManager.js +72 -61
  8. package/dist/src/core/CoreTextureManager.js.map +1 -1
  9. package/dist/src/core/Stage.js +6 -4
  10. package/dist/src/core/Stage.js.map +1 -1
  11. package/dist/src/core/TextureMemoryManager.js +1 -1
  12. package/dist/src/core/TextureMemoryManager.js.map +1 -1
  13. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js +5 -3
  14. package/dist/src/core/renderers/webgl/WebGlCtxTexture.js.map +1 -1
  15. package/dist/src/core/textures/SubTexture.js +1 -1
  16. package/dist/src/core/textures/SubTexture.js.map +1 -1
  17. package/dist/src/core/textures/Texture.d.ts +1 -1
  18. package/dist/src/core/textures/Texture.js +10 -13
  19. package/dist/src/core/textures/Texture.js.map +1 -1
  20. package/dist/src/main-api/Inspector.js +1 -1
  21. package/dist/src/main-api/Inspector.js.map +1 -1
  22. package/dist/tsconfig.dist.tsbuildinfo +1 -1
  23. package/package.json +1 -1
  24. package/src/core/CoreNode.test.ts +165 -0
  25. package/src/core/CoreNode.ts +14 -0
  26. package/src/core/CoreTextNode.ts +3 -2
  27. package/src/core/CoreTextureManager.ts +75 -70
  28. package/src/core/Stage.ts +9 -4
  29. package/src/core/TextureMemoryManager.test.ts +2 -2
  30. package/src/core/TextureMemoryManager.ts +1 -1
  31. package/src/core/renderers/webgl/WebGlCtxTexture.ts +5 -4
  32. package/src/core/textures/SubTexture.ts +1 -1
  33. package/src/core/textures/Texture.test.ts +124 -0
  34. package/src/core/textures/Texture.ts +10 -13
  35. package/src/main-api/Inspector.ts +1 -1
@@ -409,9 +409,9 @@ export class CoreTextureManager extends EventEmitter {
409
409
 
410
410
  // Anything that arrived before initialization completed is now safe to
411
411
  // process. Without this, queued textures would sit until the next frame
412
- // tick happens to call processSome().
412
+ // tick happens to drain them.
413
413
  if (this.uploadTextureQueue.size > 0) {
414
- this.processSome(Infinity).catch((err) => {
414
+ this.processUntil(Infinity).catch((err) => {
415
415
  console.error('Failed to drain pre-init texture queue:', err);
416
416
  });
417
417
  }
@@ -587,11 +587,41 @@ export class CoreTextureManager extends EventEmitter {
587
587
  }
588
588
 
589
589
  /**
590
- * Process a limited number of uploads.
590
+ * Upload a single queued texture to the GPU.
591
591
  *
592
- * @param maxProcessingTime - The maximum processing time in milliseconds
592
+ * @remarks
593
+ * Used while animations are running so uploads don't steal time from the
594
+ * animation. If the dequeued texture already died (failed/freed), nothing is
595
+ * uploaded this frame and the next call handles the following one.
596
+ */
597
+ async processOne(): Promise<void> {
598
+ if (this.initialized === false) {
599
+ return;
600
+ }
601
+
602
+ const texture = this.uploadTextureQueue.shift();
603
+ if (texture === undefined) {
604
+ return;
605
+ }
606
+
607
+ await this.uploadQueued(texture);
608
+ }
609
+
610
+ /**
611
+ * Upload queued textures to the GPU until the per-frame time budget runs out.
612
+ *
613
+ * @remarks
614
+ * Called once per frame when idle. Textures are uploaded one-by-one; after
615
+ * each, the elapsed time is rechecked and processing stops once it exceeds
616
+ * `maxProcessingTime`, leaving the rest queued for the next frame.
617
+ *
618
+ * In normal operation a queued texture's data is already decoded
619
+ * (`loadTexture` awaits `getTextureData` before enqueuing), so this budgets
620
+ * GPU upload time. Pass `Infinity` to drain the whole queue.
621
+ *
622
+ * @param maxProcessingTime - The time budget for this frame, in milliseconds
593
623
  */
594
- async processSome(maxProcessingTime: number): Promise<void> {
624
+ async processUntil(maxProcessingTime: number): Promise<void> {
595
625
  if (this.initialized === false) {
596
626
  return;
597
627
  }
@@ -599,79 +629,54 @@ export class CoreTextureManager extends EventEmitter {
599
629
  const platform = this.platform;
600
630
  const startTime = platform.getTimeStamp();
601
631
 
602
- // Decode / fetch ("getTextureData") is IO-bound and parallelisable across
603
- // image workers, while GPU upload is effectively serial. Keep a small
604
- // sliding window of in-flight data fetches so the next decode runs while
605
- // we're uploading the current one.
606
- const prefetchLimit = Math.max(1, this.numImageWorkers);
607
- const pending: Array<{ texture: Texture; data: Promise<unknown> }> = [];
608
-
609
- // Helper avoids TS narrowing `texture.state` permanently after the first
610
- // discriminated check — the property is mutable and can transition across
611
- // awaits, so we need to re-read it freshly each time.
612
- const isDead = (texture: Texture): boolean =>
613
- texture.state === 'failed' || texture.state === 'freed';
614
-
615
- const fillPrefetch = () => {
616
- while (pending.length < prefetchLimit) {
617
- const texture = this.uploadTextureQueue.shift();
618
- if (texture === undefined) break;
619
-
620
- if (isDead(texture)) {
621
- continue;
622
- }
623
-
624
- // Swallow the rejection here so an early failure doesn't surface as
625
- // an unhandled promise rejection while it sits in the prefetch
626
- // window; we re-check state after awaiting.
627
- const data =
628
- texture.textureData === null
629
- ? texture.getTextureData().catch((err) => {
630
- console.error('Failed to fetch texture data:', err);
631
- return null;
632
- })
633
- : Promise.resolve(texture.textureData);
634
-
635
- pending.push({ texture, data });
632
+ while (platform.getTimeStamp() - startTime < maxProcessingTime) {
633
+ const texture = this.uploadTextureQueue.shift();
634
+ if (texture === undefined) {
635
+ // Queue drained.
636
+ break;
636
637
  }
637
- };
638
638
 
639
- fillPrefetch();
639
+ await this.uploadQueued(texture);
640
+ }
641
+ }
640
642
 
641
- while (
642
- pending.length > 0 &&
643
- platform.getTimeStamp() - startTime < maxProcessingTime
644
- ) {
645
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
646
- const next = pending.shift()!;
647
- // Top up the prefetch window before awaiting — the next decode starts
648
- // now and overlaps with this upload.
649
- fillPrefetch();
643
+ /**
644
+ * Decode (if needed) and upload a single already-dequeued texture.
645
+ *
646
+ * @remarks
647
+ * Shared by {@link processOne} and {@link processUntil}. Dead (failed/freed)
648
+ * textures and upload failures are skipped without throwing.
649
+ */
650
+ private async uploadQueued(texture: Texture): Promise<void> {
651
+ if (this.isTextureDead(texture)) {
652
+ return;
653
+ }
650
654
 
651
- if (isDead(next.texture)) {
652
- continue;
655
+ try {
656
+ if (texture.textureData === null) {
657
+ await texture.getTextureData();
653
658
  }
654
-
655
- try {
656
- await next.data;
657
- if (isDead(next.texture)) {
658
- continue;
659
- }
660
- await this.uploadTexture(next.texture);
661
- } catch (error) {
662
- console.error('Failed to upload texture:', error);
663
- // Continue with next texture instead of stopping entire queue
659
+ if (this.isTextureDead(texture)) {
660
+ return;
664
661
  }
662
+ await this.uploadTexture(texture);
663
+ } catch (error) {
664
+ console.error('Failed to upload texture:', error);
665
+ // Skip this texture instead of stalling the queue.
665
666
  }
667
+ }
666
668
 
667
- // Time ran out before we got to these. Put them back so we don't lose
668
- // them their getTextureData() is already in flight and will populate
669
- // `textureData` for the next tick.
670
- for (const { texture } of pending) {
671
- if (!isDead(texture)) {
672
- this.uploadTextureQueue.add(texture);
673
- }
674
- }
669
+ /**
670
+ * A texture is "dead" once it has failed or been freed — both terminal for
671
+ * the upload pipeline.
672
+ *
673
+ * @remarks
674
+ * Kept as a method rather than an inline check so TypeScript doesn't
675
+ * permanently narrow `state` after the first comparison: the property is
676
+ * mutable and can transition across the awaits in {@link uploadQueued}.
677
+ */
678
+ private isTextureDead(texture: Texture): boolean {
679
+ return texture.state === 'failed' || texture.state === 'freed';
675
680
  }
676
681
 
677
682
  public hasUpdates(): boolean {
package/src/core/Stage.ts CHANGED
@@ -595,11 +595,16 @@ export class Stage {
595
595
  // Process some textures asynchronously but don't block the frame
596
596
  // Use a background task to prevent frame drops
597
597
  if (this.txManager.hasUpdates() === true) {
598
- const timeLimit = hasActiveAnimations
599
- ? this.options.textureProcessingTimeLimit / 2
600
- : this.options.textureProcessingTimeLimit;
598
+ // While animating, upload at most one texture per frame so uploads don't
599
+ // steal time from the animation; otherwise fill the per-frame time budget.
600
+ const processing =
601
+ hasActiveAnimations === true
602
+ ? this.txManager.processOne()
603
+ : this.txManager.processUntil(
604
+ this.options.textureProcessingTimeLimit,
605
+ );
601
606
 
602
- this.txManager.processSome(timeLimit).catch((err) => {
607
+ processing.catch((err) => {
603
608
  console.error('Error processing textures:', err);
604
609
  });
605
610
  }
@@ -175,7 +175,7 @@ function freedCachedTexture(cacheKey: string): Texture & {
175
175
  state: 'freed',
176
176
  type: TextureType.image,
177
177
  preventCleanup: false,
178
- renderableOwners: [],
178
+ renderableOwners: new Set<string | number>(),
179
179
  cacheKey,
180
180
  free: vi.fn(),
181
181
  destroy: vi.fn(),
@@ -206,7 +206,7 @@ describe('TextureMemoryManager — orphaned freed texture eviction', () => {
206
206
  it('keeps a freed texture that still has renderable owners', () => {
207
207
  const { mgr, keyCache } = makeManager();
208
208
  const texture = freedCachedTexture('img:poster.png');
209
- (texture.renderableOwners as unknown[]).push(1);
209
+ texture.renderableOwners.add(1);
210
210
  keyCache.set('img:poster.png', texture);
211
211
 
212
212
  mgr.cleanup();
@@ -349,7 +349,7 @@ export class TextureMemoryManager {
349
349
  if (
350
350
  evictable === true &&
351
351
  texture.preventCleanup === false &&
352
- texture.renderableOwners.length === 0 &&
352
+ texture.renderableOwners.size === 0 &&
353
353
  texture.hasListeners() === false
354
354
  ) {
355
355
  this.destroyTexture(texture);
@@ -161,10 +161,11 @@ export class WebGlCtxTexture extends CoreContextTexture {
161
161
  );
162
162
  }
163
163
 
164
- // Set to a 1x1 transparent texture
165
- glw.texImage2D(0, glw.RGBA, 1, 1, 0, glw.RGBA, glw.UNSIGNED_BYTE, null);
166
- this.setTextureMemUse(TRANSPARENT_TEXTURE_DATA.byteLength);
167
-
164
+ // No placeholder upload here: every branch below fully (re)uploads the
165
+ // texture before this method returns, and there is no `await` in between,
166
+ // so no frame can render an intermediate 1x1. The empty-texture case
167
+ // (`tdata === null`) does its own 1x1 upload. Uploading a throwaway 1x1
168
+ // first would be a wasted texImage2D + setTextureMemUse per texture.
168
169
  let w = 0;
169
170
  let h = 0;
170
171
 
@@ -79,7 +79,7 @@ export class SubTexture extends Texture {
79
79
  // Resolve parent texture from cache or fallback to provided texture
80
80
  this.parentTexture = txManager.resolveParentTexture(this.props.texture);
81
81
 
82
- if (this.renderableOwners.length > 0) {
82
+ if (this.renderableOwners.size > 0) {
83
83
  this.parentTexture.setRenderableOwner(this.subtextureId, true);
84
84
  }
85
85
 
@@ -0,0 +1,124 @@
1
+ import { describe, it, expect, vi } from 'vitest';
2
+ import { Texture, type TextureData } from './Texture.js';
3
+ import type { CoreTextureManager } from '../CoreTextureManager.js';
4
+
5
+ class TestTexture extends Texture {
6
+ override async getTextureSource(): Promise<TextureData> {
7
+ return { data: null };
8
+ }
9
+ }
10
+
11
+ function makeTexture(): {
12
+ texture: TestTexture;
13
+ loadTexture: ReturnType<typeof vi.fn>;
14
+ onChangeIsRenderable: ReturnType<typeof vi.fn>;
15
+ } {
16
+ const loadTexture = vi.fn();
17
+ const txManager = {
18
+ maxRetryCount: 3,
19
+ loadTexture,
20
+ } as unknown as CoreTextureManager;
21
+ const texture = new TestTexture(txManager);
22
+ const onChangeIsRenderable = vi.fn();
23
+ texture.onChangeIsRenderable = onChangeIsRenderable;
24
+ return { texture, loadTexture, onChangeIsRenderable };
25
+ }
26
+
27
+ describe('Texture.setRenderableOwner', () => {
28
+ it('adding an owner fires the 0→1 transition exactly once and loads', () => {
29
+ const { texture, loadTexture, onChangeIsRenderable } = makeTexture();
30
+
31
+ texture.setRenderableOwner(1, true);
32
+
33
+ expect(texture.renderableOwners.size).toBe(1);
34
+ expect(texture.renderable).toBe(true);
35
+ expect(onChangeIsRenderable).toHaveBeenCalledTimes(1);
36
+ expect(onChangeIsRenderable).toHaveBeenCalledWith(true);
37
+ expect(loadTexture).toHaveBeenCalledTimes(1);
38
+ });
39
+
40
+ it('double-add of the same owner is a no-op (size stays 1, one event)', () => {
41
+ const { texture, loadTexture, onChangeIsRenderable } = makeTexture();
42
+
43
+ texture.setRenderableOwner(1, true);
44
+ texture.setRenderableOwner(1, true);
45
+
46
+ expect(texture.renderableOwners.size).toBe(1);
47
+ expect(onChangeIsRenderable).toHaveBeenCalledTimes(1);
48
+ expect(loadTexture).toHaveBeenCalledTimes(1);
49
+ });
50
+
51
+ it('a second distinct owner does not re-fire the transition', () => {
52
+ const { texture, loadTexture, onChangeIsRenderable } = makeTexture();
53
+
54
+ texture.setRenderableOwner(1, true);
55
+ texture.setRenderableOwner(2, true);
56
+
57
+ expect(texture.renderableOwners.size).toBe(2);
58
+ expect(onChangeIsRenderable).toHaveBeenCalledTimes(1);
59
+ expect(loadTexture).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it('removing a non-member owner does not fire a transition', () => {
63
+ const { texture, onChangeIsRenderable } = makeTexture();
64
+
65
+ texture.setRenderableOwner(1, false);
66
+
67
+ expect(texture.renderableOwners.size).toBe(0);
68
+ expect(texture.renderable).toBe(false);
69
+ expect(onChangeIsRenderable).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it('1→0 fires onChangeIsRenderable(false) exactly once', () => {
73
+ const { texture, onChangeIsRenderable } = makeTexture();
74
+
75
+ texture.setRenderableOwner(1, true);
76
+ texture.setRenderableOwner(1, false);
77
+ texture.setRenderableOwner(1, false);
78
+
79
+ expect(texture.renderableOwners.size).toBe(0);
80
+ expect(texture.renderable).toBe(false);
81
+ expect(onChangeIsRenderable).toHaveBeenCalledTimes(2);
82
+ expect(onChangeIsRenderable).toHaveBeenLastCalledWith(false);
83
+ });
84
+
85
+ it('re-adding an owner after 1→0 triggers load again (freed→reload cycle)', () => {
86
+ const { texture, loadTexture } = makeTexture();
87
+
88
+ texture.setRenderableOwner(1, true);
89
+ texture.setRenderableOwner(1, false);
90
+ texture.setRenderableOwner(1, true);
91
+
92
+ expect(texture.renderable).toBe(true);
93
+ expect(loadTexture).toHaveBeenCalledTimes(2);
94
+ });
95
+
96
+ it('string and number owners coexist (node ids vs subtexture/font keys)', () => {
97
+ const { texture } = makeTexture();
98
+
99
+ texture.setRenderableOwner(7, true);
100
+ texture.setRenderableOwner('subtexture-7', true);
101
+
102
+ expect(texture.renderableOwners.size).toBe(2);
103
+
104
+ texture.setRenderableOwner(7, false);
105
+ expect(texture.renderableOwners.size).toBe(1);
106
+ expect(texture.renderable).toBe(true);
107
+
108
+ texture.setRenderableOwner('subtexture-7', false);
109
+ expect(texture.renderableOwners.size).toBe(0);
110
+ expect(texture.renderable).toBe(false);
111
+ });
112
+
113
+ it('canBeCleanedUp respects remaining owners', () => {
114
+ const { texture } = makeTexture();
115
+ // Skip the startup grace period so owners are the deciding factor.
116
+ texture.isWithinStartupGracePeriod = () => false;
117
+
118
+ texture.setRenderableOwner(1, true);
119
+ expect(texture.canBeCleanedUp()).toBe(false);
120
+
121
+ texture.setRenderableOwner(1, false);
122
+ expect(texture.canBeCleanedUp()).toBe(true);
123
+ });
124
+ });
@@ -141,7 +141,7 @@ export abstract class Texture extends EventEmitter {
141
141
  // aggregate state
142
142
  public state: TextureState = 'initial';
143
143
 
144
- readonly renderableOwners: any[] = [];
144
+ readonly renderableOwners: Set<string | number> = new Set();
145
145
 
146
146
  readonly renderable: boolean = false;
147
147
 
@@ -251,7 +251,7 @@ export abstract class Texture extends EventEmitter {
251
251
  }
252
252
 
253
253
  // Don't cleanup if there are still renderable owners
254
- if (this.renderableOwners.length > 0) {
254
+ if (this.renderableOwners.size > 0) {
255
255
  return false;
256
256
  }
257
257
 
@@ -274,28 +274,25 @@ export abstract class Texture extends EventEmitter {
274
274
  * @param renderable
275
275
  */
276
276
  setRenderableOwner(owner: string | number, renderable: boolean): void {
277
- const oldSize = this.renderableOwners.length;
278
- const hasOwnerIndex = this.renderableOwners.indexOf(owner);
277
+ const owners = this.renderableOwners;
279
278
 
280
279
  if (renderable === true) {
281
- if (hasOwnerIndex === -1) {
282
- // Add the owner to the set
283
- this.renderableOwners.push(owner);
280
+ if (owners.has(owner) === true) {
281
+ return;
284
282
  }
285
283
 
286
- const newSize = this.renderableOwners.length;
287
- if (oldSize !== newSize && newSize === 1) {
284
+ owners.add(owner);
285
+ if (owners.size === 1) {
288
286
  (this.renderable as boolean) = true;
289
287
  this.onChangeIsRenderable?.(true);
290
288
  this.load();
291
289
  }
292
290
  } else {
293
- if (hasOwnerIndex !== -1) {
294
- this.renderableOwners.splice(hasOwnerIndex, 1);
291
+ if (owners.delete(owner) === false) {
292
+ return;
295
293
  }
296
294
 
297
- const newSize = this.renderableOwners.length;
298
- if (oldSize !== newSize && newSize === 0) {
295
+ if (owners.size === 0) {
299
296
  (this.renderable as boolean) = false;
300
297
  this.onChangeIsRenderable?.(false);
301
298
 
@@ -1104,7 +1104,7 @@ export class Inspector {
1104
1104
  // Update renderable owners count
1105
1105
  div.setAttribute(
1106
1106
  'data-texture-owners',
1107
- String(texture.renderableOwners.length),
1107
+ String(texture.renderableOwners.size),
1108
1108
  );
1109
1109
 
1110
1110
  // Update retry count