diff --git a/src/core/CoreNode.ts b/src/core/CoreNode.ts index 001d2e8..946a5d1 100644 --- a/src/core/CoreNode.ts +++ b/src/core/CoreNode.ts @@ -46,7 +46,7 @@ import type { IAnimationController } from '../common/IAnimationController.js'; import { createAnimation } from './animations/CoreAnimation.js'; import type { CoreShaderNode } from './renderers/CoreShaderNode.js'; import { AutosizeMode, Autosizer } from './Autosizer.js'; -import { removeChild } from './lib/collectionUtils.js'; +import { removeChild, sortByZIndexStable } from './lib/collectionUtils.js'; export enum CoreNodeRenderState { Init = 0, @@ -63,11 +63,6 @@ const NO_CLIPPING_RECT: RectWithValid = { valid: false, }; -// Hoisted so `sortChildren` doesn't allocate a fresh comparator closure on -// every z-index reorder. -const compareZIndex = (a: CoreNode, b: CoreNode): number => - a.props.zIndex - b.props.zIndex; - const CoreNodeRenderStateMap: Map = new Map(); CoreNodeRenderStateMap.set(CoreNodeRenderState.Init, 'init'); CoreNodeRenderStateMap.set(CoreNodeRenderState.OutOfBounds, 'outOfBounds'); @@ -2207,7 +2202,7 @@ export class CoreNode extends EventEmitter { } sortChildren() { - this.children.sort(compareZIndex); + sortByZIndexStable(this.children); this.stage.requestRenderListUpdate(); } diff --git a/src/core/lib/collectionUtils.test.ts b/src/core/lib/collectionUtils.test.ts new file mode 100644 index 0000000..73f6e12 --- /dev/null +++ b/src/core/lib/collectionUtils.test.ts @@ -0,0 +1,91 @@ +import { describe, it, expect } from 'vitest'; +import type { CoreNode } from '../CoreNode.js'; +import { sortByZIndexStable } from './collectionUtils.js'; + +const makeNode = (zIndex: number, tag: number): CoreNode => + ({ props: { zIndex }, tag } as unknown as CoreNode); + +const tags = (nodes: CoreNode[]): number[] => { + const out: number[] = []; + for (let i = 0; i < nodes.length; i++) { + out.push((nodes[i] as unknown as { tag: number }).tag); + } + return out; +}; + +describe('sortByZIndexStable', () => { + it('should sort nodes ascending by zIndex', () => { + const nodes = [makeNode(3, 0), makeNode(1, 1), makeNode(2, 2)]; + + sortByZIndexStable(nodes); + + expect(tags(nodes)).toEqual([1, 2, 0]); + }); + + it('should preserve insertion order for equal zIndex values', () => { + const nodes = [ + makeNode(1, 0), + makeNode(0, 1), + makeNode(1, 2), + makeNode(0, 3), + makeNode(1, 4), + ]; + + sortByZIndexStable(nodes); + + expect(tags(nodes)).toEqual([1, 3, 0, 2, 4]); + }); + + it('should be stable for arrays longer than 10 elements', () => { + // V8 on Chrome < 70 switched to an unstable quicksort above 10 elements; + // this guards the case the native sort got wrong. + const nodes: CoreNode[] = []; + for (let i = 0; i < 20; i++) { + nodes.push(makeNode(i % 2, i)); + } + + sortByZIndexStable(nodes); + + const expected: number[] = []; + for (let i = 0; i < 20; i += 2) { + expected.push(i); + } + for (let i = 1; i < 20; i += 2) { + expected.push(i); + } + expect(tags(nodes)).toEqual(expected); + }); + + it('should handle negative and fractional zIndex values', () => { + const nodes = [ + makeNode(0.5, 0), + makeNode(-2, 1), + makeNode(1000000000, 2), + makeNode(0, 3), + makeNode(-2, 4), + ]; + + sortByZIndexStable(nodes); + + expect(tags(nodes)).toEqual([1, 4, 3, 0, 2]); + }); + + it('should leave an already sorted array unchanged', () => { + const nodes = [makeNode(0, 0), makeNode(0, 1), makeNode(1, 2)]; + + sortByZIndexStable(nodes); + + expect(tags(nodes)).toEqual([0, 1, 2]); + }); + + it('should handle empty and single-element arrays', () => { + const empty: CoreNode[] = []; + const single = [makeNode(5, 0)]; + + sortByZIndexStable(empty); + sortByZIndexStable(single); + + expect(empty.length).toBe(0); + expect(tags(single)).toEqual([0]); + }); +}); diff --git a/src/core/lib/collectionUtils.ts b/src/core/lib/collectionUtils.ts index 32830a2..2e7c590 100644 --- a/src/core/lib/collectionUtils.ts +++ b/src/core/lib/collectionUtils.ts @@ -1,44 +1,23 @@ import type { CoreNode } from '../CoreNode.js'; -//Bucket sort implementation for sorting CoreNode arrays by zIndex -export const bucketSortByZIndex = (nodes: CoreNode[], min: number): void => { - const buckets: CoreNode[][] = []; - const bucketIndices: number[] = []; - //distribute nodes into buckets - for (let i = 0; i < nodes.length; i++) { +// Stable in-place sort by zIndex. Array.prototype.sort is not stable on +// Chrome < 70 (V8 quicksorts arrays longer than 10), and equal-zIndex +// siblings must keep insertion order or paint order silently reshuffles. +// Children arrays are small and nearly sorted (kept sorted; a re-sort +// usually follows a single zIndex change), so insertion sort is O(n) in +// practice and allocation-free. +export const sortByZIndexStable = (nodes: CoreNode[]): void => { + const len = nodes.length; + for (let i = 1; i < len; i++) { const node = nodes[i]!; - const index = node.props.zIndex - min; - //create bucket if it doesn't exist - if (buckets[index] === undefined) { - buckets[index] = []; - bucketIndices.push(index); - } - buckets[index]!.push(node); - } - - //sort each bucket using insertion sort - for (let i = 1; i < bucketIndices.length; i++) { - const key = bucketIndices[i]!; + const z = node.props.zIndex; let j = i - 1; - while (j >= 0 && bucketIndices[j]! > key) { - bucketIndices[j + 1] = bucketIndices[j]!; + while (j >= 0 && nodes[j]!.props.zIndex > z) { + nodes[j + 1] = nodes[j]!; j--; } - bucketIndices[j + 1] = key; + nodes[j + 1] = node; } - - //flatten buckets - let idx = 0; - for (let i = 0; i < bucketIndices.length; i++) { - const bucket = buckets[bucketIndices[i]!]!; - for (let j = 0; j < bucket.length; j++) { - nodes[idx++] = bucket[j]!; - } - } - - //clean up - buckets.length = 0; - bucketIndices.length = 0; }; export const incrementalRepositionByZIndex = (