@solidtv/renderer 1.5.1 → 1.5.3

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.
@@ -13,6 +13,7 @@ import { premultiplyColorABGR } from '../utils.js';
13
13
  describe('set color()', () => {
14
14
  const defaultProps = (overrides?: Partial<CoreNodeProps>): CoreNodeProps => ({
15
15
  alpha: 0,
16
+ ignoreParentAlpha: false,
16
17
  autosize: false,
17
18
  boundsMargin: null,
18
19
  clipping: false,
@@ -264,6 +265,112 @@ describe('set color()', () => {
264
265
  });
265
266
  });
266
267
 
268
+ describe('ignoreParentAlpha', () => {
269
+ const makeParent = (worldAlpha: number) => {
270
+ const parent = new CoreNode(stage, defaultProps());
271
+ parent.globalTransform = Matrix3d.identity();
272
+ parent.worldAlpha = worldAlpha;
273
+ return parent;
274
+ };
275
+
276
+ it('multiplies parent world alpha by default', () => {
277
+ const parent = makeParent(0.5);
278
+ const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 }));
279
+
280
+ node.update(0, clippingRect);
281
+
282
+ expect(node.worldAlpha).toBeCloseTo(0.4);
283
+ });
284
+
285
+ it('uses own alpha only when enabled', () => {
286
+ const parent = makeParent(0.5);
287
+ const node = new CoreNode(
288
+ stage,
289
+ defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
290
+ );
291
+
292
+ node.update(0, clippingRect);
293
+
294
+ expect(node.worldAlpha).toBe(0.8);
295
+ });
296
+
297
+ it('keeps its own world alpha while the parent fades toward 0', () => {
298
+ const parent = makeParent(0.01);
299
+ const node = new CoreNode(
300
+ stage,
301
+ defaultProps({ parent, alpha: 1, ignoreParentAlpha: true }),
302
+ );
303
+ node.w = 100;
304
+ node.h = 100;
305
+ node.color = 0xffffffff;
306
+
307
+ node.update(0, clippingRect);
308
+
309
+ expect(node.worldAlpha).toBe(1);
310
+ expect(node.isRenderable).toBe(true);
311
+ });
312
+
313
+ it('premultiplies colors with the node own alpha when enabled', () => {
314
+ const parent = makeParent(0.25);
315
+ const node = new CoreNode(
316
+ stage,
317
+ defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
318
+ );
319
+ node.w = 100;
320
+ node.h = 100;
321
+ node.color = 0xff0000ff;
322
+
323
+ node.update(0, clippingRect);
324
+
325
+ expect(node.premultipliedColorTl).toBe(
326
+ premultiplyColorABGR(0xff0000ff, 0.8),
327
+ );
328
+ });
329
+
330
+ it('toggling the setter recomputes world alpha', () => {
331
+ const parent = makeParent(0.5);
332
+ const node = new CoreNode(stage, defaultProps({ parent, alpha: 0.8 }));
333
+
334
+ node.update(0, clippingRect);
335
+ expect(node.worldAlpha).toBeCloseTo(0.4);
336
+
337
+ node.ignoreParentAlpha = true;
338
+ node.update(1, clippingRect);
339
+ expect(node.worldAlpha).toBe(0.8);
340
+
341
+ node.ignoreParentAlpha = false;
342
+ node.update(2, clippingRect);
343
+ expect(node.worldAlpha).toBeCloseTo(0.4);
344
+ });
345
+
346
+ it('setting the same value does not flag an update', () => {
347
+ const node = new CoreNode(stage, defaultProps());
348
+ const updateTypeBefore = node.updateType;
349
+
350
+ node.ignoreParentAlpha = false;
351
+
352
+ expect(node.updateType).toBe(updateTypeBefore);
353
+ });
354
+
355
+ it('descendants inherit the node world alpha as usual', () => {
356
+ const parent = makeParent(0.5);
357
+ const node = new CoreNode(
358
+ stage,
359
+ defaultProps({ parent, alpha: 0.8, ignoreParentAlpha: true }),
360
+ );
361
+ const child = new CoreNode(
362
+ stage,
363
+ defaultProps({ parent: node, alpha: 0.5 }),
364
+ );
365
+
366
+ node.update(0, clippingRect);
367
+ child.update(0, clippingRect);
368
+
369
+ expect(node.worldAlpha).toBe(0.8);
370
+ expect(child.worldAlpha).toBeCloseTo(0.4);
371
+ });
372
+ });
373
+
267
374
  describe('autosize system', () => {
268
375
  it('should initialize with autosize disabled', () => {
269
376
  const node = new CoreNode(stage, defaultProps());
@@ -1463,4 +1570,146 @@ describe('set color()', () => {
1463
1570
  expect(node.renderTexture).toBe(texture);
1464
1571
  });
1465
1572
  });
1573
+
1574
+ describe('renderOnlyInViewport', () => {
1575
+ // Viewport is 0..200; the preload (bounds-margin) ring extends to 400.
1576
+ // A node at x=250 is InBounds (margin ring); at x=50 it is InViewport;
1577
+ // at x=500 it is OutOfBounds.
1578
+ function boundsStage(renderOnlyInViewport: boolean): Stage {
1579
+ return mock<Stage>({
1580
+ strictBound: createBound(0, 0, 200, 200),
1581
+ preloadBound: createBound(0, 0, 400, 200),
1582
+ defaultTexture: {
1583
+ state: 'loaded',
1584
+ },
1585
+ renderer: mock<CoreRenderer>() as CoreRenderer,
1586
+ renderOnlyInViewport,
1587
+ });
1588
+ }
1589
+
1590
+ function loadedTextureNode(stage: Stage, x: number): CoreNode {
1591
+ const parent = new CoreNode(stage, defaultProps());
1592
+ parent.globalTransform = Matrix3d.identity();
1593
+ parent.worldAlpha = 1;
1594
+
1595
+ const node = new CoreNode(stage, defaultProps({ parent }));
1596
+ node.alpha = 1;
1597
+ node.x = x;
1598
+ node.y = 0;
1599
+ node.w = 100;
1600
+ node.h = 100;
1601
+ node.texture = mock<ImageTexture>({
1602
+ state: 'loaded',
1603
+ setRenderableOwner: vi.fn(),
1604
+ });
1605
+ node.textureLoaded = true;
1606
+ return node;
1607
+ }
1608
+
1609
+ it('off: a margin-ring node is renderable (legacy behavior)', () => {
1610
+ const node = loadedTextureNode(boundsStage(false), 250);
1611
+
1612
+ node.update(0, clippingRect);
1613
+
1614
+ expect(node.isRenderable).toBe(true);
1615
+ });
1616
+
1617
+ it('on: a margin-ring node is not renderable but still owns its texture', () => {
1618
+ const node = loadedTextureNode(boundsStage(true), 250);
1619
+
1620
+ node.update(0, clippingRect);
1621
+
1622
+ expect(node.isRenderable).toBe(false);
1623
+ // Ownership is the load trigger and cleanup protection — it must stay.
1624
+ expect(node.texture!.setRenderableOwner).toHaveBeenCalledWith(
1625
+ expect.anything(),
1626
+ true,
1627
+ );
1628
+ });
1629
+
1630
+ it('on: a viewport node is renderable', () => {
1631
+ const node = loadedTextureNode(boundsStage(true), 50);
1632
+
1633
+ node.update(0, clippingRect);
1634
+
1635
+ expect(node.isRenderable).toBe(true);
1636
+ });
1637
+
1638
+ it('on: a node becomes renderable when it crosses into the viewport', () => {
1639
+ const node = loadedTextureNode(boundsStage(true), 250);
1640
+ node.update(0, clippingRect);
1641
+ expect(node.isRenderable).toBe(false);
1642
+
1643
+ // Scroll it in.
1644
+ node.x = 50;
1645
+ node.update(1, clippingRect);
1646
+ expect(node.isRenderable).toBe(true);
1647
+
1648
+ // And back out into the ring.
1649
+ node.x = 250;
1650
+ node.update(2, clippingRect);
1651
+ expect(node.isRenderable).toBe(false);
1652
+ });
1653
+
1654
+ it('on: an out-of-bounds node releases texture ownership', () => {
1655
+ const node = loadedTextureNode(boundsStage(true), 500);
1656
+
1657
+ node.update(0, clippingRect);
1658
+
1659
+ expect(node.isRenderable).toBe(false);
1660
+ expect(node.texture!.setRenderableOwner).not.toHaveBeenCalledWith(
1661
+ expect.anything(),
1662
+ true,
1663
+ );
1664
+ });
1665
+
1666
+ it('on: a margin-ring placeholder is gated the same way', () => {
1667
+ const stage = boundsStage(true);
1668
+ const parent = new CoreNode(stage, defaultProps());
1669
+ parent.globalTransform = Matrix3d.identity();
1670
+ parent.worldAlpha = 1;
1671
+
1672
+ const node = new CoreNode(stage, defaultProps({ parent }));
1673
+ node.alpha = 1;
1674
+ node.x = 250;
1675
+ node.y = 0;
1676
+ node.w = 100;
1677
+ node.h = 100;
1678
+ node.placeholderColor = 0x336699ff;
1679
+ node.texture = mock<ImageTexture>({
1680
+ state: 'initial',
1681
+ setRenderableOwner: vi.fn(),
1682
+ });
1683
+
1684
+ node.update(0, clippingRect);
1685
+ expect(node.placeholderActive).toBe(true);
1686
+ expect(node.isRenderable).toBe(false);
1687
+
1688
+ node.x = 50;
1689
+ node.update(1, clippingRect);
1690
+ expect(node.isRenderable).toBe(true);
1691
+ });
1692
+
1693
+ it('on: color-only nodes in the margin ring are gated too', () => {
1694
+ const stage = boundsStage(true);
1695
+ const parent = new CoreNode(stage, defaultProps());
1696
+ parent.globalTransform = Matrix3d.identity();
1697
+ parent.worldAlpha = 1;
1698
+
1699
+ const node = new CoreNode(stage, defaultProps({ parent }));
1700
+ node.alpha = 1;
1701
+ node.x = 250;
1702
+ node.y = 0;
1703
+ node.w = 100;
1704
+ node.h = 100;
1705
+ node.color = 0xff0000ff;
1706
+
1707
+ node.update(0, clippingRect);
1708
+ expect(node.isRenderable).toBe(false);
1709
+
1710
+ node.x = 50;
1711
+ node.update(1, clippingRect);
1712
+ expect(node.isRenderable).toBe(true);
1713
+ });
1714
+ });
1466
1715
  });
@@ -46,7 +46,7 @@ import type { IAnimationController } from '../common/IAnimationController.js';
46
46
  import { createAnimation } from './animations/CoreAnimation.js';
47
47
  import type { CoreShaderNode } from './renderers/CoreShaderNode.js';
48
48
  import { AutosizeMode, Autosizer } from './Autosizer.js';
49
- import { removeChild } from './lib/collectionUtils.js';
49
+ import { removeChild, sortByZIndexStable } from './lib/collectionUtils.js';
50
50
 
51
51
  export enum CoreNodeRenderState {
52
52
  Init = 0,
@@ -63,11 +63,6 @@ const NO_CLIPPING_RECT: RectWithValid = {
63
63
  valid: false,
64
64
  };
65
65
 
66
- // Hoisted so `sortChildren` doesn't allocate a fresh comparator closure on
67
- // every z-index reorder.
68
- const compareZIndex = (a: CoreNode, b: CoreNode): number =>
69
- a.props.zIndex - b.props.zIndex;
70
-
71
66
  const CoreNodeRenderStateMap: Map<CoreNodeRenderState, string> = new Map();
72
67
  CoreNodeRenderStateMap.set(CoreNodeRenderState.Init, 'init');
73
68
  CoreNodeRenderStateMap.set(CoreNodeRenderState.OutOfBounds, 'outOfBounds');
@@ -136,6 +131,7 @@ export enum UpdateType {
136
131
  * @remarks
137
132
  * CoreNode Properties Updated:
138
133
  * - `worldAlpha` = `parent.worldAlpha` * `alpha`
134
+ * (or just `alpha` when `ignoreParentAlpha` is enabled)
139
135
  */
140
136
  WorldAlpha = 64,
141
137
 
@@ -250,6 +246,29 @@ export interface CoreNodeProps {
250
246
  * @default `1`
251
247
  */
252
248
  alpha: number;
249
+ /**
250
+ * When enabled, the Node's world alpha is computed from its own
251
+ * {@link alpha} only, ignoring the alpha inherited from its ancestors.
252
+ *
253
+ * @remarks
254
+ * Normally `worldAlpha = parent.worldAlpha * alpha`, so fading a parent
255
+ * fades every descendant with it. With `ignoreParentAlpha` enabled this
256
+ * Node keeps rendering at its own alpha while its parent (and the rest of
257
+ * the subtree) fades.
258
+ *
259
+ * Subtrees whose world alpha reaches exactly 0 are culled from rendering
260
+ * entirely, so this Node still disappears once an ancestor hits alpha 0 —
261
+ * the prop only has an effect while every ancestor's alpha is above 0.
262
+ * This keeps the fully-transparent subtree cull free of bookkeeping.
263
+ *
264
+ * Descendants of this Node inherit from its world alpha as usual.
265
+ *
266
+ * Has no effect inside a render-to-texture subtree: the RTT root's
267
+ * composited quad is still faded as a single unit by its own world alpha.
268
+ *
269
+ * @default `false`
270
+ */
271
+ ignoreParentAlpha: boolean;
253
272
  /**
254
273
  * Autosize
255
274
  *
@@ -1453,7 +1472,10 @@ export class CoreNode extends EventEmitter {
1453
1472
  }
1454
1473
 
1455
1474
  if (updateType & UpdateType.WorldAlpha) {
1456
- this.worldAlpha = parent.worldAlpha * this.props.alpha;
1475
+ this.worldAlpha =
1476
+ props.ignoreParentAlpha === true
1477
+ ? props.alpha
1478
+ : parent.worldAlpha * props.alpha;
1457
1479
  updateType |=
1458
1480
  UpdateType.PremultipliedColors |
1459
1481
  UpdateType.Children |
@@ -1833,7 +1855,11 @@ export class CoreNode extends EventEmitter {
1833
1855
  // texture has failed to load, we cannot render the texture itself —
1834
1856
  // but a placeholder color still renders in its place
1835
1857
  this.updateTextureOwnership(false);
1836
- this.setRenderable(this.placeholderActive);
1858
+ this.setRenderable(
1859
+ this.placeholderActive === true &&
1860
+ (this.stage.renderOnlyInViewport === false ||
1861
+ this.renderState === CoreNodeRenderState.InViewport),
1862
+ );
1837
1863
  return;
1838
1864
  }
1839
1865
 
@@ -1853,6 +1879,17 @@ export class CoreNode extends EventEmitter {
1853
1879
  newIsRenderable = true;
1854
1880
  }
1855
1881
 
1882
+ // renderOnlyInViewport: nodes in the preload margin keep texture
1883
+ // ownership above (so loading proceeds) but stay out of the render list
1884
+ // until they actually intersect the viewport.
1885
+ if (
1886
+ newIsRenderable === true &&
1887
+ this.stage.renderOnlyInViewport === true &&
1888
+ this.renderState !== CoreNodeRenderState.InViewport
1889
+ ) {
1890
+ newIsRenderable = false;
1891
+ }
1892
+
1856
1893
  this.updateTextureOwnership(needsTextureOwnership);
1857
1894
  this.setRenderable(newIsRenderable);
1858
1895
  }
@@ -2165,7 +2202,7 @@ export class CoreNode extends EventEmitter {
2165
2202
  }
2166
2203
 
2167
2204
  sortChildren() {
2168
- this.children.sort(compareZIndex);
2205
+ sortByZIndexStable(this.children);
2169
2206
  this.stage.requestRenderListUpdate();
2170
2207
  }
2171
2208
 
@@ -2508,6 +2545,24 @@ export class CoreNode extends EventEmitter {
2508
2545
  this.childUpdateType |= UpdateType.WorldAlpha;
2509
2546
  }
2510
2547
 
2548
+ get ignoreParentAlpha(): boolean {
2549
+ return this.props.ignoreParentAlpha;
2550
+ }
2551
+
2552
+ set ignoreParentAlpha(value: boolean) {
2553
+ if (this.props.ignoreParentAlpha === value) {
2554
+ return;
2555
+ }
2556
+ this.props.ignoreParentAlpha = value;
2557
+ this.setUpdateType(
2558
+ UpdateType.PremultipliedColors |
2559
+ UpdateType.WorldAlpha |
2560
+ UpdateType.Children |
2561
+ UpdateType.IsRenderable,
2562
+ );
2563
+ this.childUpdateType |= UpdateType.WorldAlpha;
2564
+ }
2565
+
2511
2566
  get autosize(): boolean {
2512
2567
  return this.props.autosize;
2513
2568
  }
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it, vi } from 'vitest';
2
2
  import { mock } from 'vitest-mock-extended';
3
3
  import { CoreTextNode, type CoreTextNodeProps } from './CoreTextNode.js';
4
+ import { CoreNodeRenderState } from './CoreNode.js';
4
5
  import { Stage } from './Stage.js';
5
6
  import { CoreRenderer } from './renderers/CoreRenderer.js';
6
7
  import { createBound } from './lib/utils.js';
@@ -16,6 +17,7 @@ const defaultProps = (
16
17
  ): CoreTextNodeProps => ({
17
18
  // CoreNodeProps
18
19
  alpha: 1,
20
+ ignoreParentAlpha: false,
19
21
  autosize: false,
20
22
  boundsMargin: null,
21
23
  clipping: false,
@@ -144,3 +146,63 @@ describe('CoreTextNode (canvas) clearing text', () => {
144
146
  expect(node.isRenderable).toBe(false);
145
147
  });
146
148
  });
149
+
150
+ describe('CoreTextNode (sdf) renderOnlyInViewport', () => {
151
+ const makeSdfRenderer = (): TextRenderer => {
152
+ const font = mock<FontHandler>({ type: 'sdf' });
153
+ return {
154
+ type: 'sdf',
155
+ font,
156
+ renderText: vi.fn(),
157
+ addQuads: vi.fn(),
158
+ renderQuads: vi.fn(),
159
+ init: vi.fn(),
160
+ } as unknown as TextRenderer;
161
+ };
162
+
163
+ function sdfNodeWithLayout(renderOnlyInViewport: boolean): CoreTextNode {
164
+ const stage = mock<Stage>({
165
+ strictBound: createBound(0, 0, 200, 200),
166
+ preloadBound: createBound(0, 0, 400, 200),
167
+ defaultTexture: { state: 'loaded' } as never,
168
+ defShaderNode: null as never,
169
+ renderer: mock<CoreRenderer>() as CoreRenderer,
170
+ renderOnlyInViewport,
171
+ });
172
+ const node = new CoreTextNode(
173
+ stage,
174
+ defaultProps({ text: 'Hello' }),
175
+ makeSdfRenderer(),
176
+ );
177
+ (node as unknown as { _cachedLayout: object })._cachedLayout = {};
178
+ node.worldAlpha = 1;
179
+ return node;
180
+ }
181
+
182
+ it('off: margin-ring SDF text is renderable (legacy behavior)', () => {
183
+ const node = sdfNodeWithLayout(false);
184
+ node.renderState = CoreNodeRenderState.InBounds;
185
+
186
+ node.updateIsRenderable();
187
+
188
+ expect(node.isRenderable).toBe(true);
189
+ });
190
+
191
+ it('on: margin-ring SDF text stays out of the render list', () => {
192
+ const node = sdfNodeWithLayout(true);
193
+ node.renderState = CoreNodeRenderState.InBounds;
194
+
195
+ node.updateIsRenderable();
196
+
197
+ expect(node.isRenderable).toBe(false);
198
+ });
199
+
200
+ it('on: viewport SDF text is renderable', () => {
201
+ const node = sdfNodeWithLayout(true);
202
+ node.renderState = CoreNodeRenderState.InViewport;
203
+
204
+ node.updateIsRenderable();
205
+
206
+ expect(node.isRenderable).toBe(true);
207
+ });
208
+ });
@@ -227,9 +227,14 @@ export class CoreTextNode extends CoreNode implements CoreTextNodeProps {
227
227
  return;
228
228
  }
229
229
 
230
- // For SDF, check if we have a cached layout
230
+ // For SDF, check if we have a cached layout. renderOnlyInViewport gates
231
+ // SDF text the same way CoreNode gates quads: margin-ring text stays out
232
+ // of the render list until it intersects the viewport.
231
233
  this.setRenderable(
232
- this.checkBasicRenderability() === true && this._cachedLayout !== null,
234
+ this.checkBasicRenderability() === true &&
235
+ this._cachedLayout !== null &&
236
+ (this.stage.renderOnlyInViewport === false ||
237
+ this.renderState === CoreNodeRenderState.InViewport),
233
238
  );
234
239
  }
235
240
 
package/src/core/Stage.ts CHANGED
@@ -97,6 +97,12 @@ export class Stage {
97
97
  public readonly renderer: CoreRenderer;
98
98
  public readonly root: CoreNode;
99
99
  public boundsMargin: [number, number, number, number];
100
+ /**
101
+ * When true, nodes inside the bounds margin but outside the viewport keep
102
+ * loading textures yet stay out of the render list. Read by
103
+ * `CoreNode.updateIsRenderable` on the scroll path.
104
+ */
105
+ public readonly renderOnlyInViewport: boolean;
100
106
  public readonly defShaderNode: CoreShaderNode | null = null;
101
107
  public strictBound: Bound;
102
108
  public preloadBound: Bound;
@@ -208,6 +214,7 @@ export class Stage {
208
214
  setBaselineMode(options.textBaselineMode);
209
215
 
210
216
  this.platform = platform;
217
+ this.renderOnlyInViewport = options.renderOnlyInViewport !== false;
211
218
 
212
219
  this.startTime = platform.getTimeStamp();
213
220
 
@@ -372,6 +379,7 @@ export class Stage {
372
379
  w: appWidth,
373
380
  h: appHeight,
374
381
  alpha: 1,
382
+ ignoreParentAlpha: false,
375
383
  autosize: false,
376
384
  boundsMargin: null,
377
385
  clipping: false,
@@ -1032,6 +1040,7 @@ export class Stage {
1032
1040
  w: props.w ?? 0,
1033
1041
  h: props.h ?? 0,
1034
1042
  alpha: props.alpha ?? 1,
1043
+ ignoreParentAlpha: props.ignoreParentAlpha ?? false,
1035
1044
  autosize: props.autosize ?? false,
1036
1045
  boundsMargin: props.boundsMargin ?? null,
1037
1046
  clipping: props.clipping ?? false,
@@ -0,0 +1,91 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { CoreNode } from '../CoreNode.js';
3
+ import { sortByZIndexStable } from './collectionUtils.js';
4
+
5
+ const makeNode = (zIndex: number, tag: number): CoreNode =>
6
+ ({ props: { zIndex }, tag } as unknown as CoreNode);
7
+
8
+ const tags = (nodes: CoreNode[]): number[] => {
9
+ const out: number[] = [];
10
+ for (let i = 0; i < nodes.length; i++) {
11
+ out.push((nodes[i] as unknown as { tag: number }).tag);
12
+ }
13
+ return out;
14
+ };
15
+
16
+ describe('sortByZIndexStable', () => {
17
+ it('should sort nodes ascending by zIndex', () => {
18
+ const nodes = [makeNode(3, 0), makeNode(1, 1), makeNode(2, 2)];
19
+
20
+ sortByZIndexStable(nodes);
21
+
22
+ expect(tags(nodes)).toEqual([1, 2, 0]);
23
+ });
24
+
25
+ it('should preserve insertion order for equal zIndex values', () => {
26
+ const nodes = [
27
+ makeNode(1, 0),
28
+ makeNode(0, 1),
29
+ makeNode(1, 2),
30
+ makeNode(0, 3),
31
+ makeNode(1, 4),
32
+ ];
33
+
34
+ sortByZIndexStable(nodes);
35
+
36
+ expect(tags(nodes)).toEqual([1, 3, 0, 2, 4]);
37
+ });
38
+
39
+ it('should be stable for arrays longer than 10 elements', () => {
40
+ // V8 on Chrome < 70 switched to an unstable quicksort above 10 elements;
41
+ // this guards the case the native sort got wrong.
42
+ const nodes: CoreNode[] = [];
43
+ for (let i = 0; i < 20; i++) {
44
+ nodes.push(makeNode(i % 2, i));
45
+ }
46
+
47
+ sortByZIndexStable(nodes);
48
+
49
+ const expected: number[] = [];
50
+ for (let i = 0; i < 20; i += 2) {
51
+ expected.push(i);
52
+ }
53
+ for (let i = 1; i < 20; i += 2) {
54
+ expected.push(i);
55
+ }
56
+ expect(tags(nodes)).toEqual(expected);
57
+ });
58
+
59
+ it('should handle negative and fractional zIndex values', () => {
60
+ const nodes = [
61
+ makeNode(0.5, 0),
62
+ makeNode(-2, 1),
63
+ makeNode(1000000000, 2),
64
+ makeNode(0, 3),
65
+ makeNode(-2, 4),
66
+ ];
67
+
68
+ sortByZIndexStable(nodes);
69
+
70
+ expect(tags(nodes)).toEqual([1, 4, 3, 0, 2]);
71
+ });
72
+
73
+ it('should leave an already sorted array unchanged', () => {
74
+ const nodes = [makeNode(0, 0), makeNode(0, 1), makeNode(1, 2)];
75
+
76
+ sortByZIndexStable(nodes);
77
+
78
+ expect(tags(nodes)).toEqual([0, 1, 2]);
79
+ });
80
+
81
+ it('should handle empty and single-element arrays', () => {
82
+ const empty: CoreNode[] = [];
83
+ const single = [makeNode(5, 0)];
84
+
85
+ sortByZIndexStable(empty);
86
+ sortByZIndexStable(single);
87
+
88
+ expect(empty.length).toBe(0);
89
+ expect(tags(single)).toEqual([0]);
90
+ });
91
+ });
@@ -1,44 +1,23 @@
1
1
  import type { CoreNode } from '../CoreNode.js';
2
2
 
3
- //Bucket sort implementation for sorting CoreNode arrays by zIndex
4
- export const bucketSortByZIndex = (nodes: CoreNode[], min: number): void => {
5
- const buckets: CoreNode[][] = [];
6
- const bucketIndices: number[] = [];
7
- //distribute nodes into buckets
8
- for (let i = 0; i < nodes.length; i++) {
3
+ // Stable in-place sort by zIndex. Array.prototype.sort is not stable on
4
+ // Chrome < 70 (V8 quicksorts arrays longer than 10), and equal-zIndex
5
+ // siblings must keep insertion order or paint order silently reshuffles.
6
+ // Children arrays are small and nearly sorted (kept sorted; a re-sort
7
+ // usually follows a single zIndex change), so insertion sort is O(n) in
8
+ // practice and allocation-free.
9
+ export const sortByZIndexStable = (nodes: CoreNode[]): void => {
10
+ const len = nodes.length;
11
+ for (let i = 1; i < len; i++) {
9
12
  const node = nodes[i]!;
10
- const index = node.props.zIndex - min;
11
- //create bucket if it doesn't exist
12
- if (buckets[index] === undefined) {
13
- buckets[index] = [];
14
- bucketIndices.push(index);
15
- }
16
- buckets[index]!.push(node);
17
- }
18
-
19
- //sort each bucket using insertion sort
20
- for (let i = 1; i < bucketIndices.length; i++) {
21
- const key = bucketIndices[i]!;
13
+ const z = node.props.zIndex;
22
14
  let j = i - 1;
23
- while (j >= 0 && bucketIndices[j]! > key) {
24
- bucketIndices[j + 1] = bucketIndices[j]!;
15
+ while (j >= 0 && nodes[j]!.props.zIndex > z) {
16
+ nodes[j + 1] = nodes[j]!;
25
17
  j--;
26
18
  }
27
- bucketIndices[j + 1] = key;
19
+ nodes[j + 1] = node;
28
20
  }
29
-
30
- //flatten buckets
31
- let idx = 0;
32
- for (let i = 0; i < bucketIndices.length; i++) {
33
- const bucket = buckets[bucketIndices[i]!]!;
34
- for (let j = 0; j < bucket.length; j++) {
35
- nodes[idx++] = bucket[j]!;
36
- }
37
- }
38
-
39
- //clean up
40
- buckets.length = 0;
41
- bucketIndices.length = 0;
42
21
  };
43
22
 
44
23
  export const incrementalRepositionByZIndex = (