From 8bb3a5b481f9a4288d433f3b2df12dfa29123b8f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:18:52 -0400 Subject: [PATCH 1/3] Make the functions of a TrackedValue on their first use Each TrackedValue made four arrow functions and one options object. `get`, `set`, `update` and `freeze` are now accessors that make the bound function on its first read, and keep it. Each one has a setter, so code can still assign to them. The default options are one frozen object. Split out of #21650. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../@glimmer/validator/lib/tracked-value.ts | 99 ++++++++++++++----- .../validator/test/tracked-value-test.ts | 37 +++++++ 2 files changed, 113 insertions(+), 23 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracked-value.ts b/packages/@glimmer/validator/lib/tracked-value.ts index b08c964c6c7..87e8718cdb8 100644 --- a/packages/@glimmer/validator/lib/tracked-value.ts +++ b/packages/@glimmer/validator/lib/tracked-value.ts @@ -28,6 +28,20 @@ export class TrackedValue implements Reactive { readonly #options: ReactiveOptions; readonly #tag: UpdatableTag; + /** + * `get`, `set`, `update` and `freeze` are bound to the instance, + * so that they can be detached from it. + * + * Each one is made the first time that it is read, + * so an instance that only uses `value` makes no function. + * + * Each one can be assigned, for example by a test that replaces it. + */ + #get: (() => Value) | undefined; + #set: ((value: Value) => boolean) | undefined; + #update: ((updater: (value: Value) => Value) => void) | undefined; + #freeze: (() => void) | undefined; + constructor(value: Value, options: ReactiveOptions) { this.#value = value; this.#options = options; @@ -48,15 +62,25 @@ export class TrackedValue implements Reactive { } set value(value: Value) { - this.set(value); + let set = this.#set; + + if (set === undefined) { + this.#write(value); + } else { + set(value); + } } /** * Function short-hand for reading `value`. */ - get = (): Value => { - return this.value; - }; + get get(): () => Value { + return (this.#get ??= () => this.value); + } + + set get(fn: () => Value) { + this.#get = fn; + } /** * Function short-hand for assigning `value`. @@ -64,7 +88,42 @@ export class TrackedValue implements Reactive { * Returns `true` if the value changed (and consumers were notified), * `false` if the new value was equal to the current one. */ - set = (value: Value): boolean => { + get set(): (value: Value) => boolean { + return (this.#set ??= (value) => this.#write(value)); + } + + set set(fn: (value: Value) => boolean) { + this.#set = fn; + } + + /** + * Update the value based on the current value, without consuming it. + */ + get update(): (updater: (value: Value) => Value) => void { + return (this.#update ??= (updater) => { + this.#write(updater(this.#value)); + }); + } + + set update(fn: (updater: (value: Value) => Value) => void) { + this.#update = fn; + } + + /** + * Prevents further updates, making the TrackedValue behave as a + * ReadOnlyReactive. + */ + get freeze(): () => void { + return (this.#freeze ??= () => { + this.#isFrozen = true; + }); + } + + set freeze(fn: () => void) { + this.#freeze = fn; + } + + #write(value: Value): boolean { if (this.#isFrozen) { throw new Error( `Cannot update a frozen TrackedValue${ @@ -82,30 +141,24 @@ export class TrackedValue implements Reactive { DIRTY_TAG(this.#tag); return true; - }; - - /** - * Update the value based on the current value, without consuming it. - */ - update = (updater: (value: Value) => Value): void => { - this.set(updater(this.#value)); - }; - - /** - * Prevents further updates, making the TrackedValue behave as a - * ReadOnlyReactive. - */ - freeze = (): void => { - this.#isFrozen = true; - }; + } } +const DEFAULT_OPTIONS: ReactiveOptions = Object.freeze({ + equals: Object.is, + description: undefined, +}); + export function trackedValue( value: Value, options?: { equals?: (a: Value, b: Value) => boolean; description?: string } ): TrackedValue { + if (options === undefined) { + return new TrackedValue(value, DEFAULT_OPTIONS); + } + return new TrackedValue(value, { - equals: options?.equals ?? Object.is, - description: options?.description, + equals: options.equals ?? Object.is, + description: options.description, }); } diff --git a/packages/@glimmer/validator/test/tracked-value-test.ts b/packages/@glimmer/validator/test/tracked-value-test.ts index abcd6c6b63e..833f6b59cfa 100644 --- a/packages/@glimmer/validator/test/tracked-value-test.ts +++ b/packages/@glimmer/validator/test/tracked-value-test.ts @@ -101,6 +101,43 @@ module('@glimmer/validator: trackedValue()', () => { assert.true(validateTag(tag, snapshot), 'update() did not entangle with the value'); }); + test('each method is the same function on each read', (assert) => { + const x = trackedValue(0); + + assert.strictEqual(x.get, x.get); + assert.strictEqual(x.set, x.set); + assert.strictEqual(x.update, x.update); + assert.strictEqual(x.freeze, x.freeze); + }); + + test('each method can be replaced', (assert) => { + const x = trackedValue(0); + const calls: number[] = []; + + x.set = (value) => { + calls.push(value); + + return false; + }; + + assert.false(x.set(1)); + + x.value = 2; + + assert.deepEqual(calls, [1, 2], 'a write through `value` uses the replacement'); + assert.strictEqual(x.value, 0, 'the replacement did not write the value'); + }); + + test('a detached freeze() prevents updates', (assert) => { + const x = trackedValue(0); + const { freeze, set } = x; + + freeze(); + + assert.throws(() => set(1), /frozen/u); + assert.throws(() => (x.value = 1), /frozen/u); + }); + test('methods can be detached from the instance', (assert) => { const x = trackedValue(0); const { get, set, update } = x; From 615a2214ca9c3240eb1ea57670fce261b0adb0ce Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:41:16 -0400 Subject: [PATCH 2/3] Remove setter methods from TrackedValue class Removed setter methods for get, set, update, and freeze in TrackedValue. --- packages/@glimmer/validator/lib/tracked-value.ts | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracked-value.ts b/packages/@glimmer/validator/lib/tracked-value.ts index 87e8718cdb8..3e2a619d0b8 100644 --- a/packages/@glimmer/validator/lib/tracked-value.ts +++ b/packages/@glimmer/validator/lib/tracked-value.ts @@ -78,10 +78,6 @@ export class TrackedValue implements Reactive { return (this.#get ??= () => this.value); } - set get(fn: () => Value) { - this.#get = fn; - } - /** * Function short-hand for assigning `value`. * @@ -92,10 +88,6 @@ export class TrackedValue implements Reactive { return (this.#set ??= (value) => this.#write(value)); } - set set(fn: (value: Value) => boolean) { - this.#set = fn; - } - /** * Update the value based on the current value, without consuming it. */ @@ -105,10 +97,6 @@ export class TrackedValue implements Reactive { }); } - set update(fn: (updater: (value: Value) => Value) => void) { - this.#update = fn; - } - /** * Prevents further updates, making the TrackedValue behave as a * ReadOnlyReactive. @@ -119,10 +107,6 @@ export class TrackedValue implements Reactive { }); } - set freeze(fn: () => void) { - this.#freeze = fn; - } - #write(value: Value): boolean { if (this.#isFrozen) { throw new Error( From 82933a4ac45880ddf44ca0259975eb8a4a91b97f Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:55:02 -0400 Subject: [PATCH 3/3] This test is not something we want to support --- .../validator/test/tracked-value-test.ts | 18 ------------------ 1 file changed, 18 deletions(-) diff --git a/packages/@glimmer/validator/test/tracked-value-test.ts b/packages/@glimmer/validator/test/tracked-value-test.ts index 833f6b59cfa..b7962546ab7 100644 --- a/packages/@glimmer/validator/test/tracked-value-test.ts +++ b/packages/@glimmer/validator/test/tracked-value-test.ts @@ -110,24 +110,6 @@ module('@glimmer/validator: trackedValue()', () => { assert.strictEqual(x.freeze, x.freeze); }); - test('each method can be replaced', (assert) => { - const x = trackedValue(0); - const calls: number[] = []; - - x.set = (value) => { - calls.push(value); - - return false; - }; - - assert.false(x.set(1)); - - x.value = 2; - - assert.deepEqual(calls, [1, 2], 'a write through `value` uses the replacement'); - assert.strictEqual(x.value, 0, 'the replacement did not write the value'); - }); - test('a detached freeze() prevents updates', (assert) => { const x = trackedValue(0); const { freeze, set } = x;