From 9f82b544f72e2f12c26908cfbd4406dffe5d19fe Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 5 Oct 2026 19:19:33 -0400 Subject: [PATCH] Back a tracked field with one TrackedValue per instance The `@tracked` decorator kept each field in `trackedData`: a WeakMap of values, plus the tag registry (a WeakMap, then a Map per object) for the tag. A read or a write went through three maps. The decorator now keeps one WeakMap from the instance to a TrackedValue, which holds the value and the tag. This is the same class that the standalone `tracked(value)` form returns. - The TrackedValue takes its tag from the tag registry, so `tagFor`, `dirtyTagFor`, observers, computed chains and `notifyPropertyChange` work on the tag of the field. - A field notifies on every write, so its `equals` is a function that returns false, unless the decorator got an `equals` option. - The field setter runs the "already consumed" assertion itself, because only the decorator knows the object and the key for the message. - The TrackedValue is made at the first read or write. A first write does not run the initializer, as before. `trackedData` has no caller in the repo after this. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../@ember/-internals/metal/lib/tracked.ts | 65 +++++++++++++++---- .../metal/tests/tracked/validation_test.js | 51 +++++++++++++++ .../@glimmer/validator/lib/tracked-value.ts | 8 ++- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/packages/@ember/-internals/metal/lib/tracked.ts b/packages/@ember/-internals/metal/lib/tracked.ts index 67b09e2aa60..cc586e960ed 100644 --- a/packages/@ember/-internals/metal/lib/tracked.ts +++ b/packages/@ember/-internals/metal/lib/tracked.ts @@ -2,10 +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 { 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'; +import { trackedValue, TrackedValue } from '@glimmer/validator/lib/tracked-value'; import type { ElementDescriptor } from '..'; import { CHAIN_PASS_THROUGH } from './chain-tags'; import type { ExtendedMethodDecorator, DecoratorPropertyDescriptor } from './decorator'; @@ -324,6 +325,12 @@ if (DEBUG) { setClassicDecorator(tracked); } +// A tracked field notifies on every write, also when the value is the same: +// `this.items = this.items` is how code reports a change inside the value. +function neverEqual(): boolean { + return false; +} + function descriptorForField( [target, key, desc]: ElementDescriptor, options?: { equals?: (a: any, b: any) => boolean; description?: string } @@ -333,11 +340,33 @@ function descriptorForField( !desc || (!desc.value && !desc.get && !desc.set) ); - let { getter, setter } = trackedData(key, desc ? desc.initializer : undefined); + let initializer = desc ? desc.initializer : undefined; + let hasInitializer = typeof initializer === 'function'; let equals = options?.equals; + // Each instance gets one TrackedValue for the field, at its first read or + // write. All of them share these options. + let cells = new WeakMap>(); + let cellOptions = { equals: equals ?? neverEqual, description: options?.description }; + + function createCell(self: object, value: unknown): TrackedValue { + // Other code can ask the registry for the tag of the field before the + // first read or write, so the value takes its tag from there. + let cell = new TrackedValue(value, cellOptions, tagFor(self, key) as UpdatableTag); + + cells.set(self, cell); + + return cell; + } + function get(this: object): unknown { - let value = getter(this); + let cell = cells.get(this); + + if (cell === undefined) { + cell = createCell(this, hasInitializer ? initializer!.call(this) : undefined); + } + + let value = cell.value; // Add the tag of the returned value if it is an array, since arrays // should always cause updates if they are consumed and then changed @@ -349,17 +378,29 @@ function descriptorForField( } function set(this: object, newValue: unknown): void { - if ( - equals !== undefined && - equals( - untrack(() => getter(this)), - newValue - ) - ) { + let cell = cells.get(this); + + if (cell === undefined) { + // A first write does not run the initializer, unless `equals` needs the + // initial value to compare with. + cell = createCell( + this, + equals !== undefined && hasInitializer ? untrack(() => initializer!.call(this)) : undefined + ); + } + + if (DEBUG) { + // TrackedValue has the same assertion, but it cannot name the object + // and the key. + debug.assertTagNotConsumed?.(tagFor(this, key), this, key); + } + + if (equals === undefined) { + cell.value = newValue; + } else if (!cell.set(newValue)) { return; } - setter(this, newValue); dirtyTagFor(this, SELF_TAG); } diff --git a/packages/@ember/-internals/metal/tests/tracked/validation_test.js b/packages/@ember/-internals/metal/tests/tracked/validation_test.js index e2eef6ed310..1bfad1886a3 100644 --- a/packages/@ember/-internals/metal/tests/tracked/validation_test.js +++ b/packages/@ember/-internals/metal/tests/tracked/validation_test.js @@ -43,6 +43,57 @@ moduleFor( assert.equal(validateTag(tag, snapshot), true); } + [`@test a write dirties the tag that tagForProperty gave before the first read or write`]( + assert + ) { + class Tracked { + @tracked first = 'Tom'; + } + + let obj = new Tracked(); + + let tag = tagForProperty(obj, 'first'); + let snapshot = valueForTag(tag); + + obj.first = 'Thomas'; + + assert.equal(validateTag(tag, snapshot), false); + } + + [`@test notifyPropertyChange dirties the tag that a read of a tracked field consumed`](assert) { + class Tracked { + @tracked first = 'Tom'; + } + + let obj = new Tracked(); + + let tag = track(() => obj.first); + let snapshot = valueForTag(tag); + + notifyPropertyChange(obj, 'first'); + + assert.equal(validateTag(tag, snapshot), false); + } + + [`@test a first write does not run the initializer of a tracked field`](assert) { + let runs = 0; + + class Tracked { + @tracked first = ++runs; + } + + let obj = new Tracked(); + + // Stable decorators run the initializer in the constructor. + let expected = import.meta.env.VITE_STABLE_DECORATORS ? 1 : 0; + + obj.first = 10; + + assert.equal(runs, expected); + assert.equal(obj.first, 10); + assert.equal(runs, expected); + } + [`@test autotracking should work with initializers (${import.meta.env.VITE_STABLE_DECORATORS ? 'stable' : 'legacy'} decorators)`]( assert ) { diff --git a/packages/@glimmer/validator/lib/tracked-value.ts b/packages/@glimmer/validator/lib/tracked-value.ts index a59a1000665..ebfbcfe99f1 100644 --- a/packages/@glimmer/validator/lib/tracked-value.ts +++ b/packages/@glimmer/validator/lib/tracked-value.ts @@ -42,7 +42,11 @@ export class TrackedValue implements Reactive { #update: ((updater: (value: Value) => Value) => void) | undefined; #freeze: (() => void) | undefined; - constructor(value: Value, options: ReactiveOptions) { + /** + * The `@tracked` decorator passes the tag that the tag registry has for the field. + * `tagFor` and `dirtyTagFor` then work on the tag of the value. + */ + constructor(value: Value, options: ReactiveOptions, tag?: UpdatableTag) { /** * If we let V8 try to completely optimize TrackedValue for * for each type of `value`, @@ -58,7 +62,7 @@ export class TrackedValue implements Reactive { this.#value = 0 as Value; this.#value = value; this.#options = options; - this.#tag = createUpdatableTag(); + this.#tag = tag === undefined ? createUpdatableTag() : tag; } /**