diff --git a/src/component/BpmnVisualization.ts b/src/component/BpmnVisualization.ts index a7d1a116fb..704e006ac5 100644 --- a/src/component/BpmnVisualization.ts +++ b/src/component/BpmnVisualization.ts @@ -18,6 +18,7 @@ import type { BpmnGraph } from './mxgraph/BpmnGraph'; import type { Navigation } from './navigation'; import type { GlobalOptions, LoadOptions, ParserOptions, RendererOptions } from './options'; import type { BpmnElementsRegistry } from './registry'; +import type { Disposable } from './types'; import { htmlElement } from './helpers/dom-utils'; import { newBpmnRenderer } from './mxgraph/BpmnRenderer'; @@ -36,7 +37,7 @@ import { BpmnModelRegistry } from './registry/bpmn-model-registry'; * * @category Initialization & Configuration */ -export class BpmnVisualization { +export class BpmnVisualization implements Disposable { /** * Direct access to the `Graph` instance that powers `bpmn-visualization`. * It is for **advanced users**, so please use the lib API first and access to the `Graph` instance only when there is no alternative. @@ -72,6 +73,8 @@ export class BpmnVisualization { private readonly rendererOptions: RendererOptions; + private disposed = false; + constructor(options: GlobalOptions) { this.rendererOptions = options?.renderer; // graph configuration @@ -91,9 +94,29 @@ export class BpmnVisualization { * @throws `Error` when loading fails. This is generally due to a parsing error caused by a malformed BPMN content */ load(xml: string, options?: LoadOptions): void { + if (this.disposed) { + throw new Error('Cannot load BPMN diagram: the BpmnVisualization instance has been disposed'); + } const bpmnModel = newBpmnParser(this.parserOptions).parse(xml); const renderedModel = this.bpmnModelRegistry.load(bpmnModel, options?.modelFilter); newBpmnRenderer(this.graph, this.rendererOptions).render(renderedModel); this.navigation.fit(options?.fit); } + + /** + * Dispose the BpmnVisualization instance and release all its resources. + * + * This is particularly useful when you want to remove the BPMN diagram from the page and free the memory or, + * to clean up or unmount a component that uses a BpmnVisualization instance. + * + * @since 0.48.0 + */ + dispose(): void { + if (this.disposed) { + return; + } + (this.navigation as unknown as Disposable).dispose(); + this.disposed = true; + this.graph.destroy(); + } } diff --git a/src/component/navigation.ts b/src/component/navigation.ts index fbe002f2ec..7d934f5db9 100644 --- a/src/component/navigation.ts +++ b/src/component/navigation.ts @@ -16,6 +16,7 @@ limitations under the License. import type { BpmnGraph } from './mxgraph/BpmnGraph'; import type { FitOptions, NavigationConfiguration, ZoomConfiguration, ZoomType } from './options'; +import type { Disposable } from './types'; import type { mxMouseEvent } from 'mxgraph'; import { debounce, throttle } from 'es-toolkit'; @@ -49,7 +50,7 @@ export interface Navigation { * @internal * @since 0.47.0 */ -export class NavigationImpl implements Navigation { +export class NavigationImpl implements Disposable, Navigation { constructor( private readonly graph: BpmnGraph, private readonly zoomSupport: ZoomSupport, @@ -63,6 +64,11 @@ export class NavigationImpl implements Navigation { type == 'in' ? this.zoomSupport.zoomIn() : this.zoomSupport.zoomOut(); } + dispose(): void { + this.zoomSupport.dispose(); + this.graph.panningHandler.destroy(); + } + configure(options?: NavigationConfiguration): void { const panningHandler = this.graph.panningHandler; if (options?.enabled) { @@ -107,12 +113,15 @@ export function createNewNavigation(graph: BpmnGraph, options?: NavigationConfig const zoomFactorIn = 1.25; const zoomFactorOut = 1 / zoomFactorIn; +type MouseWheelListener = (event: Event, up: boolean) => void; + /** * Call Graph methods and zoom with mouse. * @internal */ -class ZoomSupport { +class ZoomSupport implements Disposable { private currentZoomLevel = 1; + private mouseWheelListeners: MouseWheelListener[] = []; constructor(private readonly graph: BpmnGraph) { this.graph.zoomFactor = zoomFactorIn; @@ -189,13 +198,24 @@ class ZoomSupport { } } - /** - * @internal - */ registerMouseWheelZoomListeners(config: ZoomConfiguration): void { config = ensureValidZoomConfiguration(config); - mxEvent.addMouseWheelListener(debounce(this.createMouseWheelZoomListener(true), config.debounceDelay), this.graph.container); - mxEvent.addMouseWheelListener(throttle(this.createMouseWheelZoomListener(false), config.throttleDelay), this.graph.container); + this.addMouseWheelListener(debounce(this.createMouseWheelZoomListener(true), config.debounceDelay)); + this.addMouseWheelListener(throttle(this.createMouseWheelZoomListener(false), config.throttleDelay)); + } + + private addMouseWheelListener(listener: MouseWheelListener): void { + mxEvent.addMouseWheelListener(listener, this.graph.container); + this.mouseWheelListeners.push(listener); + } + + dispose(): void { + if (this.graph.container) { + for (const listener of this.mouseWheelListeners) { + mxEvent.removeListener(this.graph.container, 'wheel', listener); + } + } + this.mouseWheelListeners = []; } private createMouseWheelZoomListener(performScaling: boolean) { diff --git a/src/component/types.ts b/src/component/types.ts new file mode 100644 index 0000000000..0403183029 --- /dev/null +++ b/src/component/types.ts @@ -0,0 +1,23 @@ +/* +Copyright 2025 Bonitasoft S.A. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/** + * An object that can be disposed to free resources. + * @since 0.48.0 + */ +export interface Disposable { + dispose(): void; +} diff --git a/test/integration/BpmnVisualization.test.ts b/test/integration/BpmnVisualization.test.ts index 7a611957a0..720b9183ca 100644 --- a/test/integration/BpmnVisualization.test.ts +++ b/test/integration/BpmnVisualization.test.ts @@ -18,6 +18,7 @@ import type { FitType } from '@lib/component/options'; import { type GlobalOptionsWithoutContainer, + initializeBpmnVisualization, initializeBpmnVisualizationWithContainerId, initializeBpmnVisualizationWithHtmlElement, } from './helpers/bpmn-visualization-initialization'; @@ -93,4 +94,31 @@ describe('BpmnVisualization API', () => { bpmnElementsRegistry.resetStyle('fake_id'); }); }); + + describe('Dispose', () => { + it('Dispose clean the bpmn container in DOM', () => { + const bpmnContainerId = 'bpmn-container-to-dispose'; + const bv = initializeBpmnVisualization(bpmnContainerId); + const bpmnContainer = document.querySelector(`#${bpmnContainerId}`); + expect(bv.graph.container).toBe(bpmnContainer); + expect(bv.graph.container.children).not.toHaveLength(0); // fill by mxGraph + + bv.dispose(); + + expect(bv.graph.container).toBeNull(); + expect(bpmnContainer.innerHTML).toBeEmpty(); // cleaned by mxGraph + }); + + it('Dispose twice does not throw error', () => { + const bv = initializeBpmnVisualization(); + bv.dispose(); + bv.dispose(); + }); + + it('Load throws error if already disposed', () => { + const bv = initializeBpmnVisualization(); + bv.dispose(); + expect(() => bv.load(readFileSync('../fixtures/bpmn/simple-start-task-end.bpmn'))).toThrow('Cannot load BPMN diagram: the BpmnVisualization instance has been disposed'); + }); + }); });