From f4b451ef7ca0845517714713e4bea60ebbc62054 Mon Sep 17 00:00:00 2001 From: Caleb Foss Date: Tue, 11 Nov 2025 19:52:51 -0600 Subject: [PATCH] Rework removal of c2d elements Bypass default remove method, instead queuing the element's removal on the parent c2d-canvas and queueing another render. At the start of the render method, remove all children queued for removal. This fixes bugs caused by removing elements while the canvas is in the middle of rendering. --- src/elements/visual/c2dBase.ts | 4 ++++ src/elements/visual/canvas.ts | 20 ++++++++++++++++++++ tests/c2d-bezier.test.ts | 5 +++++ tests/c2d-ellipse.test.ts | 5 +++++ tests/c2d-image.test.ts | 3 +++ tests/c2d-line.test.ts | 5 +++++ tests/c2d-rectangle.test.ts | 5 +++++ tests/c2d-shape.test.ts | 3 +++ tests/c2d-text.test.ts | 3 +++ tests/c2d-video.test.ts | 3 +++ tests/testRemoval.ts | 29 +++++++++++++++++++++++++++++ 11 files changed, 85 insertions(+) create mode 100644 tests/testRemoval.ts diff --git a/src/elements/visual/c2dBase.ts b/src/elements/visual/c2dBase.ts index ce0c47b..091c61c 100644 --- a/src/elements/visual/c2dBase.ts +++ b/src/elements/visual/c2dBase.ts @@ -61,6 +61,10 @@ export class C2DBase extends CustomHTMLElement { return this.#eventProxy; } + remove() { + this.canvas.queueRemoval(this); + } + /** * Scales a vector by the device's pixel ratio. */ diff --git a/src/elements/visual/canvas.ts b/src/elements/visual/canvas.ts index e9696fb..b684a69 100644 --- a/src/elements/visual/canvas.ts +++ b/src/elements/visual/canvas.ts @@ -31,6 +31,7 @@ export class Canvas2DCanvasElement extends c2dStandaloneChildren(C2DBase) { #keyboardTracker = new KeyboardTracker(); #lastFrameTime = -1; #mouseTracker: MouseTracker; + #removalQueue = new Set(); #renderEvents = new Set(); #renderQueued = false; #setAlpha: number | null = null; @@ -250,6 +251,12 @@ export class Canvas2DCanvasElement extends c2dStandaloneChildren(C2DBase) { return this.#mouseTracker; } + queueRemoval(child: HTMLElement) { + this.#removalQueue.add(child); + + this.queueRender(); + } + queueRender() { if (this.#renderQueued || this.#waitFor.size) return; @@ -323,6 +330,19 @@ export class Canvas2DCanvasElement extends c2dStandaloneChildren(C2DBase) { return; } + while (this.#removalQueue.size) { + const next = this.#removalQueue.values().next(); + + if (next.value === undefined) + throw new Error("Found undefined value in canvas's removal queue."); + + const child = next.value; + + this.removeChild(child); + + this.#removalQueue.delete(child); + } + const context = this.#context; context.save(); diff --git a/tests/c2d-bezier.test.ts b/tests/c2d-bezier.test.ts index f687c72..389f460 100644 --- a/tests/c2d-bezier.test.ts +++ b/tests/c2d-bezier.test.ts @@ -12,6 +12,7 @@ import { Canvas2DBezier, Canvas2DShapeBezier, } from "../dist/types/elements/visual/bezier"; +import { testRemoval } from "./testRemoval"; function testControlPoints( setup: ElementTestSetup<{ controlA: Vector2D; controlB: Vector2D }> @@ -169,6 +170,8 @@ describe("c2d-bezier", () => { testFill(setup, "bezierCurveTo"); testShadow(setup, "bezierCurveTo"); + + testRemoval(setup); }); describe("c2d-shape-bezier", () => { @@ -189,4 +192,6 @@ describe("c2d-shape-bezier", () => { testControlPoints(setup); testTransform(setup, 1); + + testRemoval(setup); }); diff --git a/tests/c2d-ellipse.test.ts b/tests/c2d-ellipse.test.ts index 85f7101..a2c18c9 100644 --- a/tests/c2d-ellipse.test.ts +++ b/tests/c2d-ellipse.test.ts @@ -14,6 +14,7 @@ import { Canvas2DEllipse, Canvas2DShapeEllipse, } from "../dist/types/elements/visual/ellipse"; +import { testRemoval } from "./testRemoval"; function testStartEndAngles( setup: ElementTestSetup<{ startAngle: Angle; endAngle: Angle }> @@ -91,6 +92,8 @@ describe("c2d-ellipse", () => { testFill(setup, "ellipse"); testShadow(setup, "ellipse"); + + testRemoval(setup); }); describe("c2d-shape-ellipse", () => { @@ -111,4 +114,6 @@ describe("c2d-shape-ellipse", () => { testTransform(setup, 1); testRectangleBounds(setup, "ellipse", 0.5); + + testRemoval(setup); }); diff --git a/tests/c2d-image.test.ts b/tests/c2d-image.test.ts index c567e84..18abc01 100644 --- a/tests/c2d-image.test.ts +++ b/tests/c2d-image.test.ts @@ -7,6 +7,7 @@ import { testOffset } from "./testOffset"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DImage } from "../dist/types/elements/visual/image"; +import { testRemoval } from "./testRemoval"; describe("c2d-image", () => { mockMatchMedia(); @@ -91,4 +92,6 @@ describe("c2d-image", () => { testRectangleBounds(setup, "drawImage", 1, 3, 4); testShadow(setup, "drawImage"); + + testRemoval(setup); }); diff --git a/tests/c2d-line.test.ts b/tests/c2d-line.test.ts index 4465aaa..fb3a684 100644 --- a/tests/c2d-line.test.ts +++ b/tests/c2d-line.test.ts @@ -11,6 +11,7 @@ import { Canvas2DLine, Canvas2DShapeLine, } from "../dist/types/elements/visual/line"; +import { testRemoval } from "./testRemoval"; function testTo(setup: ElementTestSetup<{ to: Vector2D }>) { describe("to", () => { @@ -108,6 +109,8 @@ describe("c2d-line", () => { testStroke(setup, "lineTo"); testShadow(setup, "lineTo"); + + testRemoval(setup); }); describe("c2d-shape-line", () => { @@ -126,4 +129,6 @@ describe("c2d-shape-line", () => { testTo(setup); testTransform(setup, 1); + + testRemoval(setup); }); diff --git a/tests/c2d-rectangle.test.ts b/tests/c2d-rectangle.test.ts index 8739aad..c4ea5c9 100644 --- a/tests/c2d-rectangle.test.ts +++ b/tests/c2d-rectangle.test.ts @@ -14,6 +14,7 @@ import { Canvas2DRectangle, Canvas2DShapeRectangle, } from "../dist/types/elements/visual/rectangle"; +import { testRemoval } from "./testRemoval"; function testBorderRadius( setup: ElementTestSetup<{ @@ -209,6 +210,8 @@ describe("c2d-rectangle", () => { testFill(setup, "rect"); testShadow(setup, "rect"); + + testRemoval(setup); }); describe("c2d-shape-rectangle", () => { @@ -233,4 +236,6 @@ describe("c2d-shape-rectangle", () => { testBorderRadius(setup); testRectangleBounds(setup, "rect"); + + testRemoval(setup); }); diff --git a/tests/c2d-shape.test.ts b/tests/c2d-shape.test.ts index 650703e..7239391 100644 --- a/tests/c2d-shape.test.ts +++ b/tests/c2d-shape.test.ts @@ -10,6 +10,7 @@ import { waitFor } from "@testing-library/dom"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DShape } from "../dist/types/elements/visual/shape"; +import { testRemoval } from "./testRemoval"; describe("c2d-shape", () => { setupJestCanvasMock(); @@ -73,4 +74,6 @@ describe("c2d-shape", () => { testStroke(setup, "moveTo"); testShadow(setup, "moveTo"); + + testRemoval(setup); }); diff --git a/tests/c2d-text.test.ts b/tests/c2d-text.test.ts index 8418a55..d85bce4 100644 --- a/tests/c2d-text.test.ts +++ b/tests/c2d-text.test.ts @@ -8,6 +8,7 @@ import { waitFor } from "@testing-library/dom"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DText } from "../dist/types/elements/visual/text"; +import { testRemoval } from "./testRemoval"; describe("c2d-text", () => { setupJestCanvasMock(); @@ -130,4 +131,6 @@ describe("c2d-text", () => { testFill(setup, "fillText", false); testShadow(setup, "fillText"); + + testRemoval(setup); }); diff --git a/tests/c2d-video.test.ts b/tests/c2d-video.test.ts index d30c9cb..239338b 100644 --- a/tests/c2d-video.test.ts +++ b/tests/c2d-video.test.ts @@ -9,6 +9,7 @@ import { waitFor } from "@testing-library/dom"; import { testShadow } from "./testShadow"; import { ElementTestSetup } from "./types"; import { Canvas2DVideo } from "../dist/types/elements/visual/video"; +import { testRemoval } from "./testRemoval"; describe("c2d-video", () => { mockMatchMedia(); @@ -145,4 +146,6 @@ describe("c2d-video", () => { }); testShadow(setup, "drawImage"); + + testRemoval(setup); }); diff --git a/tests/testRemoval.ts b/tests/testRemoval.ts new file mode 100644 index 0000000..697f7c2 --- /dev/null +++ b/tests/testRemoval.ts @@ -0,0 +1,29 @@ +import { jest } from "@jest/globals"; +import { waitFor } from "@testing-library/dom"; +import { ElementTestSetup } from "./types"; + +export function testRemoval(setup: ElementTestSetup<{}>) { + test("Queues canvas render when removed", async () => { + const { canvas, element, teardown } = setup(); + + const queueRemoval = jest.spyOn(canvas, "queueRemoval"); + + const queueRender = jest.spyOn(canvas, "queueRender"); + + expect(queueRemoval).not.toHaveBeenCalled(); + + expect(queueRender).not.toHaveBeenCalled(); + + expect(canvas.contains(element)).toBe(true); + + element.remove(); + + expect(queueRemoval).toHaveBeenCalled(); + + expect(queueRender).toHaveBeenCalled(); + + await waitFor(() => !canvas.contains(element)); + + teardown(); + }); +}