diff --git a/packages/-ember-data/tests/acceptance/relationships/has-many-test.js b/packages/-ember-data/tests/acceptance/relationships/has-many-test.js index e6b2da17bb3..5f63ba264af 100644 --- a/packages/-ember-data/tests/acceptance/relationships/has-many-test.js +++ b/packages/-ember-data/tests/acceptance/relationships/has-many-test.js @@ -854,6 +854,7 @@ module('autotracking has-many', function (hooks) { names = findAll('li').map((e) => e.textContent); assert.deepEqual(names, ['RGB', 'RGB'], 'rendered 2 children'); + assert.expectDeprecation({ id: 'ember-data:no-a-with-array-like', count: 6 }); } ); diff --git a/packages/-ember-data/tests/integration/record-data/record-data-test.ts b/packages/-ember-data/tests/integration/record-data/record-data-test.ts index 848c394e177..5bf67b1ac23 100644 --- a/packages/-ember-data/tests/integration/record-data/record-data-test.ts +++ b/packages/-ember-data/tests/integration/record-data/record-data-test.ts @@ -532,7 +532,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.expect(6); let { owner } = this; - let belongsToReturnValue = { data: { id: '1', type: 'person' } }; + let belongsToReturnValue; class RelationshipRecordData extends TestRecordData { getBelongsTo(key: string) { @@ -570,6 +570,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( owner.register('service:store', TestStore); store = owner.lookup('service:store'); + belongsToReturnValue = { data: store.identifierCache.getOrCreateRecordIdentifier({ id: '1', type: 'person' }) }; store.push({ data: [davidHash, runspiredHash], @@ -591,7 +592,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.expect(4); let { owner } = this; - let belongsToReturnValue = { data: { id: '1', type: 'person' } }; + let belongsToReturnValue; let RelationshipRecordData; if (V2CACHE_SINGLETON_MANAGER) { @@ -607,7 +608,9 @@ module('integration/record-data - Custom RecordData Implementations', function ( key: string, value: StableRecordIdentifier | null ) { - belongsToReturnValue = { data: { id: '3', type: 'person' } }; + belongsToReturnValue = { + data: store.identifierCache.getOrCreateRecordIdentifier({ id: '3', type: 'person' }), + }; this._storeWrapper.notifyChange(this._identifier, 'relationships', 'landlord'); } }; @@ -619,7 +622,9 @@ module('integration/record-data - Custom RecordData Implementations', function ( } setDirtyBelongsTo(this: V1TestRecordData, key: string, recordData: this | null) { - belongsToReturnValue = { data: { id: '3', type: 'person' } }; + belongsToReturnValue = { + data: store.identifierCache.getOrCreateRecordIdentifier({ id: '3', type: 'person' }), + }; this._storeWrapper.notifyChange(this._identifier, 'relationships', 'landlord'); } }; @@ -638,6 +643,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( owner.register('service:store', TestStore); store = owner.lookup('service:store'); + belongsToReturnValue = { data: store.identifierCache.getOrCreateRecordIdentifier({ id: '1', type: 'person' }) }; store.push({ data: [davidHash, runspiredHash, igorHash], @@ -662,8 +668,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( let { owner } = this; - let hasManyReturnValue = { data: [{ id: '1', type: 'person' }] }; - + let hasManyReturnValue; class RelationshipRecordData extends TestRecordData { getHasMany(key: string) { return hasManyReturnValue; @@ -716,6 +721,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( owner.register('service:store', TestStore); store = owner.lookup('service:store'); + hasManyReturnValue = { data: [store.identifierCache.getOrCreateRecordIdentifier({ id: '1', type: 'person' })] }; store.push({ data: [davidHash, runspiredHash, igorHash], @@ -747,8 +753,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.expect(10); let { owner } = this; - let hasManyReturnValue = { data: [{ id: '1', type: 'person' }] }; - + let hasManyReturnValue; class RelationshipRecordData extends TestRecordData { getHasMany(key: string) { return hasManyReturnValue; @@ -770,8 +775,8 @@ module('integration/record-data - Custom RecordData Implementations', function ( hasManyReturnValue = { data: [ - { id: '3', type: 'person' }, - { id: '2', type: 'person' }, + store.identifierCache.getOrCreateRecordIdentifier({ id: '3', type: 'person' }), + store.identifierCache.getOrCreateRecordIdentifier({ id: '2', type: 'person' }), ], }; this._storeWrapper.notifyChange(this._identifier, 'relationships', 'tenants'); @@ -787,7 +792,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.strictEqual(key, 'tenants', 'Passed correct key to removeFromHasMany'); assert.strictEqual(recordDatas[0].getResourceIdentifier().id, '2', 'Passed correct RD to removeFromHasMany'); } - hasManyReturnValue = { data: [{ id: '1', type: 'person' }] }; + hasManyReturnValue = { data: [store.identifierCache.getOrCreateRecordIdentifier({ id: '1', type: 'person' })] }; this._storeWrapper.notifyChange(this._identifier, 'relationships', 'tenants'); } @@ -796,8 +801,8 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.strictEqual(recordDatas[0].getResourceIdentifier().id, '3', 'Passed correct RD to addToHasMany'); hasManyReturnValue = { data: [ - { id: '1', type: 'person' }, - { id: '2', type: 'person' }, + store.identifierCache.getOrCreateRecordIdentifier({ id: '1', type: 'person' }), + store.identifierCache.getOrCreateRecordIdentifier({ id: '2', type: 'person' }), ], }; this._storeWrapper.notifyChange(this._identifier, 'relationships', 'tenants'); @@ -813,8 +818,8 @@ module('integration/record-data - Custom RecordData Implementations', function ( assert.strictEqual(values[0].id, '3', 'Passed correct RD to addToHasMany'); hasManyReturnValue = { data: [ - { id: '1', type: 'person' }, - { id: '2', type: 'person' }, + store.identifierCache.getOrCreateRecordIdentifier({ id: '1', type: 'person' }), + store.identifierCache.getOrCreateRecordIdentifier({ id: '2', type: 'person' }), ], }; this._storeWrapper.notifyChange(this._identifier, 'relationships', 'tenants'); @@ -834,6 +839,7 @@ module('integration/record-data - Custom RecordData Implementations', function ( owner.register('service:store', TestStore); store = owner.lookup('service:store'); + hasManyReturnValue = { data: [store.identifierCache.getOrCreateRecordIdentifier({ id: '1', type: 'person' })] }; store.push({ data: [davidHash, runspiredHash, igorHash], diff --git a/packages/-ember-data/tests/integration/records/create-record-test.js b/packages/-ember-data/tests/integration/records/create-record-test.js index cd98b404a19..0bcbad611ce 100644 --- a/packages/-ember-data/tests/integration/records/create-record-test.js +++ b/packages/-ember-data/tests/integration/records/create-record-test.js @@ -1,3 +1,5 @@ +import { settled } from '@ember/test-helpers'; + import { module, test } from 'qunit'; import { resolve } from 'rsvp'; @@ -85,6 +87,7 @@ module('Store.createRecord() coverage', function (hooks) { assert.deepEqual(pets, ['Shen'], 'Precondition: Chris has Shen as a pet'); pet.unloadRecord(); + await settled(); assert.strictEqual(pet.owner, null, 'Shen no longer has an owner'); // check that the relationship has been dissolved pets = chris.pets.toArray().map((pet) => pet.name); diff --git a/packages/-ember-data/tests/integration/relationships/one-to-many-test.js b/packages/-ember-data/tests/integration/relationships/one-to-many-test.js index 8b97b90287f..1e1a8a55d70 100644 --- a/packages/-ember-data/tests/integration/relationships/one-to-many-test.js +++ b/packages/-ember-data/tests/integration/relationships/one-to-many-test.js @@ -1614,7 +1614,7 @@ module('integration/relationships/one_to_many_test - OneToMany relationships', f deprecatedTest( 'createRecord updates inverse record array which has observers', - { id: 'ember-data:deprecate-promise-many-array-behaviors', until: '5.0', count: 5 }, + { id: 'ember-data:deprecate-promise-many-array-behaviors', until: '5.0', count: 4 }, async function (assert) { let store = this.owner.lookup('service:store'); let adapter = store.adapterFor('application'); diff --git a/packages/-ember-data/tests/integration/relationships/promise-many-array-test.js b/packages/-ember-data/tests/integration/relationships/promise-many-array-test.js index 4c81810f393..0f996ae3daa 100644 --- a/packages/-ember-data/tests/integration/relationships/promise-many-array-test.js +++ b/packages/-ember-data/tests/integration/relationships/promise-many-array-test.js @@ -3,7 +3,7 @@ import EmberObject, { computed } from '@ember/object'; import { filterBy } from '@ember/object/computed'; import { settled } from '@ember/test-helpers'; -import { module, test } from 'qunit'; +import { module } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; @@ -14,39 +14,43 @@ import { deprecatedTest } from '@ember-data/unpublished-test-infra/test-support/ module('PromiseManyArray', (hooks) => { setupRenderingTest(hooks); - test('PromiseManyArray is not side-affected by EmberArray', async function (assert) { - const { owner } = this; - class Person extends Model { - @attr('string') name; - } - class Group extends Model { - @hasMany('person', { async: true, inverse: null }) members; - } - owner.register('model:person', Person); - owner.register('model:group', Group); - const store = owner.lookup('service:store'); - const members = ['Bob', 'John', 'Michael', 'Larry', 'Lucy'].map((name) => store.createRecord('person', { name })); - const group = store.createRecord('group', { members }); - - const forEachFn = group.members.forEach; - assert.strictEqual(group.members.length, 5, 'initial length is correct'); - - if (DEPRECATE_PROMISE_MANY_ARRAY_BEHAVIORS) { - group.members.replace(0, 1); - assert.strictEqual(group.members.length, 4, 'updated length is correct'); - } + deprecatedTest( + 'PromiseManyArray is not side-affected by EmberArray', + { id: 'ember-data:no-a-with-array-like', until: '5.0', count: 1 }, + async function (assert) { + const { owner } = this; + class Person extends Model { + @attr('string') name; + } + class Group extends Model { + @hasMany('person', { async: true, inverse: null }) members; + } + owner.register('model:person', Person); + owner.register('model:group', Group); + const store = owner.lookup('service:store'); + const members = ['Bob', 'John', 'Michael', 'Larry', 'Lucy'].map((name) => store.createRecord('person', { name })); + const group = store.createRecord('group', { members }); - A(group.members); + const forEachFn = group.members.forEach; + assert.strictEqual(group.members.length, 5, 'initial length is correct'); - assert.strictEqual(forEachFn, group.members.forEach, 'we have the same function for forEach'); + if (DEPRECATE_PROMISE_MANY_ARRAY_BEHAVIORS) { + group.members.replace(0, 1); + assert.strictEqual(group.members.length, 4, 'updated length is correct'); + } + + A(group.members); + + assert.strictEqual(forEachFn, group.members.forEach, 'we have the same function for forEach'); - if (DEPRECATE_PROMISE_MANY_ARRAY_BEHAVIORS) { - group.members.replace(0, 1); - assert.strictEqual(group.members.length, 3, 'updated length is correct'); - // we'll want to use a different test for this but will want to still ensure we are not side-affected - assert.expectDeprecation({ id: 'ember-data:deprecate-promise-many-array-behaviors', until: '5.0', count: 2 }); + if (DEPRECATE_PROMISE_MANY_ARRAY_BEHAVIORS) { + group.members.replace(0, 1); + assert.strictEqual(group.members.length, 3, 'updated length is correct'); + // we'll want to use a different test for this but will want to still ensure we are not side-affected + assert.expectDeprecation({ id: 'ember-data:deprecate-promise-many-array-behaviors', until: '5.0', count: 2 }); + } } - }); + ); deprecatedTest( 'PromiseManyArray can be subscribed to by computed chains', @@ -130,6 +134,7 @@ module('PromiseManyArray', (hooks) => { assert.strictEqual(memberIds.length, 6, 'memberIds length is correct'); assert.strictEqual(johnRecords.length, 2, 'johnRecords length is correct'); assert.strictEqual(group.members.length, 6, 'members length is correct'); + assert.expectDeprecation({ id: 'ember-data:no-a-with-array-like', count: 2 }); } ); }); diff --git a/packages/-ember-data/tests/unit/many-array-test.js b/packages/-ember-data/tests/unit/many-array-test.js index 265d8469f77..a2c324210b1 100644 --- a/packages/-ember-data/tests/unit/many-array-test.js +++ b/packages/-ember-data/tests/unit/many-array-test.js @@ -3,11 +3,9 @@ import { run } from '@ember/runloop'; import { module, test } from 'qunit'; import { resolve } from 'rsvp'; -import { gte } from 'ember-compatibility-helpers'; import { setupTest } from 'ember-qunit'; import Model, { attr, belongsTo, hasMany } from '@ember-data/model'; -import { ManyArray } from '@ember-data/model/-private'; module('unit/many_array - ManyArray', function (hooks) { setupTest(hooks); @@ -76,118 +74,4 @@ module('unit/many_array - ManyArray', function (hooks) { }); }); }); - - if (!gte('4.0.0')) { - test('manyArray trigger arrayContentChange functions with the correct values', function (assert) { - assert.expect(6); - class Post extends Model { - @attr('string') title; - @hasMany('tag', { async: false, inverse: 'post' }) tags; - } - - class Tag extends Model { - @attr('string') name; - @belongsTo('post', { async: false, inverse: 'tags' }) post; - } - - this.owner.register('model:post', Post); - this.owner.register('model:tag', Tag); - const store = this.owner.lookup('service:store'); - - const TestManyArray = ManyArray.proto(); - - let willChangeStartIdx; - let willChangeRemoveAmt; - let willChangeAddAmt; - - let originalArrayContentWillChange = TestManyArray.arrayContentWillChange; - let originalArrayContentDidChange = TestManyArray.arrayContentDidChange; - let originalInit = TestManyArray.init; - - // override ManyArray temp (cleanup occures in afterTest); - - TestManyArray.init = function (...args) { - // We aren't actually adding any observers in this test - // just testing the observer codepaths, so we use this to - // force the ManyArray instance to take the observer paths. - this.__hasArrayObservers = true; - originalInit.call(this, ...args); - }; - - TestManyArray.arrayContentWillChange = function (startIdx, removeAmt, addAmt) { - willChangeStartIdx = startIdx; - willChangeRemoveAmt = removeAmt; - willChangeAddAmt = addAmt; - - return originalArrayContentWillChange.apply(this, arguments); - }; - - TestManyArray.arrayContentDidChange = function (startIdx, removeAmt, addAmt) { - assert.strictEqual(startIdx, willChangeStartIdx, 'WillChange and DidChange startIdx should match'); - assert.strictEqual(removeAmt, willChangeRemoveAmt, 'WillChange and DidChange removeAmt should match'); - assert.strictEqual(addAmt, willChangeAddAmt, 'WillChange and DidChange addAmt should match'); - - return originalArrayContentDidChange.apply(this, arguments); - }; - - try { - run(() => { - store.push({ - data: [ - { - type: 'tag', - id: '1', - attributes: { - name: 'Ember.js', - }, - }, - { - type: 'tag', - id: '2', - attributes: { - name: 'Tomster', - }, - }, - { - type: 'post', - id: '3', - attributes: { - title: 'A framework for creating ambitious web applications', - }, - relationships: { - tags: { - data: [{ type: 'tag', id: '1' }], - }, - }, - }, - ], - }); - - store.peekRecord('post', 3).tags; - - store.push({ - data: { - type: 'post', - id: '3', - attributes: { - title: 'A framework for creating ambitious web applications', - }, - relationships: { - tags: { - data: [ - { type: 'tag', id: '1' }, - { type: 'tag', id: '2' }, - ], - }, - }, - }, - }); - }); - } finally { - TestManyArray.arrayContentWillChange = originalArrayContentWillChange; - TestManyArray.arrayContentDidChange = originalArrayContentDidChange; - TestManyArray.init = originalInit; - } - }); - } }); diff --git a/packages/model/addon/-private/legacy-relationships-support.ts b/packages/model/addon/-private/legacy-relationships-support.ts index 080bb37f939..9205f3bcaf1 100644 --- a/packages/model/addon/-private/legacy-relationships-support.ts +++ b/packages/model/addon/-private/legacy-relationships-support.ts @@ -10,14 +10,10 @@ import type { ImplicitRelationship } from '@ember-data/record-data/-private/grap import type BelongsToRelationship from '@ember-data/record-data/-private/relationships/state/belongs-to'; import type ManyRelationship from '@ember-data/record-data/-private/relationships/state/has-many'; import type Store from '@ember-data/store'; -import { recordIdentifierFor, storeFor } from '@ember-data/store/-private'; -import { IdentifierCache } from '@ember-data/store/-private/caches/identifier-cache'; +import { isStableIdentifier, recordIdentifierFor, storeFor } from '@ember-data/store/-private'; +import { NonSingletonRecordDataManager } from '@ember-data/store/-private/managers/record-data-manager'; import type { DSModel } from '@ember-data/types/q/ds-model'; -import { - CollectionResourceRelationship, - ResourceIdentifierObject, - SingleResourceRelationship, -} from '@ember-data/types/q/ember-data-json-api'; +import { CollectionResourceRelationship, SingleResourceRelationship } from '@ember-data/types/q/ember-data-json-api'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; import type { RecordData } from '@ember-data/types/q/record-data'; import type { JsonApiRelationship } from '@ember-data/types/q/record-data-json-api'; @@ -104,8 +100,8 @@ export class LegacySupport { getBelongsTo(key: string, options?: FindOptions): PromiseBelongsTo | RecordInstance | null { const { identifier, recordData } = this; let resource = recordData.getRelationship(this.identifier, key) as SingleResourceRelationship; - let relatedIdentifier = - resource && resource.data ? this.store.identifierCache.getOrCreateRecordIdentifier(resource.data) : null; + let relatedIdentifier = resource && resource.data ? resource.data : null; + assert(`Expected a stable identifier`, !relatedIdentifier || isStableIdentifier(relatedIdentifier)); const store = this.store; const graphFor = ( @@ -154,6 +150,30 @@ export class LegacySupport { return this.recordData.setBelongsTo(this.identifier, key, extractIdentifierFromRecord(value)); } + _getCurrentState( + identifier: StableRecordIdentifier, + field: string + ): [StableRecordIdentifier[], CollectionResourceRelationship] { + let jsonApi = (this.recordData as NonSingletonRecordDataManager).getRelationship( + identifier, + field, + true + ) as CollectionResourceRelationship; + const cache = this.store._instanceCache; + let identifiers: StableRecordIdentifier[] = []; + if (jsonApi.data) { + for (let i = 0; i < jsonApi.data.length; i++) { + const identifier = jsonApi.data[i]; + assert(`Expected a stable identifier`, isStableIdentifier(identifier)); + if (cache.recordIsLoaded(identifier, true)) { + identifiers.push(identifier); + } + } + } + + return [identifiers, jsonApi]; + } + getManyArray(key: string, definition?: UpgradedMeta): ManyArray { assert('hasMany only works with the @ember-data/record-data package', HAS_RECORD_DATA_PACKAGE); let manyArray: ManyArray | undefined = this._manyArrayCache[key]; @@ -165,12 +185,16 @@ export class LegacySupport { } if (!manyArray) { + const [identifiers, doc] = this._getCurrentState(this.identifier, key); manyArray = (ManyArray as unknown as ManyArrayFactory).create({ store: this.store, type: this.store.modelFor(definition.type), identifier: this.identifier, recordData: this.recordData, key, + currentState: identifiers, + meta: doc.meta || null, + links: doc.links || null, isPolymorphic: definition.isPolymorphic, isAsync: definition.isAsync, _inverseIsAsync: definition.inverseIsAsync, @@ -196,8 +220,14 @@ export class LegacySupport { } const jsonApi = this.recordData.getRelationship(this.identifier, key) as CollectionResourceRelationship; + const promise = this._findHasManyByJsonApiResource(jsonApi, this.identifier, relationship, options); + + if (!promise) { + manyArray.isLoaded = true; + return resolve(manyArray); + } - loadingPromise = this._findHasManyByJsonApiResource(jsonApi, this.identifier, relationship, options).then( + loadingPromise = promise.then( () => handleCompletedRelationshipRequest(this, key, relationship, manyArray), (e: Error) => handleCompletedRelationshipRequest(this, key, relationship, manyArray, e) ); @@ -358,10 +388,10 @@ export class LegacySupport { parentIdentifier: StableRecordIdentifier, relationship: ManyRelationship, options: FindOptions = {} - ): Promise { + ): Promise | void { if (HAS_RECORD_DATA_PACKAGE) { if (!resource) { - return resolve(); + return; } const { definition, state } = relationship; const adapter = this.store.adapterFor(definition.type); @@ -411,11 +441,16 @@ export class LegacySupport { // fetch using data, pulling from local cache if possible if (!shouldForceReload && !isStale && (preferLocalCache || hasLocalPartialData)) { + if (allInverseRecordsAreLoaded) { + return; + } assert(`Expected collection to be an array`, Array.isArray(resource.data)); let finds = new Array(resource.data.length); + let cache = this.store._instanceCache; for (let i = 0; i < resource.data.length; i++) { - let identifier = this.store.identifierCache.getOrCreateRecordIdentifier(resource.data[i]); - finds[i] = this.store._instanceCache._fetchDataIfNeededForIdentifier(identifier, options); + const identifier = resource.data[i]; + assert(`expected a stable identifier`, isStableIdentifier(identifier)); + finds[i] = cache._fetchDataIfNeededForIdentifier(identifier, options); } return all(finds); @@ -425,8 +460,9 @@ export class LegacySupport { // fetch by data if (hasData || hasLocalPartialData) { - assert(`Expected collection to be an array`, Array.isArray(resource.data)); - let identifiers = resource.data.map((json) => this.store.identifierCache.getOrCreateRecordIdentifier(json)); + const identifiers = resource.data; + assert(`Expected collection to be an array`, Array.isArray(identifiers)); + assert(`Expected stable identifiers`, identifiers.every(isStableIdentifier)); let fetches = new Array(identifiers.length); const manager = this.store._fetchManager; @@ -441,7 +477,7 @@ export class LegacySupport { // we were explicitly told we have no data and no links. // TODO if the relationshipIsStale, should we hit the adapter anyway? - return resolve(); + return; } assert(`hasMany only works with the @ember-data/record-data package`); } @@ -456,7 +492,8 @@ export class LegacySupport { return resolve(null); } - const identifier = resource.data ? this.store.identifierCache.getOrCreateRecordIdentifier(resource.data) : null; + const identifier = resource.data ? resource.data : null; + assert(`Expected a stable identifier`, !identifier || isStableIdentifier(identifier)); let { isStale, hasDematerializedInverse, hasReceivedData, isEmpty, shouldForceReload } = relationship.state; @@ -681,30 +718,21 @@ function anyUnloaded(store: Store, relationship: ManyRelationship) { } function areAllInverseRecordsLoaded(store: Store, resource: JsonApiRelationship): boolean { - const cache = store.identifierCache; + const instanceCache = store._instanceCache; + const identifiers = resource.data; - if (Array.isArray(resource.data)) { + if (Array.isArray(identifiers)) { + assert(`Expected stable identifiers`, identifiers.every(isStableIdentifier)); // treat as collection // check for unloaded records - let hasEmptyRecords = resource.data.reduce((hasEmptyModel, resourceIdentifier) => { - return hasEmptyModel || isEmpty(store, cache, resourceIdentifier); - }, false); - - return !hasEmptyRecords; - } else { - // treat as single resource - if (!resource.data) { - return true; - } else { - return !isEmpty(store, cache, resource.data); - } + return identifiers.every((identifier: StableRecordIdentifier) => instanceCache.recordIsLoaded(identifier)); } -} -function isEmpty(store: Store, cache: IdentifierCache, resource: ResourceIdentifierObject): boolean { - const identifier = cache.getOrCreateRecordIdentifier(resource); - const recordData = store._instanceCache.__instances.recordData.get(identifier); - return !recordData || recordData.isEmpty(identifier); + // treat as single resource + if (!identifiers) return true; + + assert(`Expected stable identifiers`, isStableIdentifier(identifiers)); + return instanceCache.recordIsLoaded(identifiers); } function isBelongsTo( diff --git a/packages/model/addon/-private/many-array.ts b/packages/model/addon/-private/many-array.ts index 31e215f722b..0ff2271db99 100644 --- a/packages/model/addon/-private/many-array.ts +++ b/packages/model/addon/-private/many-array.ts @@ -9,7 +9,7 @@ import EmberObject, { get } from '@ember/object'; import { all } from 'rsvp'; import type Store from '@ember-data/store'; -import { PromiseArray, recordIdentifierFor } from '@ember-data/store/-private'; +import { isStableIdentifier, PromiseArray, recordIdentifierFor } from '@ember-data/store/-private'; import type ShimModelClass from '@ember-data/store/-private/legacy-model-support/shim-model-class'; import type { NonSingletonRecordDataManager } from '@ember-data/store/-private/managers/record-data-manager'; import type { CreateRecordProperties } from '@ember-data/store/-private/store-service'; @@ -35,6 +35,9 @@ export interface ManyArrayCreateArgs { type: ShimModelClass; identifier: StableRecordIdentifier; recordData: RecordData; + currentState: StableRecordIdentifier[]; + meta: Dict | null; + links: Links | PaginationLinks | null; key: string; isPolymorphic: boolean; isAsync: boolean; @@ -119,8 +122,6 @@ export default class ManyArray extends MutableArrayWithObject { + if (meta.kind === 'belongsTo') { + this.notifyPropertyChange(key); + } + }); + LEGACY_SUPPORT.get(this)?.destroy(); + LEGACY_SUPPORT.delete(this); + LEGACY_SUPPORT.delete(identifier); super.destroy(); } diff --git a/packages/model/addon/-private/promise-many-array.ts b/packages/model/addon/-private/promise-many-array.ts index ee55d1266cc..d24f120da91 100644 --- a/packages/model/addon/-private/promise-many-array.ts +++ b/packages/model/addon/-private/promise-many-array.ts @@ -2,6 +2,7 @@ import ArrayMixin, { NativeArray } from '@ember/array'; import type ArrayProxy from '@ember/array/proxy'; import { assert, deprecate } from '@ember/debug'; import { dependentKeyCompat } from '@ember/object/compat'; +import { DEBUG } from '@glimmer/env'; import { tracked } from '@glimmer/tracking'; import Ember from 'ember'; @@ -9,7 +10,10 @@ import { resolve } from 'rsvp'; import type { ManyArray } from 'ember-data/-private'; -import { DEPRECATE_PROMISE_MANY_ARRAY_BEHAVIORS } from '@ember-data/private-build-infra/deprecations'; +import { + DEPRECATE_A_USAGE, + DEPRECATE_PROMISE_MANY_ARRAY_BEHAVIORS, +} from '@ember-data/private-build-infra/deprecations'; import { StableRecordIdentifier } from '@ember-data/types/q/identifier'; import type { RecordInstance } from '@ember-data/types/q/record-instance'; import { FindOptions } from '@ember-data/types/q/store'; @@ -53,13 +57,26 @@ export default class PromiseManyArray { this.isDestroyed = false; this.isDestroying = false; - const meta = Ember.meta(this); - meta.hasMixin = (mixin: Object) => { - if (mixin === NativeArray || mixin === ArrayMixin) { - return true; - } - return false; - }; + if (DEPRECATE_A_USAGE) { + const meta = Ember.meta(this); + meta.hasMixin = (mixin: Object) => { + deprecate(`Do not use A() on an EmberData PromiseManyArray`, false, { + id: 'ember-data:no-a-with-array-like', + until: '5.0', + since: { enabled: '4.8', available: '4.8' }, + for: 'ember-data', + }); + if (mixin === NativeArray || mixin === ArrayMixin) { + return true; + } + return false; + }; + } else if (DEBUG) { + const meta = Ember.meta(this); + meta.hasMixin = (mixin: Object) => { + assert(`Do not use A() on an EmberData PromiseManyArray`); + }; + } } //---- Methods/Properties on ArrayProxy that we will keep as our API diff --git a/packages/private-build-infra/addon/current-deprecations.ts b/packages/private-build-infra/addon/current-deprecations.ts index 04d8587ef85..ec6673dfd61 100644 --- a/packages/private-build-infra/addon/current-deprecations.ts +++ b/packages/private-build-infra/addon/current-deprecations.ts @@ -56,4 +56,5 @@ export default { DEPRECATE_RELATIONSHIPS_WITHOUT_ASYNC: '4.8', DEPRECATE_RELATIONSHIPS_WITHOUT_INVERSE: '4.8', DEPRECATE_V1_RECORD_DATA: '4.8', + DEPRECATE_A_USAGE: '4.8', }; diff --git a/packages/private-build-infra/addon/deprecations.ts b/packages/private-build-infra/addon/deprecations.ts index ebbee00103d..7e93b5ed2be 100644 --- a/packages/private-build-infra/addon/deprecations.ts +++ b/packages/private-build-infra/addon/deprecations.ts @@ -25,3 +25,4 @@ export const DEPRECATE_RELATIONSHIPS_WITHOUT_TYPE = deprecationState('DEPRECATE_ export const DEPRECATE_RELATIONSHIPS_WITHOUT_ASYNC = deprecationState('DEPRECATE_RELATIONSHIPS_WITHOUT_ASYNC'); export const DEPRECATE_RELATIONSHIPS_WITHOUT_INVERSE = deprecationState('DEPRECATE_RELATIONSHIPS_WITHOUT_INVERSE'); export const DEPRECATE_V1_RECORD_DATA = deprecationState('DEPRECATE_V1_RECORD_DATA'); +export const DEPRECATE_A_USAGE = deprecationState('DEPRECATE_A_USAGE'); diff --git a/packages/record-data/addon/-private/graph/-utils.ts b/packages/record-data/addon/-private/graph/-utils.ts index 9662c012618..db8f04f2e24 100644 --- a/packages/record-data/addon/-private/graph/-utils.ts +++ b/packages/record-data/addon/-private/graph/-utils.ts @@ -153,7 +153,8 @@ export function forAllRelatedIdentifiers( export function removeIdentifierCompletelyFromRelationship( graph: Graph, relationship: ManyRelationship | ImplicitRelationship | BelongsToRelationship, - value: StableRecordIdentifier + value: StableRecordIdentifier, + silenceNotifications?: boolean ): void { if (isBelongsTo(relationship)) { if (relationship.remoteState === value) { @@ -165,7 +166,9 @@ export function removeIdentifierCompletelyFromRelationship( // This allows dematerialized inverses to be rematerialized // we shouldn't be notifying here though, figure out where // a notification was missed elsewhere. - notifyChange(graph, relationship.identifier, relationship.definition.key); + if (!silenceNotifications) { + notifyChange(graph, relationship.identifier, relationship.definition.key); + } } } else if (isHasMany(relationship)) { relationship.remoteMembers.delete(value); @@ -182,8 +185,9 @@ export function removeIdentifierCompletelyFromRelationship( // This allows dematerialized inverses to be rematerialized // we shouldn't be notifying here though, figure out where // a notification was missed elsewhere. - - notifyChange(graph, relationship.identifier, relationship.definition.key); + if (!silenceNotifications) { + notifyChange(graph, relationship.identifier, relationship.definition.key); + } } } else { relationship.remoteMembers.delete(value); @@ -191,6 +195,7 @@ export function removeIdentifierCompletelyFromRelationship( } } +// TODO add silencing at the graph level export function notifyChange(graph: Graph, identifier: StableRecordIdentifier, key: string) { if (identifier === graph._removing) { if (LOG_GRAPH) { diff --git a/packages/record-data/addon/-private/graph/index.ts b/packages/record-data/addon/-private/graph/index.ts index c57088a404f..ce977787e9d 100644 --- a/packages/record-data/addon/-private/graph/index.ts +++ b/packages/record-data/addon/-private/graph/index.ts @@ -232,7 +232,7 @@ export class Graph { return true; } - unload(identifier: StableRecordIdentifier) { + unload(identifier: StableRecordIdentifier, silenceNotifications?: boolean) { if (LOG_GRAPH) { // eslint-disable-next-line no-console console.log(`graph: unload ${String(identifier)}`); @@ -247,7 +247,7 @@ export class Graph { if (!rel) { return; } - destroyRelationship(this, rel); + destroyRelationship(this, rel, silenceNotifications); if (isImplicit(rel)) { relationships[key] = undefined; } @@ -446,7 +446,7 @@ export class Graph { // delete, so we remove the inverse records from this relationship to // disconnect the graph. Because it's not async, we don't need to keep around // the identifier as an id-wrapper for references -function destroyRelationship(graph: Graph, rel: RelationshipEdge) { +function destroyRelationship(graph: Graph, rel: RelationshipEdge, silenceNotifications?: boolean) { if (isImplicit(rel)) { if (graph.isReleasable(rel.identifier)) { removeCompletelyFromInverse(graph, rel); @@ -459,7 +459,7 @@ function destroyRelationship(graph: Graph, rel: RelationshipEdge) { if (!rel.definition.inverseIsImplicit) { forAllRelatedIdentifiers(rel, (inverseIdentifer: StableRecordIdentifier) => - notifyInverseOfDematerialization(graph, inverseIdentifer, inverseKey, identifier) + notifyInverseOfDematerialization(graph, inverseIdentifer, inverseKey, identifier, silenceNotifications) ); } @@ -475,7 +475,7 @@ function destroyRelationship(graph: Graph, rel: RelationshipEdge) { // we should discuss whether we still care about this, probably fine to just // leave the ui relationship populated since the record is destroyed and // internally we've fully cleaned up. - if (!rel.definition.isAsync) { + if (!rel.definition.isAsync && !silenceNotifications) { notifyChange(graph, rel.identifier, rel.definition.key); } } @@ -485,7 +485,8 @@ function notifyInverseOfDematerialization( graph: Graph, inverseIdentifier: StableRecordIdentifier, inverseKey: string, - identifier: StableRecordIdentifier + identifier: StableRecordIdentifier, + silenceNotifications?: boolean ) { if (!graph.has(inverseIdentifier, inverseKey)) { return; @@ -497,7 +498,12 @@ function notifyInverseOfDematerialization( // For remote members, it is possible that inverseRecordData has already been associated to // to another record. For such cases, do not dematerialize the inverseRecordData if (!isBelongsTo(relationship) || !relationship.localState || identifier === relationship.localState) { - removeDematerializedInverse(graph, relationship as BelongsToRelationship | ManyRelationship, identifier); + removeDematerializedInverse( + graph, + relationship as BelongsToRelationship | ManyRelationship, + identifier, + silenceNotifications + ); } } @@ -518,7 +524,8 @@ function clearRelationship(relationship: ManyRelationship | BelongsToRelationshi function removeDematerializedInverse( graph: Graph, relationship: ManyRelationship | BelongsToRelationship, - inverseIdentifier: StableRecordIdentifier + inverseIdentifier: StableRecordIdentifier, + silenceNotifications?: boolean ) { if (isBelongsTo(relationship)) { const inverseIdentifier = relationship.localState; @@ -544,7 +551,9 @@ function removeDematerializedInverse( relationship.state.hasDematerializedInverse = true; } - notifyChange(graph, relationship.identifier, relationship.definition.key); + if (!silenceNotifications) { + notifyChange(graph, relationship.identifier, relationship.definition.key); + } } else { if (!relationship.definition.isAsync || (inverseIdentifier && isNew(inverseIdentifier))) { // unloading inverse of a sync relationship is treated as a client-side @@ -557,7 +566,9 @@ function removeDematerializedInverse( relationship.state.hasDematerializedInverse = true; } - notifyChange(graph, relationship.identifier, relationship.definition.key); + if (!silenceNotifications) { + notifyChange(graph, relationship.identifier, relationship.definition.key); + } } } diff --git a/packages/store/addon/-private/index.ts b/packages/store/addon/-private/index.ts index c4ebc079ba9..c862f232f64 100644 --- a/packages/store/addon/-private/index.ts +++ b/packages/store/addon/-private/index.ts @@ -18,6 +18,7 @@ export { setIdentifierUpdateMethod, setIdentifierForgetMethod, setIdentifierResetMethod, + isStableIdentifier, } from './caches/identifier-cache'; export function normalizeModelName(modelName: string) { diff --git a/packages/store/addon/-private/network/fetch-manager.ts b/packages/store/addon/-private/network/fetch-manager.ts index 0b51ea8d6a4..0713e58aa7f 100644 --- a/packages/store/addon/-private/network/fetch-manager.ts +++ b/packages/store/addon/-private/network/fetch-manager.ts @@ -5,8 +5,10 @@ import { assert, deprecate, warn } from '@ember/debug'; import { _backburner as emberBackburner } from '@ember/runloop'; import { DEBUG } from '@glimmer/env'; +import { importSync } from '@embroider/macros'; import { default as RSVP, resolve } from 'rsvp'; +import { HAS_RECORD_DATA_PACKAGE } from '@ember-data/private-build-infra'; import { DEPRECATE_RSVP_PROMISE } from '@ember-data/private-build-infra/deprecations'; import type { CollectionResourceDocument, SingleResourceDocument } from '@ember-data/types/q/ember-data-json-api'; import type { FindRecordQuery, Request, SaveRecordMutation } from '@ember-data/types/q/fetch-manager'; @@ -214,7 +216,20 @@ export default class FetchManager { (error) => { const recordData = store._instanceCache.peek({ identifier, bucket: 'recordData' }); if (!recordData || recordData.isEmpty(identifier) || isLoading) { - store._instanceCache.unloadRecord(identifier); + let isReleasable = true; + if (!recordData && HAS_RECORD_DATA_PACKAGE) { + const graphFor = ( + importSync('@ember-data/record-data/-private') as typeof import('@ember-data/record-data/-private') + ).graphFor; + const graph = graphFor(store); + isReleasable = graph.isReleasable(identifier); + if (!isReleasable) { + graph.unload(identifier, true); + } + } + if (recordData || isReleasable) { + store._instanceCache.unloadRecord(identifier); + } } throw error; } diff --git a/packages/store/addon/-private/store-service.ts b/packages/store/addon/-private/store-service.ts index 4b12745fae3..38757e064a0 100644 --- a/packages/store/addon/-private/store-service.ts +++ b/packages/store/addon/-private/store-service.ts @@ -1853,25 +1853,27 @@ class Store extends Service { !modelName || typeof modelName === 'string' ); - if (modelName === undefined) { - // destroy the graph before unloadAll - // since then we avoid churning relationships - // during unload - if (HAS_RECORD_DATA_PACKAGE) { - const peekGraph = ( - importSync('@ember-data/record-data/-private') as typeof import('@ember-data/record-data/-private') - ).peekGraph; - let graph = peekGraph(this); - if (graph) { - graph.identifiers.clear(); + this._join(() => { + if (modelName === undefined) { + // destroy the graph before unloadAll + // since then we avoid churning relationships + // during unload + if (HAS_RECORD_DATA_PACKAGE) { + const peekGraph = ( + importSync('@ember-data/record-data/-private') as typeof import('@ember-data/record-data/-private') + ).peekGraph; + let graph = peekGraph(this); + if (graph) { + graph.identifiers.clear(); + } } + this._notificationManager.destroy(); + this._instanceCache.clear(); + } else { + let normalizedModelName = normalizeModelName(modelName); + this._instanceCache.clear(normalizedModelName); } - this._notificationManager.destroy(); - this._instanceCache.clear(); - } else { - let normalizedModelName = normalizeModelName(modelName); - this._instanceCache.clear(normalizedModelName); - } + }); } /**