Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: remove dangling variable references on model destroy #165

Merged
merged 3 commits into from
Feb 1, 2021
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 0 additions & 35 deletions .semaphore/semaphore.yml

This file was deleted.

26,050 changes: 26,003 additions & 47 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,8 +122,8 @@
"lodash-es": "^4.17.20"
},
"peerDependencies": {
"rxjs": "^6.5.5",
"core-js": "^3.6.5"
"core-js": "^3.6.5",
"rxjs": "^6.5.5"
},
"repository": {
"type": "git",
Expand Down
1 change: 1 addition & 0 deletions src/hyperdash.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export * from './model/editor/editor-library';
export * from './model/events/model-changed-event';
export * from './model/events/model-created-event';
export * from './model/events/model-destroyed-event';
export * from './model/events/before-model-destroyed-event';
export * from './model/events/model-event-installer';
export * from './model/manager/model-lifecycle-hooks';
export * from './model/manager/model-manager';
Expand Down
52 changes: 52 additions & 0 deletions src/model/events/before-model-destroyed-event.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { DashboardEventManager } from '../../communication/dashboard-event-manager';
import { getTestScheduler } from '../../test/rxjs-jest-test-scheduler';
import { BeforeModelDestroyedEvent } from './before-model-destroyed-event';

describe('Before model destroyed event', () => {
let mockEventManager: Partial<DashboardEventManager>;
let beforeModelDestroyedEvent: BeforeModelDestroyedEvent;

beforeEach(() => {
mockEventManager = {};
beforeModelDestroyedEvent = new BeforeModelDestroyedEvent(mockEventManager as DashboardEventManager);
});
test('relays all publishes to manager', () => {
mockEventManager.publishEvent = jest.fn();
const first = {};
const second = {};
beforeModelDestroyedEvent.publish(first);
beforeModelDestroyedEvent.publish(second);

expect(mockEventManager.publishEvent).toHaveBeenCalledTimes(2);

expect(mockEventManager.publishEvent).nthCalledWith(1, beforeModelDestroyedEvent.getKey(), first);

expect(mockEventManager.publishEvent).nthCalledWith(2, beforeModelDestroyedEvent.getKey(), second);
});

test('gets observable from manager for this event', () => {
mockEventManager.getObservableForEvent = jest.fn();

beforeModelDestroyedEvent.getObservable();

expect(mockEventManager.getObservableForEvent).toHaveBeenCalledWith(beforeModelDestroyedEvent.getKey());
});

test('before destruction observables notifies on the provided model then completes', () => {
getTestScheduler().run(({ cold, expectObservable }) => {
const mockModels = {
a: {},
b: {}
};
beforeModelDestroyedEvent.getObservable = jest.fn().mockReturnValue(cold('a-b-', mockModels));

expectObservable(beforeModelDestroyedEvent.getBeforeDestructionObservable(mockModels.a)).toBe('(a|)', {
a: undefined
});

expectObservable(beforeModelDestroyedEvent.getBeforeDestructionObservable(mockModels.b)).toBe('--(b|)', {
b: undefined
});
});
});
});
27 changes: 27 additions & 0 deletions src/model/events/before-model-destroyed-event.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Observable } from 'rxjs';
import { filter, mapTo, take } from 'rxjs/operators';
import { DashboardEvent } from '../../communication/dashboard-event';
import { DashboardEventManager } from '../../communication/dashboard-event-manager';

export const beforeModelDestroyedEventKey = Symbol('Before model destroyed');

/**
* Fired before a model is destroyed and any destroy hooks are called.
*/
export class BeforeModelDestroyedEvent extends DashboardEvent<object> {
public constructor(dashboardEventManager: DashboardEventManager) {
super(dashboardEventManager, beforeModelDestroyedEventKey);
}

/**
* Returns a void observable that will notify once when the provided model is
* destroyed, then complete.
*/
public getBeforeDestructionObservable(model: object): Observable<void> {
return this.getObservable().pipe(
filter(destroyedModel => destroyedModel === model),
mapTo(undefined),
take(1)
);
}
}
26 changes: 23 additions & 3 deletions src/model/manager/model-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,24 @@ import { PartialObjectMock } from '../../test/partial-object-mock';
import { Logger } from '../../util/logging/logger';
import { ModelApiBuilder } from '../api/builder/model-api-builder';
import { ModelApi } from '../api/model-api';
import { BeforeModelDestroyedEvent } from '../events/before-model-destroyed-event';
import { ModelCreatedEvent } from '../events/model-created-event';
import { ModelDestroyedEvent } from '../events/model-destroyed-event';
import { ModelOnDestroy, ModelOnInit } from './model-lifecycle-hooks';
import { ModelManager } from './model-manager';

describe('Model manager', () => {
const testClass = class TestClass {};
const testClass = class TestClass {
// Value used to disambiguate equality between instances
public readonly value: number = Math.random();
};
let manager: ModelManager;
let mockLogger: PartialObjectMock<Logger>;
let mockApiBuilder: PartialObjectMock<ModelApiBuilder<ModelApi>>;

let mockCreatedEvent: PartialObjectMock<ModelCreatedEvent>;
let mockDestroyedEvent: PartialObjectMock<ModelDestroyedEvent>;
let mockBeforeDestroyedEvent: PartialObjectMock<BeforeModelDestroyedEvent>;

beforeEach(() => {
mockCreatedEvent = {
Expand All @@ -26,6 +31,10 @@ describe('Model manager', () => {
publish: jest.fn()
};

mockBeforeDestroyedEvent = {
publish: jest.fn()
};

mockLogger = {
warn: jest.fn((message: string) => ({
throw: jest.fn(() => {
Expand All @@ -43,7 +52,8 @@ describe('Model manager', () => {
manager = new ModelManager(
mockLogger as Logger,
mockCreatedEvent as ModelCreatedEvent,
mockDestroyedEvent as ModelDestroyedEvent
mockDestroyedEvent as ModelDestroyedEvent,
mockBeforeDestroyedEvent as BeforeModelDestroyedEvent
);

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

mockBeforeDestroyedEvent.publish = jest.fn(model => {
expect(manager.isTrackedModel(model)).toBe(true);
expect(mockDestroyedEvent.publish).not.toHaveBeenCalledWith(model);
});

manager.destroy(root);

expect(mockBeforeDestroyedEvent.publish).toHaveBeenCalledTimes(2);
expect(mockBeforeDestroyedEvent.publish).toHaveBeenNthCalledWith(1, firstChild);
expect(mockBeforeDestroyedEvent.publish).toHaveBeenNthCalledWith(2, root);

expect(mockDestroyedEvent.publish).toHaveBeenCalledTimes(2);
expect(mockDestroyedEvent.publish).toHaveBeenNthCalledWith(1, firstChild);
expect(mockDestroyedEvent.publish).toHaveBeenNthCalledWith(2, root);
Expand Down Expand Up @@ -292,7 +311,8 @@ describe('Model manager', () => {
manager = new ModelManager(
mockLogger as Logger,
mockCreatedEvent as ModelCreatedEvent,
mockDestroyedEvent as ModelDestroyedEvent
mockDestroyedEvent as ModelDestroyedEvent,
mockBeforeDestroyedEvent as BeforeModelDestroyedEvent
); // Rebuild so we don't use the mock api build from other tests

const modelAClass = class {};
Expand Down
6 changes: 5 additions & 1 deletion src/model/manager/model-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Constructable } from '../../util/constructable';
import { Logger } from '../../util/logging/logger';
import { ModelApiBuilder } from '../api/builder/model-api-builder';
import { ModelApi } from '../api/model-api';
import { BeforeModelDestroyedEvent } from '../events/before-model-destroyed-event';
import { ModelCreatedEvent } from '../events/model-created-event';
import { ModelDestroyedEvent } from '../events/model-destroyed-event';
import { ModelOnDestroy, ModelOnInit } from './model-lifecycle-hooks';
Expand All @@ -19,7 +20,8 @@ export class ModelManager {
public constructor(
private readonly logger: Logger,
private readonly modelCreatedEvent: ModelCreatedEvent,
private readonly modelDestroyedEvent: ModelDestroyedEvent
private readonly modelDestroyedEvent: ModelDestroyedEvent,
private readonly beforeModelDestroyedEvent: BeforeModelDestroyedEvent
) {}

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

this.beforeModelDestroyedEvent.publish(modelInstance);

if (this.modelHasDestroyHook(modelInstance)) {
modelInstance.modelOnDestroy();
}
Expand Down
62 changes: 59 additions & 3 deletions src/variable/manager/variable-manager.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
// tslint:disable:no-invalid-template-strings
import { EMPTY, Subject } from 'rxjs';
import { BeforeModelDestroyedEvent } from '../../model/events/before-model-destroyed-event';
import { ModelChangedEvent } from '../../model/events/model-changed-event';
import { ModelManager } from '../../model/manager/model-manager';
import { PropertyLocation } from '../../model/property/property-location';
Expand All @@ -11,13 +13,15 @@ describe('Variable manager', () => {
let mockLogger: Partial<Logger>;
let mockModelManager: Partial<ModelManager>;
let mockModelChangedEvent: Partial<ModelChangedEvent>;
let mockBeforeModelDestroyedEvent: Partial<BeforeModelDestroyedEvent>;
const model = {};
const parent = {};
const root = {};

beforeEach(() => {
mockLogger = {
warn: jest.fn()
warn: jest.fn(),
error: jest.fn()
};
mockModelChangedEvent = {
publishChange: jest.fn()
Expand All @@ -35,10 +39,15 @@ describe('Variable manager', () => {
})
};

mockBeforeModelDestroyedEvent = {
getBeforeDestructionObservable: jest.fn()
};

manager = new VariableManager(
mockLogger as Logger,
mockModelManager as ModelManager,
mockModelChangedEvent as ModelChangedEvent
mockModelChangedEvent as ModelChangedEvent,
mockBeforeModelDestroyedEvent as BeforeModelDestroyedEvent
);
});

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

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

mockBeforeModelDestroyedEvent = {
getBeforeDestructionObservable: jest.fn().mockReturnValue(EMPTY)
};

mockModelLocation = {
parentModel: model,
setProperty: jest.fn(),
Expand All @@ -147,7 +161,8 @@ describe('Variable manager reference tracking', () => {
manager = new VariableManager(
mockLogger as Logger,
mockModelManager as ModelManager,
mockModelChangedEvent as ModelChangedEvent
mockModelChangedEvent as ModelChangedEvent,
mockBeforeModelDestroyedEvent as BeforeModelDestroyedEvent
);
});

Expand Down Expand Up @@ -323,4 +338,45 @@ describe('Variable manager reference tracking', () => {
'Attempted to resolve reference at modelProp which does not contain a registered reference'
);
});

test('removes dangling references after a model is destroyed', () => {
const destroySubject = new Subject();
mockBeforeModelDestroyedEvent.getBeforeDestructionObservable = jest
.fn()
.mockReturnValue(destroySubject.asObservable());

manager.registerReference(mockModelLocation as PropertyLocation, '${test}');
manager.set('test', 'foo', parent);

destroySubject.next(model);
destroySubject.complete();

// Reference should no longer be tracked
expect(manager.isVariableReference(mockModelLocation as PropertyLocation)).toBe(false);

// Setting a value should not call model manager or set property
(mockModelManager.getParent as jest.Mock).mockClear();
manager.set('test', 'bar', parent);

expect(mockModelManager.getParent).not.toHaveBeenCalled();
expect(mockModelLocation.setProperty).not.toHaveBeenCalledWith('bar');
});

test('dangling reference removal handles case where reference previously removed', () => {
const destroySubject = new Subject();
mockBeforeModelDestroyedEvent.getBeforeDestructionObservable = jest
.fn()
.mockReturnValue(destroySubject.asObservable());

manager.registerReference(mockModelLocation as PropertyLocation, '${test}');
manager.set('test', 'foo', parent);
manager.deregisterReference(mockModelLocation as PropertyLocation);

// Reference should no longer be tracked
expect(manager.isVariableReference(mockModelLocation as PropertyLocation)).toBe(false);

destroySubject.next(model);
destroySubject.complete();
expect(mockLogger.error).not.toHaveBeenCalled();
});
});
Loading