From 8787a45fd21c09122f4b6d28e5e32a8495a63532 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 8 Oct 2026 15:40:47 -0400 Subject: [PATCH] Keep the value and the tag of a tracked accessor in its storage The accessor form of @tracked asked the tag registry for the tag of the field on each read and each write: a WeakMap lookup and a Map lookup. At the first read, the storage of the accessor now gets a cell with the value and the tag. A read or a write after that does no map lookup. The cell takes its tag from the registry, so observers, computed chains and notifyPropertyChange work on the same tag as before. Until the first read, the storage holds the plain value, and a write goes through the registry as before. So an instance whose accessor is never read has no cell and no tag. Co-Authored-By: Claude Opus 5.5 --- .../@ember/-internals/metal/lib/tracked.ts | 94 ++++++-- .../metal/tests/tracked/accessor_test.js | 200 ++++++++++++++++++ 2 files changed, 281 insertions(+), 13 deletions(-) create mode 100644 packages/@ember/-internals/metal/tests/tracked/accessor_test.js 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/); + } + } +);