Skip to content

Commit 8f0052b

Browse files
fix: remove dangling variable references on model destroy (#165)
* fix: remove dangling variable references on model destroy * test: handle subscription cleanup more gracefully
1 parent a77a498 commit 8f0052b

12 files changed

+26195
-99
lines changed

.semaphore/semaphore.yml

Lines changed: 0 additions & 35 deletions
This file was deleted.

package-lock.json

Lines changed: 26003 additions & 47 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@
122122
"lodash-es": "^4.17.20"
123123
},
124124
"peerDependencies": {
125-
"rxjs": "^6.5.5",
126-
"core-js": "^3.6.5"
125+
"core-js": "^3.6.5",
126+
"rxjs": "^6.5.5"
127127
},
128128
"repository": {
129129
"type": "git",

src/hyperdash.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ export * from './model/editor/editor-library';
1919
export * from './model/events/model-changed-event';
2020
export * from './model/events/model-created-event';
2121
export * from './model/events/model-destroyed-event';
22+
export * from './model/events/before-model-destroyed-event';
2223
export * from './model/events/model-event-installer';
2324
export * from './model/manager/model-lifecycle-hooks';
2425
export * from './model/manager/model-manager';
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { DashboardEventManager } from '../../communication/dashboard-event-manager';
2+
import { getTestScheduler } from '../../test/rxjs-jest-test-scheduler';
3+
import { BeforeModelDestroyedEvent } from './before-model-destroyed-event';
4+
5+
describe('Before model destroyed event', () => {
6+
let mockEventManager: Partial<DashboardEventManager>;
7+
let beforeModelDestroyedEvent: BeforeModelDestroyedEvent;
8+
9+
beforeEach(() => {
10+
mockEventManager = {};
11+
beforeModelDestroyedEvent = new BeforeModelDestroyedEvent(mockEventManager as DashboardEventManager);
12+
});
13+
test('relays all publishes to manager', () => {
14+
mockEventManager.publishEvent = jest.fn();
15+
const first = {};
16+
const second = {};
17+
beforeModelDestroyedEvent.publish(first);
18+
beforeModelDestroyedEvent.publish(second);
19+
20+
expect(mockEventManager.publishEvent).toHaveBeenCalledTimes(2);
21+
22+
expect(mockEventManager.publishEvent).nthCalledWith(1, beforeModelDestroyedEvent.getKey(), first);
23+
24+
expect(mockEventManager.publishEvent).nthCalledWith(2, beforeModelDestroyedEvent.getKey(), second);
25+
});
26+
27+
test('gets observable from manager for this event', () => {
28+
mockEventManager.getObservableForEvent = jest.fn();
29+
30+
beforeModelDestroyedEvent.getObservable();
31+
32+
expect(mockEventManager.getObservableForEvent).toHaveBeenCalledWith(beforeModelDestroyedEvent.getKey());
33+
});
34+
35+
test('before destruction observables notifies on the provided model then completes', () => {
36+
getTestScheduler().run(({ cold, expectObservable }) => {
37+
const mockModels = {
38+
a: {},
39+
b: {}
40+
};
41+
beforeModelDestroyedEvent.getObservable = jest.fn().mockReturnValue(cold('a-b-', mockModels));
42+
43+
expectObservable(beforeModelDestroyedEvent.getBeforeDestructionObservable(mockModels.a)).toBe('(a|)', {
44+
a: undefined
45+
});
46+
47+
expectObservable(beforeModelDestroyedEvent.getBeforeDestructionObservable(mockModels.b)).toBe('--(b|)', {
48+
b: undefined
49+
});
50+
});
51+
});
52+
});
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Observable } from 'rxjs';
2+
import { filter, mapTo, take } from 'rxjs/operators';
3+
import { DashboardEvent } from '../../communication/dashboard-event';
4+
import { DashboardEventManager } from '../../communication/dashboard-event-manager';
5+
6+
export const beforeModelDestroyedEventKey = Symbol('Before model destroyed');
7+
8+
/**
9+
* Fired before a model is destroyed and any destroy hooks are called.
10+
*/
11+
export class BeforeModelDestroyedEvent extends DashboardEvent<object> {
12+
public constructor(dashboardEventManager: DashboardEventManager) {
13+
super(dashboardEventManager, beforeModelDestroyedEventKey);
14+
}
15+
16+
/**
17+
* Returns a void observable that will notify once when the provided model is
18+
* destroyed, then complete.
19+
*/
20+
public getBeforeDestructionObservable(model: object): Observable<void> {
21+
return this.getObservable().pipe(
22+
filter(destroyedModel => destroyedModel === model),
23+
mapTo(undefined),
24+
take(1)
25+
);
26+
}
27+
}

src/model/manager/model-manager.test.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,24 @@ import { PartialObjectMock } from '../../test/partial-object-mock';
33
import { Logger } from '../../util/logging/logger';
44
import { ModelApiBuilder } from '../api/builder/model-api-builder';
55
import { ModelApi } from '../api/model-api';
6+
import { BeforeModelDestroyedEvent } from '../events/before-model-destroyed-event';
67
import { ModelCreatedEvent } from '../events/model-created-event';
78
import { ModelDestroyedEvent } from '../events/model-destroyed-event';
89
import { ModelOnDestroy, ModelOnInit } from './model-lifecycle-hooks';
910
import { ModelManager } from './model-manager';
1011

1112
describe('Model manager', () => {
12-
const testClass = class TestClass {};
13+
const testClass = class TestClass {
14+
// Value used to disambiguate equality between instances
15+
public readonly value: number = Math.random();
16+
};
1317
let manager: ModelManager;
1418
let mockLogger: PartialObjectMock<Logger>;
1519
let mockApiBuilder: PartialObjectMock<ModelApiBuilder<ModelApi>>;
1620

1721
let mockCreatedEvent: PartialObjectMock<ModelCreatedEvent>;
1822
let mockDestroyedEvent: PartialObjectMock<ModelDestroyedEvent>;
23+
let mockBeforeDestroyedEvent: PartialObjectMock<BeforeModelDestroyedEvent>;
1924

2025
beforeEach(() => {
2126
mockCreatedEvent = {
@@ -26,6 +31,10 @@ describe('Model manager', () => {
2631
publish: jest.fn()
2732
};
2833

34+
mockBeforeDestroyedEvent = {
35+
publish: jest.fn()
36+
};
37+
2938
mockLogger = {
3039
warn: jest.fn((message: string) => ({
3140
throw: jest.fn(() => {
@@ -43,7 +52,8 @@ describe('Model manager', () => {
4352
manager = new ModelManager(
4453
mockLogger as Logger,
4554
mockCreatedEvent as ModelCreatedEvent,
46-
mockDestroyedEvent as ModelDestroyedEvent
55+
mockDestroyedEvent as ModelDestroyedEvent,
56+
mockBeforeDestroyedEvent as BeforeModelDestroyedEvent
4757
);
4858

4959
manager.registerModelApiBuilder(mockApiBuilder as ModelApiBuilder<ModelApi>);
@@ -233,8 +243,17 @@ describe('Model manager', () => {
233243
expect(mockCreatedEvent.publish).toHaveBeenNthCalledWith(1, root);
234244
expect(mockCreatedEvent.publish).toHaveBeenNthCalledWith(2, firstChild);
235245

246+
mockBeforeDestroyedEvent.publish = jest.fn(model => {
247+
expect(manager.isTrackedModel(model)).toBe(true);
248+
expect(mockDestroyedEvent.publish).not.toHaveBeenCalledWith(model);
249+
});
250+
236251
manager.destroy(root);
237252

253+
expect(mockBeforeDestroyedEvent.publish).toHaveBeenCalledTimes(2);
254+
expect(mockBeforeDestroyedEvent.publish).toHaveBeenNthCalledWith(1, firstChild);
255+
expect(mockBeforeDestroyedEvent.publish).toHaveBeenNthCalledWith(2, root);
256+
238257
expect(mockDestroyedEvent.publish).toHaveBeenCalledTimes(2);
239258
expect(mockDestroyedEvent.publish).toHaveBeenNthCalledWith(1, firstChild);
240259
expect(mockDestroyedEvent.publish).toHaveBeenNthCalledWith(2, root);
@@ -292,7 +311,8 @@ describe('Model manager', () => {
292311
manager = new ModelManager(
293312
mockLogger as Logger,
294313
mockCreatedEvent as ModelCreatedEvent,
295-
mockDestroyedEvent as ModelDestroyedEvent
314+
mockDestroyedEvent as ModelDestroyedEvent,
315+
mockBeforeDestroyedEvent as BeforeModelDestroyedEvent
296316
); // Rebuild so we don't use the mock api build from other tests
297317

298318
const modelAClass = class {};

src/model/manager/model-manager.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Constructable } from '../../util/constructable';
33
import { Logger } from '../../util/logging/logger';
44
import { ModelApiBuilder } from '../api/builder/model-api-builder';
55
import { ModelApi } from '../api/model-api';
6+
import { BeforeModelDestroyedEvent } from '../events/before-model-destroyed-event';
67
import { ModelCreatedEvent } from '../events/model-created-event';
78
import { ModelDestroyedEvent } from '../events/model-destroyed-event';
89
import { ModelOnDestroy, ModelOnInit } from './model-lifecycle-hooks';
@@ -19,7 +20,8 @@ export class ModelManager {
1920
public constructor(
2021
private readonly logger: Logger,
2122
private readonly modelCreatedEvent: ModelCreatedEvent,
22-
private readonly modelDestroyedEvent: ModelDestroyedEvent
23+
private readonly modelDestroyedEvent: ModelDestroyedEvent,
24+
private readonly beforeModelDestroyedEvent: BeforeModelDestroyedEvent
2325
) {}
2426

2527
/**
@@ -233,6 +235,8 @@ export class ModelManager {
233235
// Depth first, destroy children before self
234236
instanceData.children.forEach(child => this.destroy(child));
235237

238+
this.beforeModelDestroyedEvent.publish(modelInstance);
239+
236240
if (this.modelHasDestroyHook(modelInstance)) {
237241
modelInstance.modelOnDestroy();
238242
}

src/variable/manager/variable-manager.test.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// tslint:disable:no-invalid-template-strings
2+
import { EMPTY, Subject } from 'rxjs';
3+
import { BeforeModelDestroyedEvent } from '../../model/events/before-model-destroyed-event';
24
import { ModelChangedEvent } from '../../model/events/model-changed-event';
35
import { ModelManager } from '../../model/manager/model-manager';
46
import { PropertyLocation } from '../../model/property/property-location';
@@ -11,13 +13,15 @@ describe('Variable manager', () => {
1113
let mockLogger: Partial<Logger>;
1214
let mockModelManager: Partial<ModelManager>;
1315
let mockModelChangedEvent: Partial<ModelChangedEvent>;
16+
let mockBeforeModelDestroyedEvent: Partial<BeforeModelDestroyedEvent>;
1417
const model = {};
1518
const parent = {};
1619
const root = {};
1720

1821
beforeEach(() => {
1922
mockLogger = {
20-
warn: jest.fn()
23+
warn: jest.fn(),
24+
error: jest.fn()
2125
};
2226
mockModelChangedEvent = {
2327
publishChange: jest.fn()
@@ -35,10 +39,15 @@ describe('Variable manager', () => {
3539
})
3640
};
3741

42+
mockBeforeModelDestroyedEvent = {
43+
getBeforeDestructionObservable: jest.fn()
44+
};
45+
3846
manager = new VariableManager(
3947
mockLogger as Logger,
4048
mockModelManager as ModelManager,
41-
mockModelChangedEvent as ModelChangedEvent
49+
mockModelChangedEvent as ModelChangedEvent,
50+
mockBeforeModelDestroyedEvent as BeforeModelDestroyedEvent
4251
);
4352
});
4453

@@ -106,6 +115,7 @@ describe('Variable manager reference tracking', () => {
106115
let mockLogger: PartialObjectMock<Logger>;
107116
let mockModelManager: PartialObjectMock<ModelManager>;
108117
let mockModelChangedEvent: PartialObjectMock<ModelChangedEvent>;
118+
let mockBeforeModelDestroyedEvent: Partial<BeforeModelDestroyedEvent>;
109119

110120
let mockParentLocation: PartialObjectMock<PropertyLocation>;
111121
const parent = {};
@@ -132,6 +142,10 @@ describe('Variable manager reference tracking', () => {
132142
)
133143
};
134144

145+
mockBeforeModelDestroyedEvent = {
146+
getBeforeDestructionObservable: jest.fn().mockReturnValue(EMPTY)
147+
};
148+
135149
mockModelLocation = {
136150
parentModel: model,
137151
setProperty: jest.fn(),
@@ -147,7 +161,8 @@ describe('Variable manager reference tracking', () => {
147161
manager = new VariableManager(
148162
mockLogger as Logger,
149163
mockModelManager as ModelManager,
150-
mockModelChangedEvent as ModelChangedEvent
164+
mockModelChangedEvent as ModelChangedEvent,
165+
mockBeforeModelDestroyedEvent as BeforeModelDestroyedEvent
151166
);
152167
});
153168

@@ -323,4 +338,45 @@ describe('Variable manager reference tracking', () => {
323338
'Attempted to resolve reference at modelProp which does not contain a registered reference'
324339
);
325340
});
341+
342+
test('removes dangling references after a model is destroyed', () => {
343+
const destroySubject = new Subject();
344+
mockBeforeModelDestroyedEvent.getBeforeDestructionObservable = jest
345+
.fn()
346+
.mockReturnValue(destroySubject.asObservable());
347+
348+
manager.registerReference(mockModelLocation as PropertyLocation, '${test}');
349+
manager.set('test', 'foo', parent);
350+
351+
destroySubject.next(model);
352+
destroySubject.complete();
353+
354+
// Reference should no longer be tracked
355+
expect(manager.isVariableReference(mockModelLocation as PropertyLocation)).toBe(false);
356+
357+
// Setting a value should not call model manager or set property
358+
(mockModelManager.getParent as jest.Mock).mockClear();
359+
manager.set('test', 'bar', parent);
360+
361+
expect(mockModelManager.getParent).not.toHaveBeenCalled();
362+
expect(mockModelLocation.setProperty).not.toHaveBeenCalledWith('bar');
363+
});
364+
365+
test('dangling reference removal handles case where reference previously removed', () => {
366+
const destroySubject = new Subject();
367+
mockBeforeModelDestroyedEvent.getBeforeDestructionObservable = jest
368+
.fn()
369+
.mockReturnValue(destroySubject.asObservable());
370+
371+
manager.registerReference(mockModelLocation as PropertyLocation, '${test}');
372+
manager.set('test', 'foo', parent);
373+
manager.deregisterReference(mockModelLocation as PropertyLocation);
374+
375+
// Reference should no longer be tracked
376+
expect(manager.isVariableReference(mockModelLocation as PropertyLocation)).toBe(false);
377+
378+
destroySubject.next(model);
379+
destroySubject.complete();
380+
expect(mockLogger.error).not.toHaveBeenCalled();
381+
});
326382
});

0 commit comments

Comments
 (0)