diff --git a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.spec.ts b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.spec.ts
new file mode 100644
index 00000000000..6ceacf50e94
--- /dev/null
+++ b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.spec.ts
@@ -0,0 +1,337 @@
+/*
+ * 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.
+ */
+
+import { CdkPortalOutlet, PortalModule } from '@angular/cdk/portal';
+import { CommonModule } from '@angular/common';
+import {
+ ChangeDetectorRef,
+ Injector,
+ NO_ERRORS_SCHEMA,
+ provideZoneChangeDetection,
+ ViewContainerRef
+} from '@angular/core';
+import { TestBed } from '@angular/core/testing';
+import { DomSanitizer } from '@angular/platform-browser';
+import { DatasetType, GraphConfig } from '@zeppelin/sdk';
+import { HeliumClassicVisualization, HeliumClassicVisualizationConstructor } from '@zeppelin/interfaces';
+import { Visualization } from '@zeppelin/visualization';
+import { EMPTY, Subscription } from 'rxjs';
+import { describe, expect, it, vi } from 'vitest';
+
+vi.mock('@zeppelin/services', () => ({
+ ClassicVisualizationService: class {},
+ HeliumService: class {},
+ NgZService: class {},
+ RuntimeCompilerService: class {}
+}));
+vi.mock('@zeppelin/visualizations', () => ({
+ AreaChartVisualization: class {},
+ BarChartVisualization: class {},
+ LineChartVisualization: class {},
+ PieChartVisualization: class {},
+ ScatterChartVisualization: class {},
+ TableVisualization: class {}
+}));
+
+import {
+ ClassicVisualizationService,
+ DynamicTemplate,
+ HeliumService,
+ NgZService,
+ RuntimeCompilerService
+} from '@zeppelin/services';
+import { NotebookParagraphResultComponent } from './result.component';
+import template from './result.component.html?raw';
+
+const component = (
+ compiler: Partial
= {},
+ classic: Partial = {}
+) =>
+ new NotebookParagraphResultComponent(
+ {} as Injector,
+ {} as ViewContainerRef,
+ { detectChanges: vi.fn(), markForCheck: vi.fn() } as unknown as ChangeDetectorRef,
+ compiler as RuntimeCompilerService,
+ {} as DomSanitizer,
+ {} as NgZService,
+ {} as HeliumService,
+ { destroyAllInstances: vi.fn(), ...classic } as unknown as ClassicVisualizationService
+ );
+
+const pendingResult = () => {
+ let resolve!: (value: T) => void;
+ let reject!: (error: Error) => void;
+ const promise = new Promise((resolvePromise, rejectPromise) => {
+ resolve = resolvePromise;
+ reject = rejectPromise;
+ });
+ return { promise, resolve, reject };
+};
+
+const pendingCompilation = () => pendingResult();
+
+const flushCompilation = async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+};
+
+describe('finite dataset rendering', () => {
+ it('encodes SVG output as an image URL', () => {
+ const result = component();
+ const svg = '';
+ result.result = { type: DatasetType.SVG, data: svg };
+ result.angularComponent = {} as DynamicTemplate;
+ result.renderDefaultDisplay();
+ expect(result.imgData).toBe(`data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`);
+ expect(result.angularComponent).toBeNull();
+ });
+
+ it('clears a previous Angular display for NULL output', () => {
+ const result = component();
+ result.result = { type: DatasetType.NULL, data: '' };
+ result.angularComponent = {} as DynamicTemplate;
+ result.renderDefaultDisplay();
+ expect(result.angularComponent).toBeNull();
+ });
+
+ it.each([DatasetType.NULL, DatasetType.NETWORK])('leaves %s without a display renderer', type => {
+ const result = component();
+ result.result = { type, data: '' };
+ const renderHTML = vi.spyOn(result, 'renderHTML');
+ const renderAngular = vi.spyOn(result, 'renderAngular');
+ result.renderDefaultDisplay();
+ expect(renderHTML).not.toHaveBeenCalled();
+ expect(renderAngular).not.toHaveBeenCalled();
+ expect(result.frontEndError).toBe('');
+ });
+});
+
+describe('SVG and NULL invalidate pending Angular output', () => {
+ it.each([DatasetType.SVG, DatasetType.NULL])('ignores stale compilation success after %s output', async type => {
+ const pending = pendingCompilation();
+ const result = component({ createAndCompileTemplate: vi.fn().mockReturnValue(pending.promise) });
+ result.result = { type: DatasetType.ANGULAR, data: 'previous
' };
+ result.renderDefaultDisplay();
+ result.result = { type, data: '' };
+ result.renderDefaultDisplay();
+
+ pending.resolve({} as DynamicTemplate);
+ await flushCompilation();
+
+ expect(result.angularComponent).toBeNull();
+ expect(result.frontEndError).toBe('');
+ });
+
+ it.each([DatasetType.SVG, DatasetType.NULL])('ignores stale compilation errors after %s output', async type => {
+ const pending = pendingCompilation();
+ const result = component({ createAndCompileTemplate: vi.fn().mockReturnValue(pending.promise) });
+ result.result = { type: DatasetType.ANGULAR, data: 'previous
' };
+ result.renderDefaultDisplay();
+ result.result = { type, data: '' };
+ result.renderDefaultDisplay();
+
+ pending.reject(new Error('obsolete compilation'));
+ await flushCompilation();
+
+ expect(result.angularComponent).toBeNull();
+ expect(result.frontEndError).toBe('');
+ });
+
+ it('reports errors from the current Angular compilation', async () => {
+ const pending = pendingCompilation();
+ const result = component({ createAndCompileTemplate: vi.fn().mockReturnValue(pending.promise) });
+ result.result = { type: DatasetType.ANGULAR, data: 'current
' };
+ result.renderDefaultDisplay();
+
+ pending.reject(new Error('current compilation failed'));
+ await flushCompilation();
+
+ expect(result.angularComponent).toBeNull();
+ expect(result.frontEndError).toBe('current compilation failed');
+ });
+});
+
+describe('result type transitions', () => {
+ it.each([DatasetType.SVG, DatasetType.NULL])('destroys an attached modern visualization for %s', type => {
+ const result = component();
+ const destroy = vi.fn();
+ const detach = vi.fn();
+ const unsubscribe = vi.fn();
+ const subscription = new Subscription(unsubscribe);
+ result.visualizations[0].instance = { destroy } as unknown as Visualization;
+ result.visualizations[0].changeSubscription = subscription;
+ result.portalOutlet = { hasAttached: () => true, detach } as unknown as CdkPortalOutlet;
+ result.result = { type, data: '' };
+
+ result.renderDefaultDisplay();
+
+ expect(destroy).toHaveBeenCalledOnce();
+ expect(detach).toHaveBeenCalledOnce();
+ expect(unsubscribe).toHaveBeenCalledOnce();
+ expect(result.visualizations[0].instance).toBeUndefined();
+ expect(result.visualizations[0].changeSubscription).toBeNull();
+ });
+
+ it.each([DatasetType.SVG, DatasetType.NULL])('destroys a previous classic visualization for %s', type => {
+ const destroyInstance = vi.fn();
+ const result = component({}, { destroyInstance });
+ result.id = 'paragraph';
+ result.visualizations.push({
+ id: 'classic',
+ name: 'classic',
+ isClassic: true,
+ icon: {},
+ Class: vi.fn() as unknown as HeliumClassicVisualizationConstructor,
+ instance: {} as HeliumClassicVisualization,
+ changeSubscription: null
+ });
+ result.result = { type, data: '' };
+
+ result.renderDefaultDisplay();
+
+ expect(destroyInstance).toHaveBeenCalledExactlyOnceWith('pparagraph_classic');
+ expect(result.visualizations.at(-1)?.instance).toBeUndefined();
+ });
+
+ it('preserves an existing visualization during a TABLE refresh', () => {
+ const result = component();
+ const destroy = vi.fn();
+ const instance = { destroy } as unknown as Visualization;
+ result.visualizations[0].instance = instance;
+ result.result = { type: DatasetType.TABLE, data: 'column\nvalue' };
+ const renderGraph = vi.spyOn(result, 'renderGraph').mockImplementation(() => {});
+
+ result.renderDefaultDisplay();
+
+ expect(renderGraph).toHaveBeenCalledOnce();
+ expect(destroy).not.toHaveBeenCalled();
+ expect(result.visualizations[0].instance).toBe(instance);
+ });
+
+ it.each([DatasetType.SVG, DatasetType.NULL])('cancels a pending classic visualization after %s', async type => {
+ const pending = pendingResult();
+ const createClassicVisualization = vi.fn().mockReturnValue(pending.promise);
+ const result = component({}, { createClassicVisualization });
+ result.id = 'paragraph';
+ result.visualizations.push({
+ id: 'classic',
+ name: 'classic',
+ isClassic: true,
+ icon: {},
+ Class: vi.fn() as unknown as HeliumClassicVisualizationConstructor,
+ instance: undefined,
+ changeSubscription: null
+ });
+ result.config = { graph: { ...new GraphConfig(), mode: 'classic' } };
+ result.result = { type: DatasetType.TABLE, data: 'column\nvalue' };
+ result.renderDefaultDisplay();
+ const isCurrentRender = createClassicVisualization.mock.calls[0][5] as () => boolean;
+ expect(isCurrentRender()).toBe(true);
+
+ result.result = { type, data: '' };
+ result.renderDefaultDisplay();
+ expect(isCurrentRender()).toBe(false);
+ pending.resolve(undefined);
+ await flushCompilation();
+
+ expect(result.visualizations.at(-1)?.instance).toBeUndefined();
+ expect(result.frontEndError).toBe('');
+ });
+
+ it('keeps the current classic instance when an obsolete render completes after returning to TABLE', async () => {
+ const previous = pendingResult();
+ const current = pendingResult();
+ const destroyInstance = vi.fn();
+ const result = component(
+ {},
+ {
+ createClassicVisualization: vi.fn().mockReturnValueOnce(previous.promise).mockReturnValueOnce(current.promise),
+ destroyInstance
+ }
+ );
+ result.id = 'paragraph';
+ result.visualizations.push({
+ id: 'classic',
+ name: 'classic',
+ isClassic: true,
+ icon: {},
+ Class: vi.fn() as unknown as HeliumClassicVisualizationConstructor,
+ instance: undefined,
+ changeSubscription: null
+ });
+ result.config = { graph: { ...new GraphConfig(), mode: 'classic' } };
+ result.result = { type: DatasetType.TABLE, data: 'column\nprevious' };
+ result.renderDefaultDisplay();
+ result.result = { type: DatasetType.NULL, data: '' };
+ result.renderDefaultDisplay();
+ result.result = { type: DatasetType.TABLE, data: 'column\ncurrent' };
+ result.renderDefaultDisplay();
+ const currentInstance = {} as HeliumClassicVisualization;
+ const previousInstance = {} as HeliumClassicVisualization;
+
+ current.resolve(currentInstance);
+ await flushCompilation();
+ previous.resolve(previousInstance);
+ await flushCompilation();
+
+ expect(result.visualizations.at(-1)?.instance).toBe(currentInstance);
+ expect(destroyInstance).toHaveBeenCalledExactlyOnceWith('pparagraph_classic', false, previousInstance);
+ });
+
+ it.each([DatasetType.SVG, DatasetType.NULL])(
+ 'removes classic visualization containers for %s in the template',
+ async type => {
+ await TestBed.configureTestingModule({
+ declarations: [NotebookParagraphResultComponent],
+ imports: [CommonModule, PortalModule],
+ schemas: [NO_ERRORS_SCHEMA],
+ providers: [
+ provideZoneChangeDetection(),
+ { provide: RuntimeCompilerService, useValue: {} },
+ { provide: NgZService, useValue: { contextChanged: () => EMPTY } },
+ { provide: HeliumService, useValue: { visualizationBundles: () => EMPTY } },
+ { provide: ClassicVisualizationService, useValue: { destroyAllInstances: vi.fn() } }
+ ]
+ })
+ .overrideComponent(NotebookParagraphResultComponent, {
+ set: { template, templateUrl: undefined, styles: [], styleUrls: [] }
+ })
+ .compileComponents();
+ const fixture = TestBed.createComponent(NotebookParagraphResultComponent);
+ const result = fixture.componentInstance;
+ result.published = true;
+ result.id = 'paragraph';
+ result.visualizations.push({
+ id: 'classic',
+ name: 'classic',
+ isClassic: true,
+ icon: {},
+ Class: vi.fn() as unknown as HeliumClassicVisualizationConstructor,
+ instance: undefined,
+ changeSubscription: null
+ });
+ result.config = { graph: { ...new GraphConfig(), mode: 'classic' } };
+ result.result = { type: DatasetType.TABLE, data: 'column\nvalue' };
+ vi.spyOn(result, 'renderGraph').mockImplementation(() => {});
+ fixture.detectChanges();
+ const element = fixture.nativeElement as HTMLElement;
+ const containers = '.classic-visualization-container, .transformation-setting, .visualization-setting';
+ expect(element.querySelectorAll(containers)).toHaveLength(3);
+
+ result.updateResult(result.config, { type, data: '' });
+ fixture.detectChanges();
+
+ expect(element.querySelectorAll(containers)).toHaveLength(0);
+ expect(element.querySelectorAll('img')).toHaveLength(type === DatasetType.SVG ? 1 : 0);
+ }
+ );
+});
diff --git a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
index 1b2ef5c9f38..ba73c32c260 100644
--- a/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
+++ b/zeppelin-web-angular/src/app/pages/workspace/share/result/result.component.ts
@@ -109,6 +109,8 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
@ViewChild(CdkPortalOutlet, { static: false }) portalOutlet!: CdkPortalOutlet;
private destroy$ = new Subject();
+ private outputResetVersion = 0;
+ private destroyed = false;
datasetType = DatasetType;
angularComponent: DynamicTemplate | null = null;
innerHTML: string | SafeHtml = '';
@@ -332,6 +334,14 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
renderDefaultDisplay() {
this.frontEndError = '';
+ if (this.result.type === DatasetType.SVG || this.result.type === DatasetType.NULL) {
+ this.outputResetVersion++;
+ this.angularComponent = null;
+ this.destroyVisualizations();
+ if (this.portalOutlet?.hasAttached()) {
+ this.portalOutlet.detach();
+ }
+ }
switch (this.result.type) {
case DatasetType.TABLE:
this.renderGraph();
@@ -345,9 +355,21 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
case DatasetType.IMG:
this.renderImg();
break;
+ case DatasetType.SVG:
+ this.renderSvg();
+ break;
case DatasetType.ANGULAR:
this.renderAngular();
break;
+ case DatasetType.NULL:
+ break;
+ case DatasetType.NETWORK:
+ // NULL has no display; NETWORK has no renderer in the Angular UI.
+ break;
+ default: {
+ const _unhandled: never = this.result.type;
+ break;
+ }
}
this.cdr.detectChanges();
}
@@ -363,13 +385,20 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
}
renderAngular(): void {
+ const resetVersion = this.outputResetVersion;
this.runtimeCompilerService
.createAndCompileTemplate(this.id, this.result.data)
.then(data => {
+ if (resetVersion !== this.outputResetVersion) {
+ return;
+ }
this.angularComponent = data;
this.cdr.markForCheck();
})
.catch(error => {
+ if (resetVersion !== this.outputResetVersion) {
+ return;
+ }
this.angularComponent = null;
this.frontEndError = error.message;
this.cdr.markForCheck();
@@ -410,6 +439,10 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
this.imgData = this.sanitizer.bypassSecurityTrustUrl(`data:image/png;base64,${this.result.data}`);
}
+ renderSvg(): void {
+ this.imgData = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(this.result.data)}`;
+ }
+
setGraphConfig() {
const config = this.config;
if (!config || !config.graph) {
@@ -448,6 +481,11 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
if (visualizationItem.isClassic) {
// Classic visualization - delegate to ClassicVisualizationService
const targetElementId = `p${this.id}_${config.graph.mode}`;
+ const resetVersion = this.outputResetVersion;
+ const isCurrentRender = () =>
+ !this.destroyed &&
+ resetVersion === this.outputResetVersion &&
+ targetElementId === `p${this.id}_${this.config?.graph?.mode}`;
const emitter = (c: GraphConfig) => {
if (!this.config) {
throw new Error('config is not defined');
@@ -457,8 +495,22 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
};
this.classicVisualizationService
- .createClassicVisualization(visualizationItem.Class, targetElementId, config.graph, this.tableData, emitter)
+ .createClassicVisualization(
+ visualizationItem.Class,
+ targetElementId,
+ config.graph,
+ this.tableData,
+ emitter,
+ isCurrentRender
+ )
.then(classicInstance => {
+ if (!classicInstance) {
+ return;
+ }
+ if (!isCurrentRender()) {
+ this.classicVisualizationService.destroyInstance(targetElementId, false, classicInstance);
+ return;
+ }
visualizationItem.instance = classicInstance;
this.cdr.markForCheck();
})
@@ -590,6 +642,7 @@ export class NotebookParagraphResultComponent implements OnInit, AfterViewInit,
}
ngOnDestroy(): void {
+ this.destroyed = true;
this.destroyVisualizations();
this.classicVisualizationService.destroyAllInstances(true);
this.destroy$.next();
diff --git a/zeppelin-web-angular/src/app/services/classic-visualization.service.spec.ts b/zeppelin-web-angular/src/app/services/classic-visualization.service.spec.ts
new file mode 100644
index 00000000000..cd27b4ca55b
--- /dev/null
+++ b/zeppelin-web-angular/src/app/services/classic-visualization.service.spec.ts
@@ -0,0 +1,101 @@
+/*
+ * 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.
+ */
+
+import { HttpClient } from '@angular/common/http';
+import { Injector } from '@angular/core';
+import { HeliumClassicVisualization, HeliumClassicVisualizationConstructor } from '@zeppelin/interfaces';
+import { GraphConfig } from '@zeppelin/sdk';
+import { TableData } from '@zeppelin/visualization';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+
+import { AngularDragDropService } from './angular-drag-drop.service';
+import { BootstrapCompatibilityService } from './bootstrap-compatibility.service';
+import { ClassicVisualizationService } from './classic-visualization.service';
+import { TableDataAdapterService } from './table-data-adapter.service';
+
+const service = () => {
+ const injectBootstrapStyles = vi.fn();
+ const instance = new ClassicVisualizationService(
+ {} as Injector,
+ {} as TableDataAdapterService,
+ {} as HttpClient,
+ {} as AngularDragDropService,
+ { injectBootstrapStyles } as unknown as BootstrapCompatibilityService
+ );
+ return { instance, injectBootstrapStyles };
+};
+
+afterEach(() => {
+ document.body.replaceChildren();
+ vi.useRealTimers();
+});
+
+describe('classic visualization cancellation', () => {
+ it('does not destroy a newer instance when an obsolete completion cleans up the same target', () => {
+ const { instance } = service();
+ const destroy = vi.fn();
+ const current = { destroy } as unknown as HeliumClassicVisualization;
+ const registry = instance as unknown as {
+ activeInstanceInfos: Map;
+ };
+ registry.activeInstanceInfos.set('visualization', { instance: current });
+
+ instance.destroyInstance('visualization', false, {} as HeliumClassicVisualization);
+
+ expect(destroy).not.toHaveBeenCalled();
+ expect(registry.activeInstanceInfos.get('visualization')?.instance).toBe(current);
+ });
+ it('cancels before allocation when its resolved target is removed before the await resumes', async () => {
+ const { instance, injectBootstrapStyles } = service();
+ const target = document.createElement('div');
+ target.id = 'visualization';
+ document.body.appendChild(target);
+ let active = true;
+ const constructor = vi.fn() as unknown as HeliumClassicVisualizationConstructor;
+ const pending = instance.createClassicVisualization(
+ constructor,
+ target.id,
+ new GraphConfig(),
+ new TableData(),
+ vi.fn(),
+ () => active
+ );
+
+ target.remove();
+ active = false;
+
+ await expect(pending).resolves.toBeUndefined();
+ expect(injectBootstrapStyles).not.toHaveBeenCalled();
+ expect(constructor).not.toHaveBeenCalled();
+ });
+
+ it('stops polling without an error when a pending target is cancelled', async () => {
+ vi.useFakeTimers();
+ const { instance, injectBootstrapStyles } = service();
+ let active = true;
+ const pending = instance.createClassicVisualization(
+ vi.fn() as unknown as HeliumClassicVisualizationConstructor,
+ 'missing-target',
+ new GraphConfig(),
+ new TableData(),
+ vi.fn(),
+ () => active
+ );
+
+ active = false;
+ await vi.advanceTimersByTimeAsync(100);
+
+ await expect(pending).resolves.toBeUndefined();
+ expect(vi.getTimerCount()).toBe(0);
+ expect(injectBootstrapStyles).not.toHaveBeenCalled();
+ });
+});
diff --git a/zeppelin-web-angular/src/app/services/classic-visualization.service.ts b/zeppelin-web-angular/src/app/services/classic-visualization.service.ts
index ca2c6866d1d..dd011532a3e 100644
--- a/zeppelin-web-angular/src/app/services/classic-visualization.service.ts
+++ b/zeppelin-web-angular/src/app/services/classic-visualization.service.ts
@@ -100,11 +100,20 @@ export class ClassicVisualizationService {
};
}
- private waitForElement(elementId: string, maxRetries = 50, interval = 100): Promise {
+ private waitForElement(
+ elementId: string,
+ isCurrentRender: () => boolean,
+ maxRetries = 50,
+ interval = 100
+ ): Promise {
return new Promise((resolve, reject) => {
let retries = 0;
const checkElement = () => {
+ if (!isCurrentRender()) {
+ resolve(undefined);
+ return;
+ }
const element = document.getElementById(elementId);
if (element) {
resolve(element);
@@ -160,14 +169,18 @@ export class ClassicVisualizationService {
config: GraphConfig,
tableData: TableData,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
- emitter: (config: any) => void
- ): Promise {
+ emitter: (config: any) => void,
+ isCurrentRender: () => boolean
+ ): Promise {
+ // Wait for DOM element to be available
+ const targetElement = await this.waitForElement(targetElementId, isCurrentRender);
+ if (!targetElement || !isCurrentRender()) {
+ return undefined;
+ }
+
// Inject Bootstrap compatibility styles before creating visualization
this.bootstrapCompatibilityService.injectBootstrapStyles();
- // Wait for DOM element to be available
- const targetElement = await this.waitForElement(targetElementId);
-
// Clean up any existing instance for this element
this.destroyInstance(targetElementId);
@@ -367,9 +380,13 @@ export class ClassicVisualizationService {
}
}
- destroyInstance(targetElementId: string, forceCleanBootstrap = false): void {
+ destroyInstance(
+ targetElementId: string,
+ forceCleanBootstrap = false,
+ expectedInstance?: HeliumClassicVisualization
+ ): void {
const instanceInfo = this.activeInstanceInfos.get(targetElementId);
- if (!instanceInfo) {
+ if (!instanceInfo || (expectedInstance && instanceInfo.instance !== expectedInstance)) {
return;
}