import { describe, it, expect, afterEach } from 'vitest'; import { config } from '../config'; import { Canvas } from './Canvas'; import { ActiveSelection, getFabricDocument, runningAnimations, StaticCanvas, util, } from '../../fabric'; import { makeRect } from '../../test/utils'; describe('Canvas dispose', () => { describe.for([ { name: 'StaticCanvas', CanvasClass: StaticCanvas }, { name: 'Canvas', CanvasClass: Canvas }, ])('disposing $name', ({ CanvasClass }) => { afterEach(() => { config.restoreDefaults(); }); it('dispose', async () => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); expect( canvas.destroyed, 'should not have been destroyed yet', ).toBeFalsy(); await canvas.dispose(); expect(canvas.destroyed, 'should have flagged `destroyed`').toBeTruthy(); }); it('dispose: clear references sync', () => { const el = getFabricDocument().createElement('canvas'); const parentEl = getFabricDocument().createElement('div'); el.width = 200; el.height = 200; parentEl.className = 'rootNode'; parentEl.appendChild(el); config.configure({ devicePixelRatio: 1.25 }); el.style.position = 'relative'; const elStyle = el.style.cssText; expect(elStyle, 'el style should not be empty').toBe( 'position: relative;', ); const canvas = new CanvasClass(el, { enableRetinaScaling: true, renderOnAddRemove: false, }); expect( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, 'saved original canvas style for disposal', ).toBe(elStyle); expect(el.style.cssText, 'canvas el style has been changed').not.toBe( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, ); expect( el.getAttribute('data-fabric'), 'lowerCanvasEl should be marked by fabric', ).toBe('main'); expect(canvas.dispose).toBeTypeOf('function'); expect(canvas.destroy).toBeTypeOf('function'); canvas.add(makeRect(), makeRect(), makeRect()); canvas.item(0).animate({ scaleX: 10 }); expect(runningAnimations.length, 'should have a running animation').toBe( 1, ); canvas.dispose(); expect(canvas.disposed, 'dispose should flag disposed').toBe(true); expect( el.hasAttribute('data-fabric'), 'dispose should clear lowerCanvasEl data-fabric attr', ).toBe(false); expect( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, 'removed original canvas style', ).toBeUndefined(); expect(el.style.cssText, 'restored original canvas style').toBe(elStyle); expect(el.width, 'restored width').toBe(200); expect(el.height, 'restored height').toBe(200); }); it('dispose: clear references async', async () => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); expect(canvas.dispose).toBeTypeOf('function'); expect(canvas.destroy).toBeTypeOf('function'); canvas.add(makeRect(), makeRect(), makeRect()); const lowerCanvas = canvas.lowerCanvasEl; expect( lowerCanvas.getAttribute('data-fabric'), 'lowerCanvasEl should be marked by fabric', ).toBe('main'); await canvas.dispose(); expect(canvas.destroyed, 'dispose should flag destroyed').toBe(true); expect(canvas.getObjects().length, 'dispose should clear canvas').toBe(0); expect( canvas.lowerCanvasEl, 'dispose should clear lowerCanvasEl', ).toBeUndefined(); expect( lowerCanvas.hasAttribute('data-fabric'), 'dispose should clear lowerCanvasEl data-fabric attr', ).toBe(false); expect( canvas.contextContainer, 'dispose should clear contextContainer', ).toBeUndefined(); }); it('dispose edge case: multiple calls', async () => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); expect( canvas.destroyed, 'should not have been destroyed yet', ).toBeFalsy(); const res = await Promise.all([ canvas.dispose(), canvas.dispose(), canvas.dispose(), ]); expect(canvas.disposed, 'should have flagged `disposed`').toBeTruthy(); expect(canvas.destroyed, 'should have flagged `destroyed`').toBeTruthy(); expect(res, 'should have disposed in the first call').toEqual([ true, false, false, ]); }); it('dispose edge case: multiple calls after `requestRenderAll`', async () => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); expect( canvas.destroyed, 'should not have been destroyed yet', ).toBeFalsy(); canvas.requestRenderAll(); const res = await Promise.allSettled([ canvas.dispose(), canvas.dispose(), canvas.dispose(), ]); expect(canvas.disposed, 'should have flagged `disposed`').toBeTruthy(); expect(canvas.destroyed, 'should have flagged `destroyed`').toBeTruthy(); expect( res, 'should have disposed in the last call, aborting the other calls', ).toEqual([ { status: 'rejected', reason: 'aborted' }, { status: 'rejected', reason: 'aborted' }, { status: 'fulfilled', value: true }, ]); }); it('dispose edge case: rendering after dispose', async () => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); let called = 0; expect(await canvas.dispose(), 'should dispose').toBeTruthy(); canvas.on('after:render', () => { called++; }); canvas.fire('after:render'); expect(called, 'should have fired').toBe(1); // @ts-expect-error -- protected property expect(canvas.nextRenderHandle).toBeUndefined(); canvas.requestRenderAll(); expect( // @ts-expect-error -- private property canvas.nextRenderHandle, '`requestRenderAll` should have no affect', ).toBeUndefined(); canvas.renderAll(); expect(called, 'should not have rendered, should still equal 1').toBe(1); }); it('dispose edge case: `toCanvasElement` interrupting `requestRenderAll`', async () => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); // @ts-expect-error -- private property expect(canvas.nextRenderHandle).toBeUndefined(); // @ts-expect-error -- private property canvas.nextRenderHandle = 1; canvas.toCanvasElement(); // @ts-expect-error -- private property expect(canvas.nextRenderHandle, 'should request rendering').toBe(1); }); it('dispose edge case: `toCanvasElement` after dispose', async () => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); const getAlphaValues = () => { return canvas .toCanvasElement() .getContext('2d')! .getImageData(10, 10, 20, 20) .data.filter((_, i) => i % 4 === 3); }; const hasOpaquePixels = () => getAlphaValues().some((x) => x === 255); const isFullyTransparent = () => getAlphaValues().every((x) => x === 0); canvas.add( makeRect({ fill: 'red', width: 20, height: 20, top: 10, left: 10 }), ); expect(hasOpaquePixels(), 'control').toBeTruthy(); canvas.disposed = true; expect(hasOpaquePixels(), 'should render canvas').toBeTruthy(); canvas.destroyed = true; expect( isFullyTransparent(), 'should have disabled canvas rendering', ).toBeTruthy(); canvas.destroyed = false; expect(await canvas.dispose(), 'dispose').toBeTruthy(); }); it('dispose edge case: during animation', () => { return new Promise((resolve) => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); let called = 0; const animate = () => util.animate({ onChange() { if (called === 1) { canvas.dispose().then(() => { runningAnimations.cancelAll(); resolve(); }); expect(canvas.disposed, 'should flag `disposed`').toBeTruthy(); } called++; (canvas as Canvas).contextTopDirty = true; // @ts-expect-error -- private property canvas.hasLostContext = true; canvas.renderAll(); }, onComplete() { animate(); }, }); animate(); }); }); it('disposing during animation should cancel it by target', () => { return new Promise((resolve) => { const canvas = new CanvasClass(undefined, { renderOnAddRemove: false }); let called = 0; const animate = () => util.animate({ target: canvas, onChange() { if (called === 1) { expect( runningAnimations[0].target, 'should register the animation by target', ).toBe(canvas); canvas.dispose().then(() => { expect( runningAnimations, 'should cancel the animation', ).toEqual([]); resolve(); }); expect(canvas.disposed, 'should flag `disposed`').toBeTruthy(); } called++; // TODO: check typings, because this runs for both static and normal canvas but it is only typed on normal canvas (canvas as Canvas).contextTopDirty = true; // @ts-expect-error -- private property canvas.hasLostContext = true; canvas.renderAll(); }, onComplete() { animate(); }, }); animate(); }); }); if (CanvasClass === Canvas) { it('dispose: clear refs sync for Canvas', () => { const el = getFabricDocument().createElement('canvas'); const parentEl = getFabricDocument().createElement('div'); el.width = 200; el.height = 200; parentEl.className = 'rootNode'; parentEl.appendChild(el); config.configure({ devicePixelRatio: 1.25 }); expect( parentEl.firstChild, 'canvas should be appended at parentEl', ).toBe(el); expect(parentEl.childNodes.length, 'parentEl has 1 child only').toBe(1); el.style.position = 'relative'; const elStyle = el.style.cssText; expect(elStyle, 'el style should not be empty').toBe( 'position: relative;', ); const canvas = new Canvas(el, { enableRetinaScaling: true, renderOnAddRemove: false, }); const { upperCanvasEl, lowerCanvasEl, wrapperEl } = canvas; const activeSel = new ActiveSelection(); expect( parentEl.childNodes.length, 'parentEl has still 1 child only', ).toBe(1); expect( wrapperEl.childNodes.length, 'wrapper should have 2 children', ).toBe(2); expect(wrapperEl.tagName, 'We wrapped canvas with DIV').toBe('DIV'); expect(wrapperEl.className, 'DIV class should be set').toBe( canvas.containerClass, ); expect( wrapperEl.childNodes[0], 'First child should be lowerCanvas', ).toBe(lowerCanvasEl); expect( wrapperEl.childNodes[1], 'Second child should be upperCanvas', ).toBe(upperCanvasEl); expect( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, 'saved original canvas style for disposal', ).toBe(elStyle); expect(activeSel, 'active selection').toBeInstanceOf(ActiveSelection); expect(el.style.cssText, 'canvas el style has been changed').not.toBe( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, ); expect( parentEl.childNodes[0], 'wrapperEl is appended to rootNode', ).toBe(wrapperEl); expect( parentEl.childNodes.length, 'parent div should have 1 child', ).toBe(1); expect( parentEl.firstChild, 'canvas should not be parent div firstChild', ).not.toBe(canvas.getElement()); expect(canvas.dispose).toBeTypeOf('function'); expect(canvas.destroy).toBeTypeOf('function'); canvas.add(makeRect(), makeRect(), makeRect()); canvas.item(0).animate({ scaleX: 10 }); activeSel.add(canvas.item(1)); expect( runningAnimations.length, 'should have a running animation', ).toBe(1); canvas.dispose(); expect(parentEl.childNodes.length, 'parent has always 1 child').toBe(1); expect( parentEl.childNodes[0], 'canvas should be back to its firstChild place', ).toBe(lowerCanvasEl); expect( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, 'removed original canvas style', ).toBeUndefined(); expect(el.style.cssText, 'restored original canvas style').toBe( elStyle, ); expect(el.width, 'restored width').toBe(200); expect(el.height, 'restored height').toBe(200); }); it('dispose: clear refs async for Canvas', async () => { const el = getFabricDocument().createElement('canvas'); const parentEl = getFabricDocument().createElement('div'); el.width = 200; el.height = 200; parentEl.className = 'rootNode'; parentEl.appendChild(el); config.configure({ devicePixelRatio: 1.25 }); expect( parentEl.firstChild, 'canvas should be appended at partentEl', ).toBe(el); expect(parentEl.childNodes.length, 'parentEl has 1 child only').toBe(1); el.style.position = 'relative'; const elStyle = el.style.cssText; expect(elStyle, 'el style should not be empty').toBe( 'position: relative;', ); const canvas = new Canvas(el, { enableRetinaScaling: true, renderOnAddRemove: false, }); const { wrapperEl, lowerCanvasEl, upperCanvasEl } = canvas; const activeSel = new ActiveSelection(); canvas.setActiveObject(activeSel); expect( parentEl.childNodes.length, 'parentEl has still 1 child only', ).toBe(1); expect( wrapperEl.childNodes.length, 'wrapper should have 2 children', ).toBe(2); expect(wrapperEl.tagName, 'We wrapped canvas with DIV').toBe('DIV'); expect(wrapperEl.className, 'DIV class should be set').toBe( canvas.containerClass, ); expect( wrapperEl.childNodes[0], 'First child should be lowerCanvas', ).toBe(lowerCanvasEl); expect( wrapperEl.childNodes[1], 'Second child should be upperCanvas', ).toBe(upperCanvasEl); expect( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, 'saved original canvas style for disposal', ).toBe(elStyle); expect( canvas.getActiveObject() === activeSel, 'active selection', ).toBeTruthy(); expect(el.style.cssText, 'canvas el style has been changed').not.toBe( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, ); expect( parentEl.childNodes[0], 'wrapperEl is appended to rootNode', ).toBe(wrapperEl); expect( parentEl.childNodes.length, 'parent div should have 1 child', ).toBe(1); expect( parentEl.firstChild, 'canvas should not be parent div firstChild', ).not.toBe(canvas.getElement()); expect(canvas.dispose).toBeTypeOf('function'); expect(canvas.destroy).toBeTypeOf('function'); canvas.add(makeRect(), makeRect(), makeRect()); canvas.item(0).animate({ scaleX: 10 }); activeSel.add(canvas.item(1)); expect( runningAnimations.length, 'should have a running animation', ).toBe(1); await canvas.dispose(); expect( runningAnimations.length, 'dispose should clear running animations', ).toBe(0); expect(canvas.getObjects().length, 'dispose should clear canvas').toBe( 0, ); expect( canvas.getActiveObject(), 'dispose should dispose active selection', ).toBeUndefined(); expect( activeSel.size(), 'dispose should dispose active selection', ).toBe(0); expect(parentEl.childNodes.length, 'parent has always 1 child').toBe(1); expect( parentEl.childNodes[0], 'canvas should be back to its firstChild place', ).toBe(lowerCanvasEl); expect(canvas.wrapperEl, 'wrapperEl should be deleted').toBeUndefined(); expect( canvas.upperCanvasEl, 'upperCanvas should be deleted', ).toBeUndefined(); expect( canvas.lowerCanvasEl, 'lowerCanvasEl should be deleted', ).toBeUndefined(); expect( // @ts-expect-error -- private property canvas.pixelFindCanvasEl, 'pixelFindCanvasEl should be deleted', ).toBeUndefined(); expect( canvas.contextTop, 'contextTop should be deleted', ).toBeUndefined(); expect( // @ts-expect-error -- private property canvas.pixelFindContext, 'pixelFindContext should be deleted', ).toBeNull(); expect( // @ts-expect-error -- private property canvas.elements._originalCanvasStyle, 'removed original canvas style', ).toBeUndefined(); expect(el.style.cssText, 'restored original canvas style').toBe( elStyle, ); expect(el.width, 'restored width').toBe(200); expect(el.height, 'restored height').toBe(200); }); } }); });