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

[BUGFIX] ensure destroy-sync cleanup is correct #5438

Merged
merged 3 commits into from
Apr 19, 2018
Merged
Show file tree
Hide file tree
Changes from 2 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
12 changes: 7 additions & 5 deletions addon/-private/system/model/internal-model.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ export default class InternalModel {
this._record = null;
this._isDestroyed = false;
this.isError = false;
this._isUpdatingRecordArrays = false; // used by the recordArrayManager
this._pendingRecordArrayManagerFlush = false; // used by the recordArrayManager

// During dematerialization we don't want to rematerialize the record. The
// reason this might happen is that dematerialization removes records from
Expand Down Expand Up @@ -415,13 +415,15 @@ export default class InternalModel {
}

dematerializeRecord() {
this._isDematerializing = true;

if (this._record) {
this._isDematerializing = true;
this._record.destroy();
this.destroyRelationships();
this.resetRecord();
}

// move to an empty never-loaded state
this.destroyRelationships();
this.resetRecord();
this.updateRecordArrays();
}

Expand Down Expand Up @@ -607,7 +609,7 @@ export default class InternalModel {

destroy() {
assert("Cannot destroy an internalModel while its record is materialized", !this._record || this._record.get('isDestroyed') || this._record.get('isDestroying'));

this.isDestroying = true;
this.store._internalModelDestroyed(this);

this._relationships.forEach((name, rel) => rel.destroy());
Expand Down
2 changes: 1 addition & 1 deletion addon/-private/system/relationships/state/has-many.js
Original file line number Diff line number Diff line change
Expand Up @@ -167,9 +167,9 @@ export default class ManyRelationship extends Relationship {
}

removeAllCanonicalInternalModelsFromOwn() {
super.removeAllCanonicalInternalModelsFromOwn();
this.canonicalMembers.clear();
this.canonicalState.splice(0, this.canonicalState.length);
super.removeAllCanonicalInternalModelsFromOwn();
}

removeCompletelyFromOwn(internalModel) {
Expand Down
23 changes: 16 additions & 7 deletions addon/-private/system/store.js
Original file line number Diff line number Diff line change
Expand Up @@ -1103,15 +1103,20 @@ Store = Service.extend({
let internalModel = this._internalModelsFor(modelName).get(trueId);

if (internalModel) {
// unloadRecord is async, if one attempts to unload + then sync push,
// we must ensure the unload is canceled before continuing
// The createRecord path will take _existingInternalModelForId()
// which will call `destroySync` instead for this unload + then
// sync createRecord scenario. Once we have true client-side
// delete signaling, we should never call destroySync
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These comments are good but this logic relies on _internalModelForId and _existingInternalModelForId being called from separate code paths which is nonobvious and brittle.

I'm 👍 on merging this to get the fix in, but we'll want to clean this up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My hope is that once we have remove operations we will simply stop supporting unloadRecord + createRecord which is the only reason why this divergence exists. Instead you would be required to remove before the add to ensure the store fully forgets about the previous record by that ID.

if (internalModel.hasScheduledDestroy()) {
internalModel.destroySync();
return this._buildInternalModel(modelName, trueId);
} else {
return internalModel;
internalModel.cancelDestroy();
}
} else {
return this._buildInternalModel(modelName, trueId);

return internalModel;
}

return this._buildInternalModel(modelName, trueId);
},

_internalModelDidReceiveRelationshipData(modelName, id, relationshipData) {
Expand Down Expand Up @@ -2555,7 +2560,11 @@ Store = Service.extend({

if (internalModel && internalModel.hasScheduledDestroy()) {
// unloadRecord is async, if one attempts to unload + then sync create,
// we must ensure the unload is complete before starting the create
// we must ensure the unload is complete before starting the create
// The push path will take _internalModelForId()
// which will call `cancelDestroy` instead for this unload + then
// sync push scenario. Once we have true client-side
// delete signaling, we should never call destroySync
internalModel.destroySync();
internalModel = null;
}
Expand Down
106 changes: 98 additions & 8 deletions tests/integration/records/unload-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,92 @@ test('unloadAll(type) does not leave stranded internalModels in relationships (r
assert.ok(reloadedBoatInternalModel === initialBoatInternalModel, 'after an unloadAll, subsequent fetch results in the same InternalModel');
});

test('(regression) unloadRecord followed by push in the same run-loop', function(assert) {
let { store } = env;

let person = run(() => store.push({
data: {
type: 'person',
id: '1',
attributes: {
name: 'Could be Anybody'
},
relationships: {
boats: {
data: [
{ type: 'boat', id: '1' }
]
}
}
},
included: [
makeBoatOneForPersonOne()
]
}));

let boat = store.peekRecord('boat', '1');
let initialBoatInternalModel = boat._internalModel;
let relationshipState = person.hasMany('boats').hasManyRelationship;
let knownPeople = env.store._internalModelsFor('person');
let knownBoats = store._internalModelsFor('boat');

// ensure we loaded the people and boats
assert.equal(knownPeople.models.length, 1, 'one person record is loaded');
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These are fine, but better assertions would match against eg an array of ids rather than just the length as that would catch more regressions.

eg

assert.deepEqual(knownPeople.models.map(m => m.id), ['1'], 'one person record is loaded');

i don't think it matters enough to not merge an important bug fix, but is an improvement we can make to the tests.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(the above comment applies to the rest of the tests that assert against size)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

assert.equal(knownBoats.models.length, 1, 'one boat record is loaded');
assert.equal(env.store.hasRecordForId('person', '1'), true);
assert.equal(env.store.hasRecordForId('boat', '1'), true);

// ensure the relationship was established (we reach through the async proxy here)
let peopleBoats = run(() => person.get('boats.content'));
let boatPerson = run(() => boat.get('person.content'));

assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should be 1');
assert.equal(relationshipState.members.size, 1, 'members size should be 1');
assert.ok(get(peopleBoats, 'length') === 1, 'Our person has a boat');
assert.ok(peopleBoats.objectAt(0) === boat, 'Our person has the right boat');
assert.ok(boatPerson === person, 'Our boat has the right person');

run(() => boat.unloadRecord());

// ensure that our new state is correct
assert.equal(knownPeople.models.length, 1, 'one person record is loaded');
assert.equal(knownBoats.models.length, 1, 'one boat record is known');
assert.ok(knownBoats.models[0] === initialBoatInternalModel, 'We still have our boat');
assert.equal(initialBoatInternalModel.isEmpty(), true, 'Model is in the empty state');
assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should still be 1');
assert.equal(relationshipState.members.size, 1, 'members size should still be 1');
assert.ok(get(peopleBoats, 'length') === 0, 'Our person thinks they have no boats');

run(() => store.push({
data: makeBoatOneForPersonOne()
}));

let reloadedBoat = store.peekRecord('boat', '1');
let reloadedBoatInternalModel = reloadedBoat._internalModel;

assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should be 1');
assert.equal(relationshipState.members.size, 1, 'members size should be 1');
assert.ok(reloadedBoatInternalModel === initialBoatInternalModel, 'after an unloadRecord, subsequent fetch results in the same InternalModel');

// and now the kicker, run-loop fun!
// here, we will dematerialize the record, but push it back into the store
// all in the same run-loop!
// effectively this tests that our destroySync is not stupid
run(() => {
reloadedBoat.unloadRecord();
store.push({
data: makeBoatOneForPersonOne()
});
});

let yaBoat = store.peekRecord('boat', '1');
let yaBoatInternalModel = yaBoat._internalModel;

assert.equal(relationshipState.canonicalMembers.size, 1, 'canonical member size should be 1');
assert.equal(relationshipState.members.size, 1, 'members size should be 1');
assert.ok(yaBoatInternalModel === initialBoatInternalModel, 'after an unloadRecord, subsequent same-loop push results in the same InternalModel');
});

test('unloading a disconnected subgraph clears the relevant internal models', function(assert) {
env.adapter.shouldBackgroundReloadRecord = () => false;

Expand Down Expand Up @@ -632,7 +718,6 @@ test('unloading a disconnected subgraph clears the relevant internal models', fu
});
});


test('Unloading a record twice only schedules destroy once', function(assert) {
const store = env.store;
let record;
Expand Down Expand Up @@ -753,7 +838,7 @@ test('after unloading a record, the record can be fetched again immediately', fu
assert.equal(record.isDestroying, true, 'the record is destroying');
assert.equal(internalModel.currentState.stateName, 'root.empty', 'We are unloaded after unloadRecord');
return store.findRecord('person', '1').then(newRecord => {
assert.equal(internalModel.currentState.stateName, 'root.empty', 'the old internalModel is discarded');
assert.ok(internalModel === newRecord._internalModel, 'the old internalModel is reused');
assert.equal(newRecord._internalModel.currentState.stateName, 'root.loaded.saved', 'We are loaded after findRecord');
});
});
Expand All @@ -772,7 +857,9 @@ test('after unloading a record, the record can be fetched again immediately (pur
name: 'Adam Sunderland'
},
relationships: {
cars: { data: null }
cars: {
data: []
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does this payload change? these are both equivalent relationship payloads no?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"cosmetic".

[] for empty collections, null for empty resource identifiers.

The spec is specific on this although we have been lax historically: http://jsonapi.org/format/#document-resource-object-linkage

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(the tests passed either way, just thought it was good to continue working us little by little closer to full spec)

}
}
};
Expand All @@ -791,7 +878,7 @@ test('after unloading a record, the record can be fetched again immediately (pur
cars: {
data: [
{
id: 1,
id: '1',
type: 'car'
}
]
Expand All @@ -801,7 +888,7 @@ test('after unloading a record, the record can be fetched again immediately (pur
included: [
{
type: 'car',
id: 1,
id: '1',
attributes: {
make: 'jeep',
model: 'wrangler'
Expand All @@ -822,8 +909,8 @@ test('after unloading a record, the record can be fetched again immediately (pur
assert.equal(internalModel.currentState.stateName, 'root.empty', 'Expected the previous internal model tobe unloaded');

return store.findRecord('person', '1').then(record => {
assert.equal(record.get('cars.length'), 0);
assert.equal(internalModel.currentState.stateName, 'root.empty', 'Expected the previous internal model to STILL be unloaded');
assert.equal(record.get('cars.length'), 0, 'Expected relationship to be cleared by the new push');
assert.ok(internalModel === record._internalModel, 'the old internalModel is reused');
assert.equal(record._internalModel.currentState.stateName, 'root.loaded.saved', 'Expected the NEW internal model to be loaded');
});
});
Expand Down Expand Up @@ -870,6 +957,7 @@ test('after unloading a record, the record can be fetched again immediately (wit
});

const internalModel = record._internalModel;
const bike = store.peekRecord('bike', '1');
assert.equal(internalModel.currentState.stateName, 'root.loaded.saved', 'We are loaded initially');

assert.equal(record.get('bike.name'), 'mr bike');
Expand All @@ -880,10 +968,12 @@ test('after unloading a record, the record can be fetched again immediately (wit
assert.equal(record.isDestroying, true, 'the record is destroying');
assert.equal(record.isDestroyed, false, 'the record is NOT YET destroyed');
assert.equal(internalModel.currentState.stateName, 'root.empty', 'We are unloaded after unloadRecord');

let wait = store.findRecord('person', '1').then(newRecord => {
assert.equal(record.isDestroyed, false, 'the record is NOT YET destroyed');
assert.ok(newRecord.get('bike') === null, 'the newRecord should NOT have had a bike');
assert.ok(newRecord.get('bike') === bike, 'the newRecord should retain knowledge of the bike');
});

assert.equal(record.isDestroyed, false, 'the record is NOT YET destroyed');
return wait;
});
Expand Down