diff --git a/packages/@ember/-internals/metal/lib/tracked.ts b/packages/@ember/-internals/metal/lib/tracked.ts index 67b09e2aa60..07def0d8cd1 100644 --- a/packages/@ember/-internals/metal/lib/tracked.ts +++ b/packages/@ember/-internals/metal/lib/tracked.ts @@ -2,7 +2,11 @@ import { meta as metaFor } from '@ember/-internals/meta/lib/meta'; import { isEmberArray } from '@ember/array/-internals'; import { assert } from '@ember/debug'; import { DEBUG } from '@glimmer/env'; +import type { UpdatableTag } from '@glimmer/interfaces'; +import { debug } from '@glimmer/validator/lib/debug'; import { consumeTag, untrack } from '@glimmer/validator/lib/tracking'; +import { unwrap } from '@glimmer/validator/lib/utils'; +import { DIRTY_TAG } from '@glimmer/validator/lib/validators'; import { dirtyTagFor, tagFor } from '@glimmer/validator/lib/meta'; import { trackedData } from '@glimmer/validator/lib/tracked-data'; import { trackedValue, type TrackedValue } from '@glimmer/validator/lib/tracked-value'; @@ -396,6 +400,35 @@ export class TrackedDescriptor { } } +/** + * The value of one tracked accessor of one instance, + * with the tag that reports a change of it. + * + * The accessor keeps the cell in its own storage, + * so a read or a write needs no map lookup. + * + * Observers, computed chains and `notifyPropertyChange` + * find the tag of a key in the tag registry. + * So the cell uses that tag and makes none of its own. + */ +class AccessorCell { + value: unknown; + tag: UpdatableTag; + + constructor(value: unknown, tag: UpdatableTag) { + /** + * All cells have one hidden class, and V8 records which kind of value + * the `value` field held so far. The first value of another kind makes + * V8 throw away the optimized code that reads the field. + * + * A number and then the value make the field general from the first cell. + */ + this.value = 0; + this.value = value; + this.tag = tag; + } +} + function tracked2023( args: Parameters, options?: { equals?: (a: any, b: any) => boolean; description?: string } @@ -417,28 +450,63 @@ function tracked2023( return; case 'accessor': { let equals = options?.equals; + let key = dec.context.name; + let storage = dec.value; + return { get(this: object) { - consumeTag(tagFor(this, dec.context.name)); - let value = dec.value.get.call(this); + let stored = storage.get.call(this); + let cell: AccessorCell; + + if (stored instanceof AccessorCell) { + cell = stored; + } else { + /** + * The storage holds the plain value until the first read. + * An instance whose accessor is never read + * has no cell and no tag. + */ + cell = new AccessorCell(stored, tagFor(this, key) as UpdatableTag); + storage.set.call(this, cell); + } + + consumeTag(cell.tag); + + let value = cell.value; + if (Array.isArray(value) || isEmberArray(value)) { consumeTag(tagFor(value, '[]')); } return value; }, set(this: object, value: unknown) { - if ( - equals !== undefined && - equals( - untrack(() => dec.value.get.call(this)), - value - ) - ) { - return; + let stored = storage.get.call(this); + + if (stored instanceof AccessorCell) { + if (equals !== undefined && equals(stored.value, value)) { + return; + } + + if (DEBUG) { + unwrap(debug.assertTagNotConsumed)(stored.tag, this, key); + } + + DIRTY_TAG(stored.tag, true); + dirtyTagFor(this, SELF_TAG); + stored.value = value; + } else { + if (equals !== undefined && equals(stored, value)) { + return; + } + + /** + * No read made a cell yet. The registry can still have a tag, + * from other code that asked for it by key. + */ + dirtyTagFor(this, key); + dirtyTagFor(this, SELF_TAG); + storage.set.call(this, value); } - dirtyTagFor(this, dec.context.name); - dirtyTagFor(this, SELF_TAG); - return dec.value.set.call(this, value); }, }; } diff --git a/packages/@ember/-internals/metal/tests/tracked/accessor_test.js b/packages/@ember/-internals/metal/tests/tracked/accessor_test.js new file mode 100644 index 00000000000..07b94054924 --- /dev/null +++ b/packages/@ember/-internals/metal/tests/tracked/accessor_test.js @@ -0,0 +1,200 @@ +import { AbstractTestCase, moduleFor } from 'internal-test-helpers'; +import { + computed, + defineProperty, + get, + notifyPropertyChange, + tagForProperty, + tracked, +} from '../..'; + +import { track, valueForTag, validateTag } from '@glimmer/validator'; + +/* + A class with a tracked accessor `count`, with the decorator applied by hand. + + The legacy decorator build of the tests cannot compile the `accessor` + keyword, so this does what a stage 3 transform emits. +*/ +function classWithTrackedAccessor(decorator, initial) { + let decorated; + + class Example { + #count = decorated.init ? decorated.init.call(this, initial) : initial; + + get count() { + return decorated.get.call(this); + } + + set count(value) { + decorated.set.call(this, value); + } + + static storage = { + get() { + return this.#count; + }, + set(value) { + this.#count = value; + }, + }; + + toString() { + return 'Example'; + } + } + + decorated = decorator(Example.storage, { + kind: 'accessor', + name: 'count', + static: false, + private: false, + addInitializer() {}, + metadata: {}, + }); + + return Example; +} + +moduleFor( + '@tracked accessor', + class extends AbstractTestCase { + ['@test a read gives the initial value and a write invalidates'](assert) { + let Example = classWithTrackedAccessor(tracked, 0); + let obj = new Example(); + + let tag = track(() => obj.count); + let snapshot = valueForTag(tag); + + assert.strictEqual(obj.count, 0); + assert.true(validateTag(tag, snapshot)); + + obj.count = 1; + + assert.false(validateTag(tag, snapshot)); + assert.strictEqual(obj.count, 1); + } + + ['@test a write before the first read keeps the value'](assert) { + let Example = classWithTrackedAccessor(tracked, 0); + let obj = new Example(); + + obj.count = 5; + + assert.strictEqual(obj.count, 5); + } + + ['@test instances do not share a value or a tag'](assert) { + let Example = classWithTrackedAccessor(tracked, 0); + let first = new Example(); + let second = new Example(); + + let tag = track(() => second.count); + let snapshot = valueForTag(tag); + + first.count = 1; + + assert.true(validateTag(tag, snapshot)); + assert.strictEqual(second.count, 0); + } + + ['@test a write dirties the tag that tagForProperty gave before the first read or write']( + assert + ) { + let Example = classWithTrackedAccessor(tracked, 0); + let obj = new Example(); + + let tag = tagForProperty(obj, 'count'); + let snapshot = valueForTag(tag); + + obj.count = 1; + + assert.false(validateTag(tag, snapshot)); + } + + ['@test notifyPropertyChange dirties the tag that a read consumed'](assert) { + let Example = classWithTrackedAccessor(tracked, 0); + let obj = new Example(); + + let tag = track(() => obj.count); + let snapshot = valueForTag(tag); + + notifyPropertyChange(obj, 'count'); + + assert.false(validateTag(tag, snapshot)); + } + + ['@test a computed property with the accessor as a dependent key updates'](assert) { + let Example = classWithTrackedAccessor(tracked, 1); + + defineProperty( + Example.prototype, + 'double', + computed('count', function () { + return this.count * 2; + }) + ); + + let obj = new Example(); + + assert.strictEqual(get(obj, 'double'), 2); + + obj.count = 2; + + assert.strictEqual(get(obj, 'double'), 4); + } + + ['@test self-assignment invalidates without an equals option'](assert) { + let Example = classWithTrackedAccessor(tracked, 0); + let obj = new Example(); + + let tag = track(() => obj.count); + let snapshot = valueForTag(tag); + + obj.count = 0; + + assert.false(validateTag(tag, snapshot)); + } + + ['@test the equals option skips a write of an equal value'](assert) { + let Example = classWithTrackedAccessor(tracked({ equals: (a, b) => a === b }), 0); + let obj = new Example(); + + let tag = track(() => obj.count); + let snapshot = valueForTag(tag); + + obj.count = 0; + + assert.true(validateTag(tag, snapshot)); + + obj.count = 1; + + assert.false(validateTag(tag, snapshot)); + assert.strictEqual(obj.count, 1); + } + + ['@test a read of an array value consumes the tag of the array'](assert) { + let Example = classWithTrackedAccessor(tracked, []); + let obj = new Example(); + + let tag = track(() => obj.count); + let snapshot = valueForTag(tag); + + notifyPropertyChange(obj.count, '[]'); + + assert.false(validateTag(tag, snapshot)); + } + + ['@test a write after a read in the same computation names the object and the key']() { + let Example = classWithTrackedAccessor(tracked, 0); + let obj = new Example(); + + expectAssertion(() => { + track(() => { + obj.count; + obj.count = 1; + }); + }, /You attempted to update `count` on `Example`, but it had already been used previously in the same computation/); + } + } +);