diff --git a/.gitignore b/.gitignore index 16047f85901..2cb55c937fc 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ npm-debug.log* testem.log yarn-error.log .broccoli-cache +tsconfig.tsbuildinfo # Ignore artifacts of publishing *.tgz diff --git a/packages/model/addon/-private/many-array.ts b/packages/model/addon/-private/many-array.ts index c44ff94e6ad..0d3b5f48e0b 100644 --- a/packages/model/addon/-private/many-array.ts +++ b/packages/model/addon/-private/many-array.ts @@ -5,7 +5,14 @@ import { assert, deprecate } from '@ember/debug'; import { DEPRECATE_PROMISE_PROXIES } from '@ember-data/private-build-infra/deprecations'; import type Store from '@ember-data/store'; -import { IDENTIFIER_ARRAY_TAG, MUTATE, RecordArray, recordIdentifierFor, SOURCE } from '@ember-data/store/-private'; +import { + IDENTIFIER_ARRAY_TAG, + MUTATE, + notifyArray, + RecordArray, + recordIdentifierFor, + SOURCE, +} from '@ember-data/store/-private'; import type ShimModelClass from '@ember-data/store/-private/legacy-model-support/shim-model-class'; import { IdentifierArrayCreateOptions } from '@ember-data/store/-private/record-arrays/identifier-array'; import type { CreateRecordProperties } from '@ember-data/store/-private/store-service'; @@ -271,8 +278,9 @@ export default class RelatedCollection extends RecordArray { notify() { const tag = this[IDENTIFIER_ARRAY_TAG]; - tag.ref = null; tag.shouldReset = true; + // @ts-expect-error + notifyArray(this); } /** diff --git a/packages/model/addon/-private/record-state.ts b/packages/model/addon/-private/record-state.ts index aea5389add3..71eab95d4ec 100644 --- a/packages/model/addon/-private/record-state.ts +++ b/packages/model/addon/-private/record-state.ts @@ -42,6 +42,9 @@ class Tag { this.rev = 1; this.isDirty = true; this.value = undefined; + /* + * whether this was part of a transaction when mutated + */ this.t = false; } @tracked ref = null; diff --git a/packages/private-build-infra/addon/current-deprecations.ts b/packages/private-build-infra/addon/current-deprecations.ts index b30a36df0af..4932b05d419 100644 --- a/packages/private-build-infra/addon/current-deprecations.ts +++ b/packages/private-build-infra/addon/current-deprecations.ts @@ -59,6 +59,6 @@ export default { DEPRECATE_A_USAGE: '4.7', DEPRECATE_PROMISE_PROXIES: '4.7', DEPRECATE_ARRAY_LIKE: '4.7', - DEPRECATE_COMPUTED_CHAINS: '4.7', + DEPRECATE_COMPUTED_CHAINS: '5.0', DEPRECATE_NON_EXPLICIT_POLYMORPHISM: '4.7', }; diff --git a/packages/store/addon/-private/index.ts b/packages/store/addon/-private/index.ts index 1c730096eac..00e4a5022de 100644 --- a/packages/store/addon/-private/index.ts +++ b/packages/store/addon/-private/index.ts @@ -46,6 +46,7 @@ export { default as RecordArray, default as IdentifierArray, Collection as AdapterPopulatedRecordArray, + notifyArray, SOURCE, MUTATE, IDENTIFIER_ARRAY_TAG, diff --git a/packages/store/addon/-private/managers/record-array-manager.ts b/packages/store/addon/-private/managers/record-array-manager.ts index b1405088cb8..5c8f796416c 100644 --- a/packages/store/addon/-private/managers/record-array-manager.ts +++ b/packages/store/addon/-private/managers/record-array-manager.ts @@ -1,7 +1,7 @@ /** @module @ember-data/store */ -import { addToTransaction } from '@ember-data/tracking/-private'; +import { addTransactionCB } from '@ember-data/tracking/-private'; import type { CollectionResourceDocument } from '@ember-data/types/q/ember-data-json-api'; import type { StableRecordIdentifier } from '@ember-data/types/q/identifier'; import type { Dict } from '@ember-data/types/q/utils'; @@ -10,6 +10,8 @@ import IdentifierArray, { Collection, CollectionCreateOptions, IDENTIFIER_ARRAY_TAG, + NOTIFY, + notifyArray, SOURCE, } from '../record-arrays/identifier-array'; import type Store from '../store-service'; @@ -175,9 +177,9 @@ class RecordArrayManager { let tag = array[IDENTIFIER_ARRAY_TAG]; if (!tag.shouldReset) { tag.shouldReset = true; - addToTransaction(tag); - } else if (delta > 0 && tag.t) { - addToTransaction(tag); + addTransactionCB(array[NOTIFY]); + } else if (delta > 0 && !tag.t) { + addTransactionCB(array[NOTIFY]); } } @@ -244,7 +246,8 @@ class RecordArrayManager { const old = source.slice(); source.length = 0; fastPush(source, identifiers); - array[IDENTIFIER_ARRAY_TAG].ref = null; + + notifyArray(array); array.meta = payload.meta || null; array.links = payload.links || null; array.isLoaded = true; diff --git a/packages/store/addon/-private/record-arrays/identifier-array.ts b/packages/store/addon/-private/record-arrays/identifier-array.ts index d8c422c0dbe..70d2b6a6a4c 100644 --- a/packages/store/addon/-private/record-arrays/identifier-array.ts +++ b/packages/store/addon/-private/record-arrays/identifier-array.ts @@ -1,17 +1,22 @@ /** @module @ember-data/store */ +// @ts-expect-error +import { tagForProperty } from '@ember/-internals/metal'; import { assert, deprecate } from '@ember/debug'; import { get, set } from '@ember/object'; import { dependentKeyCompat } from '@ember/object/compat'; import { compare } from '@ember/utils'; import { DEBUG } from '@glimmer/env'; import { tracked } from '@glimmer/tracking'; +// @ts-expect-error +import { dirtyTag } from '@glimmer/validator'; import Ember from 'ember'; import { DEPRECATE_A_USAGE, DEPRECATE_ARRAY_LIKE, + DEPRECATE_COMPUTED_CHAINS, DEPRECATE_PROMISE_PROXIES, DEPRECATE_SNAPSHOT_MODEL_CLASS_ACCESS, } from '@ember-data/private-build-infra/deprecations'; @@ -63,6 +68,18 @@ function isArraySetter(prop: KeyType): boolean { export const IDENTIFIER_ARRAY_TAG = Symbol('#tag'); export const SOURCE = Symbol('#source'); export const MUTATE = Symbol('#update'); +export const NOTIFY = Symbol('#notify'); + +export function notifyArray(arr: IdentifierArray) { + arr[IDENTIFIER_ARRAY_TAG].ref = null; + + if (DEPRECATE_COMPUTED_CHAINS) { + // eslint-disable-next-line + dirtyTag(tagForProperty(arr, 'length')); + // eslint-disable-next-line + dirtyTag(tagForProperty(arr, '[]')); + } +} function convertToInt(prop: KeyType): number | null { if (typeof prop === 'symbol') return null; @@ -76,8 +93,16 @@ function convertToInt(prop: KeyType): number | null { class Tag { @tracked ref = null; - shouldReset: boolean = false; - t = false; + declare shouldReset: boolean; + /* + * whether this was part of a transaction when last mutated + */ + declare t: boolean; + + constructor() { + this.shouldReset = false; + this.t = false; + } } type ProxiedMethod = (...args: unknown[]) => unknown; @@ -127,11 +152,9 @@ interface PrivateState { @class RecordArray @public */ - -interface IdentifierArray { +interface IdentifierArray extends Omit, '[]'> { [MUTATE]?(prop: string, args: unknown[], result?: unknown): void; } -interface IdentifierArray extends Array {} class IdentifierArray { declare DEPRECATED_CLASS_NAME: string; /** @@ -155,6 +178,9 @@ class IdentifierArray { [IDENTIFIER_ARRAY_TAG] = new Tag(); [SOURCE]: StableRecordIdentifier[]; + [NOTIFY]() { + notifyArray(this); + } declare links: Links | PaginationLinks | null; declare meta: Dict | null; @@ -183,7 +209,7 @@ class IdentifierArray { // changing the reference breaks the Proxy // this[SOURCE] = []; this[SOURCE].length = 0; - this[IDENTIFIER_ARRAY_TAG].ref = null; + this[NOTIFY](); this.isDestroyed = true; } @@ -196,6 +222,14 @@ class IdentifierArray { this[SOURCE].length = value; } + // here to support computed chains + // and {{#each}} + get '[]'() { + if (DEPRECATE_COMPUTED_CHAINS) { + return this; + } + } + constructor(options: IdentifierArrayCreateOptions) { // eslint-disable-next-line @typescript-eslint/no-this-alias let self = this; @@ -296,6 +330,10 @@ class IdentifierArray { } } + if (prop === NOTIFY || prop === IDENTIFIER_ARRAY_TAG || prop === SOURCE) { + return self[prop]; + } + let fn = boundFns.get(prop); if (fn) return fn; @@ -403,6 +441,8 @@ class IdentifierArray { }; } + this[NOTIFY] = this[NOTIFY].bind(proxy); + return proxy; } @@ -544,7 +584,7 @@ export class Collection extends IdentifierArray { Collection.prototype.query = null; // Ensure instanceof works correctly -// Object.setPrototypeOf(IdentifierArray.prototype, Array.prototype); +//Object.setPrototypeOf(IdentifierArray.prototype, Array.prototype); if (DEPRECATE_ARRAY_LIKE) { IdentifierArray.prototype.DEPRECATED_CLASS_NAME = 'RecordArray'; diff --git a/packages/tracking/src/-private.ts b/packages/tracking/src/-private.ts index ea23e5bac70..812b43c3c97 100644 --- a/packages/tracking/src/-private.ts +++ b/packages/tracking/src/-private.ts @@ -50,6 +50,7 @@ function flushTransaction() { cb(); }); transaction.props.forEach((obj: Tag) => { + // mark this mutation as part of a transaction obj.t = true; obj.ref = null; }); @@ -67,6 +68,7 @@ async function untrack() { cb(); }); transaction.props.forEach((obj: Tag) => { + // mark this mutation as part of a transaction obj.t = true; obj.ref = null; }); diff --git a/tests/main/tests/acceptance/record-array-test.js b/tests/main/tests/acceptance/record-array-test.js new file mode 100644 index 00000000000..6f5c83602aa --- /dev/null +++ b/tests/main/tests/acceptance/record-array-test.js @@ -0,0 +1,230 @@ +import { computed } from '@ember/object'; +import { findAll, render, rerender } from '@ember/test-helpers'; + +import hbs from 'htmlbars-inline-precompile'; +import { module, test } from 'qunit'; + +import { setupRenderingTest } from 'ember-qunit'; + +import Model, { attr } from '@ember-data/model'; +import { DEPRECATE_COMPUTED_CHAINS } from '@ember-data/private-build-infra/deprecations'; + +class Person extends Model { + @attr name; +} + +module('IdentifierArray | Classic Chains', function (hooks) { + setupRenderingTest(hooks); + + hooks.beforeEach(function () { + this.owner.register('model:person', Person); + }); + + test('recomputed with {{#each}}', async function (assert) { + const store = this.owner.lookup('service:store'); + + // populate initial date + store.push({ + data: [ + { type: 'person', id: '1', attributes: { name: 'Chris' } }, + { type: 'person', id: '2', attributes: { name: 'James' } }, + { type: 'person', id: '3', attributes: { name: 'Thomas' } }, + ], + }); + + class Presenter { + records = store.peekAll('person'); + } + const presenter = new Presenter(); + this.set('presenter', presenter); + + await render(hbs` +
    + {{#each this.presenter.records as |record|}} +
  • {{record.name}}
  • + {{/each}} +
+ `); + + let rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 3, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas'], 'We rendered the names'); + + store.createRecord('person', { name: 'Austen' }); + + await rerender(); + + rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 4, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas', 'Austen'], 'We rendered the names'); + }); + + if (DEPRECATE_COMPUTED_CHAINS) { + test('recomputed with computed.@each', async function (assert) { + const store = this.owner.lookup('service:store'); + + // populate initial date + store.push({ + data: [ + { type: 'person', id: '1', attributes: { name: 'Chris' } }, + { type: 'person', id: '2', attributes: { name: 'James' } }, + { type: 'person', id: '3', attributes: { name: 'Thomas' } }, + ], + }); + + class Presenter { + records = store.peekAll('person'); + + @computed('records.@each.name') + get names() { + return this.records.map((r) => r.name); + } + } + const presenter = new Presenter(); + let { names } = presenter; + + assert.strictEqual(names.length, 3, 'correct names length'); + assert.deepEqual(names, ['Chris', 'James', 'Thomas'], 'correct names in array'); + + this.set('presenter', presenter); + + await render(hbs` +
    + {{#each this.presenter.names as |name|}} +
  • {{name}}
  • + {{/each}} +
+ `); + + let rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 3, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas'], 'We rendered the names'); + + store.createRecord('person', { name: 'Austen' }); + + names = presenter.names; + assert.strictEqual(names.length, 4, 'correct names length'); + assert.deepEqual(names, ['Chris', 'James', 'Thomas', 'Austen'], 'correct names in array'); + + await rerender(); + + rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 4, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas', 'Austen'], 'We rendered the names'); + }); + + test('recomputed with computed.[]', async function (assert) { + const store = this.owner.lookup('service:store'); + + // populate initial date + store.push({ + data: [ + { type: 'person', id: '1', attributes: { name: 'Chris' } }, + { type: 'person', id: '2', attributes: { name: 'James' } }, + { type: 'person', id: '3', attributes: { name: 'Thomas' } }, + ], + }); + + class Presenter { + records = store.peekAll('person'); + + @computed('records.[]') + get names() { + return this.records.map((r) => r.name); + } + } + const presenter = new Presenter(); + let { names } = presenter; + + assert.strictEqual(names.length, 3, 'correct names length'); + assert.deepEqual(names, ['Chris', 'James', 'Thomas'], 'correct names in array'); + + this.set('presenter', presenter); + + await render(hbs` +
    + {{#each this.presenter.names as |name|}} +
  • {{name}}
  • + {{/each}} +
+ `); + + let rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 3, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas'], 'We rendered the names'); + + store.createRecord('person', { name: 'Austen' }); + + names = presenter.names; + assert.strictEqual(names.length, 4, 'correct names length'); + assert.deepEqual(names, ['Chris', 'James', 'Thomas', 'Austen'], 'correct names in array'); + + await rerender(); + + rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 4, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas', 'Austen'], 'We rendered the names'); + }); + + test('recomputed with computed.length', async function (assert) { + const store = this.owner.lookup('service:store'); + + // populate initial date + store.push({ + data: [ + { type: 'person', id: '1', attributes: { name: 'Chris' } }, + { type: 'person', id: '2', attributes: { name: 'James' } }, + { type: 'person', id: '3', attributes: { name: 'Thomas' } }, + ], + }); + + class Presenter { + records = store.peekAll('person'); + + @computed('records.length') + get names() { + return this.records.map((r) => r.name); + } + } + const presenter = new Presenter(); + let { names } = presenter; + + assert.strictEqual(names.length, 3, 'correct names length'); + assert.deepEqual(names, ['Chris', 'James', 'Thomas'], 'correct names in array'); + + this.set('presenter', presenter); + + await render(hbs` +
    + {{#each this.presenter.names as |name|}} +
  • {{name}}
  • + {{/each}} +
+ `); + + let rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 3, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas'], 'We rendered the names'); + + store.createRecord('person', { name: 'Austen' }); + + names = presenter.names; + assert.strictEqual(names.length, 4, 'correct names length'); + assert.deepEqual(names, ['Chris', 'James', 'Thomas', 'Austen'], 'correct names in array'); + + await rerender(); + + rendered = findAll('li').map((e) => e.textContent); + + assert.strictEqual(rendered.length, 4, 'we rendered the correct number of names'); + assert.deepEqual(rendered, ['Chris', 'James', 'Thomas', 'Austen'], 'We rendered the names'); + }); + } +});