From 17f4b7cf48b04fc9d2aa33a3f923667b2d79ba9d Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 23 Sep 2026 16:26:33 +0200 Subject: [PATCH 1/4] fix: avoid Array.prototype.toSorted in keyframe normalization toSorted is Chrome 110+ only; older mobile Chromium throws during edit.load. Use slice().sort with the same comparators so keyframe and opacity paths keep working on older browsers. --- src/core/animations/keyframe-builder.ts | 4 ++- src/core/animations/opacity-keyframes.ts | 3 ++- tests/keyframe-builder.test.ts | 33 ++++++++++++++++++++++++ tests/opacity-keyframes.test.ts | 23 +++++++++++++++++ 4 files changed, 61 insertions(+), 2 deletions(-) diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index ece0e132..dd6be449 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -130,7 +130,9 @@ export class KeyframeBuilder { private createNormalizedKeyframes(keyframes: Keyframe[]): NumericKeyframe[] { return keyframes .filter((kf): kf is Keyframe & { start: number; length: number } => typeof kf.start === "number" && typeof kf.length === "number") - .toSorted((a, b) => a.start - b.start) + // Use slice().sort — Array.prototype.toSorted is Chrome 110+ / Safari 16+ only. + .slice() + .sort((a, b) => a.start - b.start) .map(keyframe => ({ start: keyframe.start, length: keyframe.length, diff --git a/src/core/animations/opacity-keyframes.ts b/src/core/animations/opacity-keyframes.ts index 53e1f834..01a70483 100644 --- a/src/core/animations/opacity-keyframes.ts +++ b/src/core/animations/opacity-keyframes.ts @@ -149,7 +149,8 @@ export function upsertOpacityPoint( const time = snapOpacityTime(localTime, clipLength, fps); const existing = findOpacityPoint(points, time, fps); const next = existing ? points.map(point => (point === existing ? { time: point.time, value } : point)) : [...points, { time, value }]; - return next.toSorted((a, b) => a.time - b.time); + // Use slice().sort — Array.prototype.toSorted is Chrome 110+ / Safari 16+ only. + return next.slice().sort((a, b) => a.time - b.time); } export function removeOpacityPoint(points: readonly OpacityPoint[], localTime: number, fps: number): OpacityPoint[] { diff --git a/tests/keyframe-builder.test.ts b/tests/keyframe-builder.test.ts index 54d81bc4..979dd021 100644 --- a/tests/keyframe-builder.test.ts +++ b/tests/keyframe-builder.test.ts @@ -202,4 +202,37 @@ describe("KeyframeBuilder", () => { ).toThrow("Overlapping keyframes detected."); }); }); + + describe("toSorted-compat (older Chromium)", () => { + it("sorts unsorted keyframes by start without Array.prototype.toSorted", () => { + const original = Array.prototype.toSorted; + // Simulate Chrome < 110 / Safari < 16 where toSorted is missing. + // @ts-expect-error — intentionally removing a newer Array method + delete Array.prototype.toSorted; + + try { + const builder = new KeyframeBuilder( + [ + { start: 5, length: 5, from: 0.5, to: 1, interpolation: "linear" }, + { start: 0, length: 5, from: 0, to: 0.5, interpolation: "linear" } + ], + 10 + ); + + expect(builder.getValue(0)).toBe(0); + expect(builder.getValue(2.5)).toBeCloseTo(0.25); + expect(builder.getValue(5)).toBe(0.5); + expect(builder.getValue(7.5)).toBeCloseTo(0.75); + } finally { + if (original) { + Object.defineProperty(Array.prototype, "toSorted", { + configurable: true, + writable: true, + value: original + }); + } + } + }); + }); + }); diff --git a/tests/opacity-keyframes.test.ts b/tests/opacity-keyframes.test.ts index bc62cf7f..e84dd39e 100644 --- a/tests/opacity-keyframes.test.ts +++ b/tests/opacity-keyframes.test.ts @@ -107,4 +107,27 @@ describe("opacity keyframe editing", () => { expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "linear" }], 1, 2)).toBe(0.5); expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "bezier", easing: "ease" }], 1, 2)).toBeCloseTo(0.8024); }); + + it("upserts and sorts points without Array.prototype.toSorted", () => { + const original = Array.prototype.toSorted; + // @ts-expect-error — intentionally removing a newer Array method + delete Array.prototype.toSorted; + + try { + const first = upsertOpacityPoint([], 2, 0.75, 5, 30); + const second = upsertOpacityPoint(first, 1, 0.25, 5, 30); + + expect(second.map(point => point.time)).toEqual([1, 2]); + expect(second.map(point => point.value)).toEqual([0.25, 0.75]); + } finally { + if (original) { + Object.defineProperty(Array.prototype, "toSorted", { + configurable: true, + writable: true, + value: original + }); + } + } + }); + }); From 15ddb9e51020b694facb3b88b9978b3adc97e637 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Wed, 23 Sep 2026 16:32:30 +0200 Subject: [PATCH 2/4] test: assert keyframe sort without mutating Array.prototype Remove delete/restore of Array.prototype.toSorted in unit tests (eslint no-extend-native). Prove slice().sort via unsorted public API inputs instead. --- tests/keyframe-builder.test.ts | 44 +++++++++++---------------------- tests/opacity-keyframes.test.ts | 29 ++++++---------------- 2 files changed, 23 insertions(+), 50 deletions(-) diff --git a/tests/keyframe-builder.test.ts b/tests/keyframe-builder.test.ts index 979dd021..67f935eb 100644 --- a/tests/keyframe-builder.test.ts +++ b/tests/keyframe-builder.test.ts @@ -204,35 +204,21 @@ describe("KeyframeBuilder", () => { }); describe("toSorted-compat (older Chromium)", () => { - it("sorts unsorted keyframes by start without Array.prototype.toSorted", () => { - const original = Array.prototype.toSorted; - // Simulate Chrome < 110 / Safari < 16 where toSorted is missing. - // @ts-expect-error — intentionally removing a newer Array method - delete Array.prototype.toSorted; - - try { - const builder = new KeyframeBuilder( - [ - { start: 5, length: 5, from: 0.5, to: 1, interpolation: "linear" }, - { start: 0, length: 5, from: 0, to: 0.5, interpolation: "linear" } - ], - 10 - ); - - expect(builder.getValue(0)).toBe(0); - expect(builder.getValue(2.5)).toBeCloseTo(0.25); - expect(builder.getValue(5)).toBe(0.5); - expect(builder.getValue(7.5)).toBeCloseTo(0.75); - } finally { - if (original) { - Object.defineProperty(Array.prototype, "toSorted", { - configurable: true, - writable: true, - value: original - }); - } - } + it("sorts unsorted keyframes by start via slice().sort (no Array.prototype.toSorted)", () => { + // Unsorted input exercises the production normalize path that uses + // slice().sort — compatible with Chrome < 110 / Safari < 16. + const builder = new KeyframeBuilder( + [ + { start: 5, length: 5, from: 0.5, to: 1, interpolation: "linear" }, + { start: 0, length: 5, from: 0, to: 0.5, interpolation: "linear" } + ], + 10 + ); + + expect(builder.getValue(0)).toBe(0); + expect(builder.getValue(2.5)).toBeCloseTo(0.25); + expect(builder.getValue(5)).toBe(0.5); + expect(builder.getValue(7.5)).toBeCloseTo(0.75); }); }); - }); diff --git a/tests/opacity-keyframes.test.ts b/tests/opacity-keyframes.test.ts index e84dd39e..1c4b6a79 100644 --- a/tests/opacity-keyframes.test.ts +++ b/tests/opacity-keyframes.test.ts @@ -108,26 +108,13 @@ describe("opacity keyframe editing", () => { expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "bezier", easing: "ease" }], 1, 2)).toBeCloseTo(0.8024); }); - it("upserts and sorts points without Array.prototype.toSorted", () => { - const original = Array.prototype.toSorted; - // @ts-expect-error — intentionally removing a newer Array method - delete Array.prototype.toSorted; - - try { - const first = upsertOpacityPoint([], 2, 0.75, 5, 30); - const second = upsertOpacityPoint(first, 1, 0.25, 5, 30); - - expect(second.map(point => point.time)).toEqual([1, 2]); - expect(second.map(point => point.value)).toEqual([0.25, 0.75]); - } finally { - if (original) { - Object.defineProperty(Array.prototype, "toSorted", { - configurable: true, - writable: true, - value: original - }); - } - } + it("upserts and sorts points via slice().sort (no Array.prototype.toSorted)", () => { + // Insert out of order so the production upsert path must sort with + // slice().sort — compatible with Chrome < 110 / Safari < 16. + const first = upsertOpacityPoint([], 2, 0.75, 5, 30); + const second = upsertOpacityPoint(first, 1, 0.25, 5, 30); + + expect(second.map(point => point.time)).toEqual([1, 2]); + expect(second.map(point => point.value)).toEqual([0.25, 0.75]); }); - }); From d41ea22e2b106bfdd7937c69c76be7538d683365 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 25 Sep 2026 09:34:53 +1000 Subject: [PATCH 3/4] test: enforce keyframe compatibility without toSorted --- src/core/animations/keyframe-builder.ts | 2 -- src/core/animations/opacity-keyframes.ts | 3 +- tests/keyframe-builder.test.ts | 38 ++++++++++++++---------- tests/opacity-keyframes.test.ts | 22 +++++++++----- 4 files changed, 37 insertions(+), 28 deletions(-) diff --git a/src/core/animations/keyframe-builder.ts b/src/core/animations/keyframe-builder.ts index dd6be449..dcd96f2a 100644 --- a/src/core/animations/keyframe-builder.ts +++ b/src/core/animations/keyframe-builder.ts @@ -130,8 +130,6 @@ export class KeyframeBuilder { private createNormalizedKeyframes(keyframes: Keyframe[]): NumericKeyframe[] { return keyframes .filter((kf): kf is Keyframe & { start: number; length: number } => typeof kf.start === "number" && typeof kf.length === "number") - // Use slice().sort — Array.prototype.toSorted is Chrome 110+ / Safari 16+ only. - .slice() .sort((a, b) => a.start - b.start) .map(keyframe => ({ start: keyframe.start, diff --git a/src/core/animations/opacity-keyframes.ts b/src/core/animations/opacity-keyframes.ts index 01a70483..33aa7f12 100644 --- a/src/core/animations/opacity-keyframes.ts +++ b/src/core/animations/opacity-keyframes.ts @@ -149,8 +149,7 @@ export function upsertOpacityPoint( const time = snapOpacityTime(localTime, clipLength, fps); const existing = findOpacityPoint(points, time, fps); const next = existing ? points.map(point => (point === existing ? { time: point.time, value } : point)) : [...points, { time, value }]; - // Use slice().sort — Array.prototype.toSorted is Chrome 110+ / Safari 16+ only. - return next.slice().sort((a, b) => a.time - b.time); + return next.sort((a, b) => a.time - b.time); } export function removeOpacityPoint(points: readonly OpacityPoint[], localTime: number, fps: number): OpacityPoint[] { diff --git a/tests/keyframe-builder.test.ts b/tests/keyframe-builder.test.ts index 67f935eb..62e4a889 100644 --- a/tests/keyframe-builder.test.ts +++ b/tests/keyframe-builder.test.ts @@ -203,22 +203,28 @@ describe("KeyframeBuilder", () => { }); }); - describe("toSorted-compat (older Chromium)", () => { - it("sorts unsorted keyframes by start via slice().sort (no Array.prototype.toSorted)", () => { - // Unsorted input exercises the production normalize path that uses - // slice().sort — compatible with Chrome < 110 / Safari < 16. - const builder = new KeyframeBuilder( - [ - { start: 5, length: 5, from: 0.5, to: 1, interpolation: "linear" }, - { start: 0, length: 5, from: 0, to: 0.5, interpolation: "linear" } - ], - 10 - ); - - expect(builder.getValue(0)).toBe(0); - expect(builder.getValue(2.5)).toBeCloseTo(0.25); - expect(builder.getValue(5)).toBe(0.5); - expect(builder.getValue(7.5)).toBeCloseTo(0.75); + describe("older browser compatibility", () => { + it("sorts unsorted keyframes without Array.prototype.toSorted", () => { + const { toSorted } = Array.prototype; + // eslint-disable-next-line no-extend-native -- Simulate a browser without toSorted for this regression test. + Object.defineProperty(Array.prototype, "toSorted", { value: undefined }); + try { + const builder = new KeyframeBuilder( + [ + { start: 5, length: 5, from: 0.5, to: 1, interpolation: "linear" }, + { start: 0, length: 5, from: 0, to: 0.5, interpolation: "linear" } + ], + 10 + ); + + expect(builder.getValue(0)).toBe(0); + expect(builder.getValue(2.5)).toBeCloseTo(0.25); + expect(builder.getValue(5)).toBe(0.5); + expect(builder.getValue(7.5)).toBeCloseTo(0.75); + } finally { + // eslint-disable-next-line no-extend-native -- Restore the native method even when an assertion fails. + Object.defineProperty(Array.prototype, "toSorted", { value: toSorted }); + } }); }); }); diff --git a/tests/opacity-keyframes.test.ts b/tests/opacity-keyframes.test.ts index 1c4b6a79..c30e574b 100644 --- a/tests/opacity-keyframes.test.ts +++ b/tests/opacity-keyframes.test.ts @@ -108,13 +108,19 @@ describe("opacity keyframe editing", () => { expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "bezier", easing: "ease" }], 1, 2)).toBeCloseTo(0.8024); }); - it("upserts and sorts points via slice().sort (no Array.prototype.toSorted)", () => { - // Insert out of order so the production upsert path must sort with - // slice().sort — compatible with Chrome < 110 / Safari < 16. - const first = upsertOpacityPoint([], 2, 0.75, 5, 30); - const second = upsertOpacityPoint(first, 1, 0.25, 5, 30); - - expect(second.map(point => point.time)).toEqual([1, 2]); - expect(second.map(point => point.value)).toEqual([0.25, 0.75]); + it("upserts and sorts points without Array.prototype.toSorted", () => { + const { toSorted } = Array.prototype; + // eslint-disable-next-line no-extend-native -- Simulate a browser without toSorted for this regression test. + Object.defineProperty(Array.prototype, "toSorted", { value: undefined }); + try { + const first = upsertOpacityPoint([], 2, 0.75, 5, 30); + const second = upsertOpacityPoint(first, 1, 0.25, 5, 30); + + expect(second.map(point => point.time)).toEqual([1, 2]); + expect(second.map(point => point.value)).toEqual([0.25, 0.75]); + } finally { + // eslint-disable-next-line no-extend-native -- Restore the native method even when an assertion fails. + Object.defineProperty(Array.prototype, "toSorted", { value: toSorted }); + } }); }); From 83516688b8f6999758218ffafacd76c13df7a0f9 Mon Sep 17 00:00:00 2001 From: dazzatronus Date: Fri, 25 Sep 2026 09:49:36 +1000 Subject: [PATCH 4/4] test: remove browser compatibility checks from SDK suite --- tests/keyframe-builder.test.ts | 25 ------------------------- tests/opacity-keyframes.test.ts | 16 ---------------- 2 files changed, 41 deletions(-) diff --git a/tests/keyframe-builder.test.ts b/tests/keyframe-builder.test.ts index 62e4a889..54d81bc4 100644 --- a/tests/keyframe-builder.test.ts +++ b/tests/keyframe-builder.test.ts @@ -202,29 +202,4 @@ describe("KeyframeBuilder", () => { ).toThrow("Overlapping keyframes detected."); }); }); - - describe("older browser compatibility", () => { - it("sorts unsorted keyframes without Array.prototype.toSorted", () => { - const { toSorted } = Array.prototype; - // eslint-disable-next-line no-extend-native -- Simulate a browser without toSorted for this regression test. - Object.defineProperty(Array.prototype, "toSorted", { value: undefined }); - try { - const builder = new KeyframeBuilder( - [ - { start: 5, length: 5, from: 0.5, to: 1, interpolation: "linear" }, - { start: 0, length: 5, from: 0, to: 0.5, interpolation: "linear" } - ], - 10 - ); - - expect(builder.getValue(0)).toBe(0); - expect(builder.getValue(2.5)).toBeCloseTo(0.25); - expect(builder.getValue(5)).toBe(0.5); - expect(builder.getValue(7.5)).toBeCloseTo(0.75); - } finally { - // eslint-disable-next-line no-extend-native -- Restore the native method even when an assertion fails. - Object.defineProperty(Array.prototype, "toSorted", { value: toSorted }); - } - }); - }); }); diff --git a/tests/opacity-keyframes.test.ts b/tests/opacity-keyframes.test.ts index c30e574b..bc62cf7f 100644 --- a/tests/opacity-keyframes.test.ts +++ b/tests/opacity-keyframes.test.ts @@ -107,20 +107,4 @@ describe("opacity keyframe editing", () => { expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "linear" }], 1, 2)).toBe(0.5); expect(evaluateOpacity([{ from: 0, to: 1, start: 0, length: 2, interpolation: "bezier", easing: "ease" }], 1, 2)).toBeCloseTo(0.8024); }); - - it("upserts and sorts points without Array.prototype.toSorted", () => { - const { toSorted } = Array.prototype; - // eslint-disable-next-line no-extend-native -- Simulate a browser without toSorted for this regression test. - Object.defineProperty(Array.prototype, "toSorted", { value: undefined }); - try { - const first = upsertOpacityPoint([], 2, 0.75, 5, 30); - const second = upsertOpacityPoint(first, 1, 0.25, 5, 30); - - expect(second.map(point => point.time)).toEqual([1, 2]); - expect(second.map(point => point.value)).toEqual([0.25, 0.75]); - } finally { - // eslint-disable-next-line no-extend-native -- Restore the native method even when an assertion fails. - Object.defineProperty(Array.prototype, "toSorted", { value: toSorted }); - } - }); });