Skip to content

Commit 8f0052b

Browse files
authoredFeb 1, 2021
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

-35
This file was deleted.

‎package-lock.json

+26,003-47
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎package.json

+2-2
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

+1
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';
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+
});
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

+23-3
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

+5-1
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

+59-3
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
});

‎src/variable/manager/variable-manager.ts

+13-5
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { fromPairs } from 'lodash-es';
2+
import { BeforeModelDestroyedEvent } from '../../model/events/before-model-destroyed-event';
23
import { ModelChangedEvent } from '../../model/events/model-changed-event';
34
import { ModelManager } from '../../model/manager/model-manager';
45
import { PropertyLocation } from '../../model/property/property-location';
@@ -22,7 +23,8 @@ export class VariableManager {
2223
public constructor(
2324
private readonly logger: Logger,
2425
private readonly modelManager: ModelManager,
25-
private readonly modelChangedEvent: ModelChangedEvent
26+
private readonly modelChangedEvent: ModelChangedEvent,
27+
private readonly beforeModelDestroyedEvent: BeforeModelDestroyedEvent
2628
) {}
2729

2830
/**
@@ -76,17 +78,22 @@ export class VariableManager {
7678
* Throws Error if the provided location is already being tracked
7779
*/
7880
public registerReference(location: PropertyLocation, variableExpression: string): unknown {
79-
let reference = new VariableReference(variableExpression, location);
80-
8181
const referenceMap = this.getOrCreateReferenceMapForModelContainingLocation(location);
8282

8383
if (referenceMap.has(location.toString())) {
8484
this.logger.error(`Attempting to register reference which has already been declared at ${location.toString()}`);
85-
reference = referenceMap.get(location.toString())!;
8685
} else {
87-
referenceMap.set(location.toString(), reference);
86+
const autoCleanupSubscription = this.beforeModelDestroyedEvent
87+
.getBeforeDestructionObservable(location.parentModel)
88+
.subscribe(() => this.deregisterReference(location));
89+
referenceMap.set(
90+
location.toString(),
91+
new VariableReference(variableExpression, location, autoCleanupSubscription)
92+
);
8893
}
8994

95+
const reference = referenceMap.get(location.toString())!;
96+
9097
return this.updateReference(reference);
9198
}
9299

@@ -126,6 +133,7 @@ export class VariableManager {
126133
this.getOrCreateReferenceMapForModelContainingLocation(location).delete(reference.location.toString());
127134

128135
const result = reference.unresolve();
136+
reference.autoCleanupSubscription.unsubscribe();
129137
this.updateValueReferenceTrackingFromEvaluationResult(reference, result);
130138

131139
return result.value!;

‎src/variable/reference/variable-reference.test.ts

+4-2
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
// tslint:disable:no-invalid-template-strings
2+
import { Subscription } from 'rxjs';
23
import { PropertyLocation } from '../../model/property/property-location';
34
import { EvaluationResult, VariableEvaluator } from '../evaluator/variable-evaluator';
45
import { VariableReference } from './variable-reference';
@@ -10,6 +11,7 @@ describe('Variable reference', () => {
1011
let mockLocation: Partial<PropertyLocation>;
1112
let mockEvaluationResult: Partial<EvaluationResult<unknown>>;
1213
let mockUnevaluationResult: Partial<EvaluationResult<unknown>>;
14+
const closeSubscription: Subscription = new Subscription();
1315

1416
beforeEach(() => {
1517
mockedVariableEvaluatorConstructor.mockReset();
@@ -32,14 +34,14 @@ describe('Variable reference', () => {
3234
});
3335

3436
test('resolve sets the property to the evaluation result', () => {
35-
const ref = new VariableReference('${test}', mockLocation as PropertyLocation);
37+
const ref = new VariableReference('${test}', mockLocation as PropertyLocation, closeSubscription);
3638

3739
expect(ref.resolve({})).toBe(mockEvaluationResult);
3840
expect(mockLocation.setProperty).toHaveBeenCalledWith(mockEvaluationResult.value);
3941
});
4042

4143
test('unresolve returns the unevaluate result', () => {
42-
const ref = new VariableReference('${test}', mockLocation as PropertyLocation);
44+
const ref = new VariableReference('${test}', mockLocation as PropertyLocation, closeSubscription);
4345
expect(ref.unresolve()).toBe(mockUnevaluationResult);
4446
});
4547
});

‎src/variable/reference/variable-reference.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Subscription } from 'rxjs';
12
import { PropertyLocation } from '../../model/property/property-location';
23
import { EvaluationResult, VariableEvaluator } from '../evaluator/variable-evaluator';
34
import { ResolveDictionary } from '../variable-dictionary';
@@ -8,7 +9,11 @@ import { ResolveDictionary } from '../variable-dictionary';
89
export class VariableReference<T = unknown> {
910
private readonly evaluator: VariableEvaluator<T>;
1011

11-
public constructor(variableString: string, public readonly location: PropertyLocation<T>) {
12+
public constructor(
13+
variableString: string,
14+
public readonly location: PropertyLocation<T>,
15+
public readonly autoCleanupSubscription: Subscription
16+
) {
1217
this.evaluator = new VariableEvaluator(variableString);
1318
}
1419

0 commit comments

Comments
 (0)
Please sign in to comment.