Dashboard datasource: Fix library panels not tracked in mixed queries (#112959)
* Dashboard datasource: Fix library panels not tracked in mixed queries * Remove unnecessary code and add unit tests * Add relevant comment
This commit is contained in:
@@ -198,7 +198,7 @@ describe('DashboardDatasourceBehaviour', () => {
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
// since there is no previous request ID on dashboard load, the behaviour should not re-run queries
|
||||
expect(behaviour['prevRequestId']).toBeUndefined();
|
||||
expect(behaviour['prevRequestIds'].size).toBe(0);
|
||||
});
|
||||
|
||||
it('Should not re-run queries in behaviour on scene load', async () => {
|
||||
@@ -242,7 +242,7 @@ describe('DashboardDatasourceBehaviour', () => {
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
// since there is no previous request ID on dashboard load, the behaviour should not re-run queries
|
||||
expect(behaviour['prevRequestId']).toBeUndefined();
|
||||
expect(behaviour['prevRequestIds'].size).toBe(0);
|
||||
});
|
||||
|
||||
it('Should exit behaviour early if not in a dashboard scene', async () => {
|
||||
@@ -593,6 +593,363 @@ describe('DashboardDatasourceBehaviour', () => {
|
||||
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should re-run query when ANY source panel changes with multiple dashboardDS queries', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
// Create two source panels
|
||||
const sourcePanel1 = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'table',
|
||||
key: 'panel-1',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const sourcePanel2 = new VizPanel({
|
||||
title: 'Panel B',
|
||||
pluginId: 'table',
|
||||
key: 'panel-2',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
// Create a mixed DS panel that references BOTH source panels
|
||||
const mixedDSPanel = new VizPanel({
|
||||
title: 'Panel C - Mixed',
|
||||
pluginId: 'table',
|
||||
key: 'panel-3',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: MIXED_DATASOURCE_NAME },
|
||||
queries: [
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'A',
|
||||
panelId: 1,
|
||||
},
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'B',
|
||||
panelId: 2,
|
||||
},
|
||||
],
|
||||
$behaviors: [new DashboardDatasourceBehaviour({})],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const scene = new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
meta: {
|
||||
canEdit: true,
|
||||
},
|
||||
body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]),
|
||||
});
|
||||
|
||||
const sceneDeactivate = activateFullSceneTree(scene);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
const spy = jest
|
||||
.spyOn(mixedDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries')
|
||||
.mockImplementation();
|
||||
|
||||
// deactivate scene
|
||||
sceneDeactivate();
|
||||
|
||||
// Only change the SECOND source panel
|
||||
(sourcePanel2.state.$data!.state.$data as SceneQueryRunner).runQueries();
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
// activate scene again
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
// Should re-run because the second panel changed
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Should track multiple dashboardDS queries independently', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
const sourcePanel1 = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'table',
|
||||
key: 'panel-1',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const sourcePanel2 = new VizPanel({
|
||||
title: 'Panel B',
|
||||
pluginId: 'table',
|
||||
key: 'panel-2',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const mixedDSPanel = new VizPanel({
|
||||
title: 'Panel C - Mixed',
|
||||
pluginId: 'table',
|
||||
key: 'panel-3',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: MIXED_DATASOURCE_NAME },
|
||||
queries: [
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'A',
|
||||
panelId: 1,
|
||||
},
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'B',
|
||||
panelId: 2,
|
||||
},
|
||||
],
|
||||
$behaviors: [new DashboardDatasourceBehaviour({})],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const scene = new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
meta: {
|
||||
canEdit: true,
|
||||
},
|
||||
body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]),
|
||||
});
|
||||
|
||||
const sceneDeactivate = activateFullSceneTree(scene);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
const spy = jest
|
||||
.spyOn(mixedDSPanel.state.$data!.state.$data as SceneQueryRunner, 'runQueries')
|
||||
.mockImplementation();
|
||||
|
||||
// First cycle: change panel 1
|
||||
sceneDeactivate();
|
||||
(sourcePanel1.state.$data!.state.$data as SceneQueryRunner).runQueries();
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
const deactivate2 = activateFullSceneTree(scene);
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Second cycle: change panel 2
|
||||
deactivate2();
|
||||
(sourcePanel2.state.$data!.state.$data as SceneQueryRunner).runQueries();
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
// Should have been called again for panel 2
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should handle multiple dashboardDS queries with library panels', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
const libPanelBehavior1 = new LibraryPanelBehavior({
|
||||
isLoaded: false,
|
||||
uid: 'lib-panel-1',
|
||||
name: 'Library Panel 1',
|
||||
_loadedPanel: undefined,
|
||||
});
|
||||
|
||||
const libPanelBehavior2 = new LibraryPanelBehavior({
|
||||
isLoaded: false,
|
||||
uid: 'lib-panel-2',
|
||||
name: 'Library Panel 2',
|
||||
_loadedPanel: undefined,
|
||||
});
|
||||
|
||||
const sourcePanel1 = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'table',
|
||||
key: 'panel-1',
|
||||
$behaviors: [libPanelBehavior1],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const sourcePanel2 = new VizPanel({
|
||||
title: 'Panel B',
|
||||
pluginId: 'table',
|
||||
key: 'panel-2',
|
||||
$behaviors: [libPanelBehavior2],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
});
|
||||
|
||||
const mixedDSPanel = new VizPanel({
|
||||
title: 'Panel C - Mixed',
|
||||
pluginId: 'table',
|
||||
key: 'panel-3',
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: MIXED_DATASOURCE_NAME },
|
||||
queries: [
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'A',
|
||||
panelId: 1,
|
||||
},
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'B',
|
||||
panelId: 2,
|
||||
},
|
||||
],
|
||||
$behaviors: [new DashboardDatasourceBehaviour({})],
|
||||
}),
|
||||
});
|
||||
|
||||
const scene = new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
meta: {
|
||||
canEdit: true,
|
||||
},
|
||||
body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]),
|
||||
});
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
const spy = jest.spyOn(mixedDSPanel.state.$data as SceneQueryRunner, 'runQueries');
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
// Should not run queries until library panels are loaded
|
||||
expect(spy).not.toHaveBeenCalled();
|
||||
|
||||
// Load first library panel
|
||||
libPanelBehavior1.setState({
|
||||
isLoaded: true,
|
||||
uid: 'lib-panel-1',
|
||||
name: 'Library Panel 1',
|
||||
_loadedPanel: undefined,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Load second library panel
|
||||
libPanelBehavior2.setState({
|
||||
isLoaded: true,
|
||||
uid: 'lib-panel-2',
|
||||
name: 'Library Panel 2',
|
||||
_loadedPanel: undefined,
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('Should handle multiple queries with transformers on all source panels', async () => {
|
||||
jest.spyOn(console, 'error').mockImplementation();
|
||||
|
||||
const sourcePanel1 = new VizPanel({
|
||||
title: 'Panel A',
|
||||
pluginId: 'table',
|
||||
key: 'panel-1',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [{ id: 'transformA', options: {} }],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const sourcePanel2 = new VizPanel({
|
||||
title: 'Panel B',
|
||||
pluginId: 'table',
|
||||
key: 'panel-2',
|
||||
$data: new SceneDataTransformer({
|
||||
transformations: [{ id: 'transformB', options: {} }],
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: 'grafana' },
|
||||
queries: [{ refId: 'A', queryType: 'randomWalk' }],
|
||||
}),
|
||||
}),
|
||||
});
|
||||
|
||||
const mixedDSPanel = new VizPanel({
|
||||
title: 'Panel C - Mixed',
|
||||
pluginId: 'table',
|
||||
key: 'panel-3',
|
||||
$data: new SceneQueryRunner({
|
||||
datasource: { uid: MIXED_DATASOURCE_NAME },
|
||||
queries: [
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'A',
|
||||
panelId: 1,
|
||||
},
|
||||
{
|
||||
datasource: { uid: SHARED_DASHBOARD_QUERY },
|
||||
refId: 'B',
|
||||
panelId: 2,
|
||||
},
|
||||
],
|
||||
$behaviors: [new DashboardDatasourceBehaviour({})],
|
||||
}),
|
||||
});
|
||||
|
||||
const scene = new DashboardScene({
|
||||
title: 'hello',
|
||||
uid: 'dash-1',
|
||||
meta: {
|
||||
canEdit: true,
|
||||
},
|
||||
body: DefaultGridLayoutManager.fromVizPanels([sourcePanel1, sourcePanel2, mixedDSPanel]),
|
||||
});
|
||||
|
||||
activateFullSceneTree(scene);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 1));
|
||||
|
||||
const spy = jest.spyOn(mixedDSPanel.state.$data as SceneQueryRunner, 'runQueries').mockImplementation();
|
||||
|
||||
// Trigger transformer reprocessing on panel 1
|
||||
(sourcePanel1.state.$data as SceneDataTransformer).setState({
|
||||
data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() },
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Trigger transformer reprocessing on panel 2
|
||||
(sourcePanel2.state.$data as SceneDataTransformer).setState({
|
||||
data: { state: LoadingState.Done, series: [], timeRange: getDefaultTimeRange() },
|
||||
});
|
||||
|
||||
expect(spy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('Should re-run query after transformations reprocess', async () => {
|
||||
|
||||
@@ -18,7 +18,7 @@ import { LibraryPanelBehaviorState } from './LibraryPanelBehavior';
|
||||
interface DashboardDatasourceBehaviourState extends SceneObjectState {}
|
||||
|
||||
export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatasourceBehaviourState> {
|
||||
private prevRequestId: string | undefined;
|
||||
private prevRequestIds: Map<number, string> = new Map();
|
||||
public constructor(state: DashboardDatasourceBehaviourState) {
|
||||
super(state);
|
||||
|
||||
@@ -27,9 +27,8 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatas
|
||||
|
||||
private _activationHandler() {
|
||||
const queryRunner = this.parent;
|
||||
let libraryPanelSub: Unsubscribable;
|
||||
let transformerSub: Unsubscribable;
|
||||
let dashboard: DashboardScene;
|
||||
|
||||
if (!(queryRunner instanceof SceneQueryRunner)) {
|
||||
throw new Error('DashboardDatasourceBehaviour must be attached to a SceneQueryRunner');
|
||||
}
|
||||
@@ -44,64 +43,123 @@ export class DashboardDatasourceBehaviour extends SceneObjectBase<DashboardDatas
|
||||
return;
|
||||
}
|
||||
|
||||
const dashboardQuery = queryRunner.state.queries.find((query) => query.panelId !== undefined);
|
||||
/** Get all "Dashboard datasource" queries
|
||||
* panelId prop is the way we identify Dashboard datasource queries
|
||||
* {
|
||||
* datasource: { uid: "-- Dashboard --" },
|
||||
* panelId: 12, // ← Points to panel 12
|
||||
* refId: "A"
|
||||
* }
|
||||
*/
|
||||
const dashboardDsQueries = queryRunner.state.queries.filter((query) => query.panelId !== undefined);
|
||||
|
||||
if (!dashboardQuery) {
|
||||
if (dashboardDsQueries.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// find the source panel referenced in the the dashboard ds query
|
||||
const panelId = dashboardQuery.panelId;
|
||||
const vizKey = getVizPanelKeyForPanelId(panelId);
|
||||
// We're trying to find the original panel, not a cloned one, since `panelId` alone cannot resolve clones
|
||||
const sourcePanel = findVizPanelByKey(dashboard, vizKey);
|
||||
return this._handleQueries(dashboardDsQueries, queryRunner, dashboard);
|
||||
}
|
||||
|
||||
if (!(sourcePanel instanceof VizPanel)) {
|
||||
return;
|
||||
/**
|
||||
* Handles dashboard datasource queries by tracking all referenced panels.
|
||||
* Supports single or multiple queries with library panels and transformers.
|
||||
*/
|
||||
private _handleQueries(
|
||||
dashboardQueries: Array<{ panelId?: number; [key: string]: unknown }>,
|
||||
queryRunner: SceneQueryRunner,
|
||||
dashboard: DashboardScene
|
||||
): () => void {
|
||||
const libraryPanelSubs: Unsubscribable[] = [];
|
||||
const transformerSubs: Unsubscribable[] = [];
|
||||
let shouldRunQueries = false;
|
||||
|
||||
// Loop through ALL dashboard queries to track each panel
|
||||
for (const dashboardQuery of dashboardQueries) {
|
||||
const panelId = dashboardQuery.panelId;
|
||||
if (panelId === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vizKey = getVizPanelKeyForPanelId(panelId);
|
||||
const sourcePanel = findVizPanelByKey(dashboard, vizKey);
|
||||
|
||||
if (!(sourcePanel instanceof VizPanel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if the source panel is a library panel and wait for it to load
|
||||
const libraryPanelBehaviour = getLibraryPanelBehavior(sourcePanel);
|
||||
|
||||
if (libraryPanelBehaviour && !libraryPanelBehaviour.state.isLoaded) {
|
||||
const sub = libraryPanelBehaviour.subscribeToState((newLibPanel) => {
|
||||
this.handleLibPanelStateUpdates(newLibPanel, queryRunner, sourcePanel);
|
||||
});
|
||||
libraryPanelSubs.push(sub);
|
||||
continue; // Don't process transformers until library panel is loaded
|
||||
}
|
||||
|
||||
// Subscribe to transformer changes for this panel
|
||||
const sourcePanelQueryRunner = getQueryRunnerFor(sourcePanel);
|
||||
|
||||
if (!sourcePanelQueryRunner) {
|
||||
continue; // Skip panels without query runners instead of throwing
|
||||
}
|
||||
|
||||
// Check if this panel's requestId changed since last activation
|
||||
const currentRequestId = sourcePanelQueryRunner.state.data?.request?.requestId;
|
||||
const prevRequestId = this.prevRequestIds.get(panelId);
|
||||
|
||||
if (prevRequestId && currentRequestId && prevRequestId !== currentRequestId) {
|
||||
shouldRunQueries = true;
|
||||
}
|
||||
|
||||
const dataTransformer = sourcePanelQueryRunner.parent;
|
||||
|
||||
if (dataTransformer instanceof SceneDataTransformer && dataTransformer.state.transformations.length) {
|
||||
// In mixed DS scenario we complete the observable and merge data, so on a variable change
|
||||
// the data transformer will emit but there will be no subscription and thus no visual update
|
||||
// on the panel. Similar thing happens when going to edit mode and back, where we unsubscribe and
|
||||
// since we never re-run the query, only reprocess the transformations, the panel will not update.
|
||||
const transformerSub = dataTransformer.subscribeToState((newState, oldState) => {
|
||||
if (newState.data !== oldState.data) {
|
||||
queryRunner.runQueries();
|
||||
}
|
||||
});
|
||||
transformerSubs.push(transformerSub);
|
||||
}
|
||||
}
|
||||
|
||||
//check if the source panel is a library panel and wait for it to load
|
||||
const libraryPanelBehaviour = getLibraryPanelBehavior(sourcePanel);
|
||||
if (libraryPanelBehaviour && !libraryPanelBehaviour.state.isLoaded) {
|
||||
libraryPanelSub = libraryPanelBehaviour.subscribeToState((newLibPanel) => {
|
||||
this.handleLibPanelStateUpdates(newLibPanel, queryRunner, sourcePanel);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const sourcePanelQueryRunner = getQueryRunnerFor(sourcePanel);
|
||||
|
||||
if (!sourcePanelQueryRunner) {
|
||||
throw new Error('Could not find SceneQueryRunner for panel');
|
||||
}
|
||||
|
||||
const dataTransformer = sourcePanelQueryRunner.parent;
|
||||
|
||||
if (dataTransformer instanceof SceneDataTransformer && dataTransformer.state.transformations.length) {
|
||||
// in mixed DS scenario we complete the observable and merge data, so on a variable change
|
||||
// the data transformer will emit but there will be no subscription and thus not visual update
|
||||
// on the panel. Similar thing happens when going to edit mode and back, where we unsubscribe and
|
||||
// since we never re-run the query, only reprocess the transformations, the panel will not update.
|
||||
transformerSub = dataTransformer.subscribeToState((newState, oldState) => {
|
||||
if (newState.data !== oldState.data) {
|
||||
queryRunner.runQueries();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (this.prevRequestId && this.prevRequestId !== sourcePanelQueryRunner.state.data?.request?.requestId) {
|
||||
// If any panel's data changed since last activation, run queries
|
||||
if (shouldRunQueries) {
|
||||
queryRunner.runQueries();
|
||||
}
|
||||
|
||||
// Return cleanup function that unsubscribes from ALL subscriptions
|
||||
return () => {
|
||||
this.prevRequestId = sourcePanelQueryRunner?.state.data?.request?.requestId;
|
||||
if (libraryPanelSub) {
|
||||
libraryPanelSub.unsubscribe();
|
||||
// Store all current requestIds before cleanup
|
||||
for (const dashboardQuery of dashboardQueries) {
|
||||
const panelId = dashboardQuery.panelId;
|
||||
if (panelId === undefined) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const vizKey = getVizPanelKeyForPanelId(panelId);
|
||||
const sourcePanel = findVizPanelByKey(dashboard, vizKey);
|
||||
|
||||
if (!(sourcePanel instanceof VizPanel)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePanelQueryRunner = getQueryRunnerFor(sourcePanel);
|
||||
const requestId = sourcePanelQueryRunner?.state.data?.request?.requestId;
|
||||
|
||||
if (requestId) {
|
||||
this.prevRequestIds.set(panelId, requestId);
|
||||
}
|
||||
}
|
||||
|
||||
if (transformerSub) {
|
||||
transformerSub.unsubscribe();
|
||||
}
|
||||
libraryPanelSubs.forEach((sub) => sub.unsubscribe());
|
||||
transformerSubs.forEach((sub) => sub.unsubscribe());
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user