From e2e3cfbc957c62e6500594254abbe2d3cafff698 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:05:00 -0400 Subject: [PATCH 1/8] Pool trackers and reuse the combined tag of a tracking frame A tracking frame allocated one tracker, one Set, and one array from that Set. In CPU profiles of three createCache graphs, the tracker, its Set and beginTrackFrame took 27% to 45% of the samples. Frames are strictly nested, so one tracker for each depth is enough. beginTrackFrame takes the tracker of its depth from a pool. A tracker keeps its tags in an array. Each frame has a number, and the tracker writes that number on each tag that it takes, so a tag that the frame consumes again costs one comparison and no Set. endTrackFrame now takes the tag that the same frame produced the last time. If the frame consumed the same tags again, that tag is the result, with its memoized revision and no allocation. getValue passes the tag of its cache. The loop over the subtags of a combined tag uses an index in place of an iterator. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/@glimmer/validator/lib/tracking.ts | 129 +++++++++++++--- packages/@glimmer/validator/lib/validators.ts | 29 +++- .../@glimmer/validator/test/tracking-test.ts | 142 ++++++++++++++++++ 3 files changed, 276 insertions(+), 24 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..2dc83da893a 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -5,37 +5,97 @@ import type { Revision } from './validators'; import { debug } from './debug'; import { unwrap } from './utils'; -import { combine, CONSTANT_TAG, isConstTag, validateTag, valueForTag } from './validators'; +import { + combine, + CONSTANT_TAG, + isCombinationOf, + isConstTag, + validateTag, + valueForTag, +} from './validators'; /** - * An object that that tracks @tracked properties that were consumed. + * A tag, with the number of the last tracking frame that consumed it. + * + * The tag classes of this package declare `lastFrame`. + * A tag from another implementation gets the property on its first consumption. */ -class Tracker { - private tags = new Set(); - private last: Tag | null = null; +interface ConsumedTag extends Tag { + lastFrame?: number; +} - add(tag: Tag) { +/** + * An object that tracks @tracked properties that were consumed. + * + * Trackers are pooled by frame depth (see `beginTrackFrame`), + * so a tracker holds no tag after its frame ends. + */ +class Tracker { + /** + * The consumed tags. `size` counts the live entries. + * + * The array never shrinks, because a write to `length` is a slow path in V8, + * and this code runs for every frame. + */ + private tags: (Tag | null)[] = []; + private size = 0; + + /** + * The number of the frame that uses this tracker. + * + * `add` writes it on each tag that it takes, + * so a tag that the frame consumes again costs one comparison. + * + * A tag that a nested frame consumes between two consumptions of this frame + * is taken two times. A combined tag with a duplicate has the same revision. + */ + frame = 0; + + add(tag: ConsumedTag) { if (tag === CONSTANT_TAG) return; - this.tags.add(tag); - if (DEBUG) { unwrap(debug.markTagAsConsumed)(tag); } - this.last = tag; - } + if (tag.lastFrame === this.frame) return; - combine(): Tag { - let { tags } = this; + tag.lastFrame = this.frame; + this.tags[this.size++] = tag; + } - if (tags.size === 0) { - return CONSTANT_TAG; - } else if (tags.size === 1) { - return this.last as Tag; + /** + * `previous` is the tag that this frame produced the last time it ran. + * If the frame consumed the same tags again, the result is `previous`, + * which keeps its memoized revision and needs no allocation. + */ + combine(previous: Tag | undefined): Tag { + let { tags, size } = this; + let result: Tag; + + if (size === 0) { + result = CONSTANT_TAG; + } else if (size === 1) { + result = tags[0] as Tag; + } else if (previous !== undefined && isCombinationOf(previous, tags, size)) { + result = previous; } else { - return combine(Array.from(this.tags)); + result = combine(tags.slice(0, size) as Tag[]); + } + + this.clear(); + + return result; + } + + clear(): void { + let { tags, size } = this; + + for (let i = 0; i < size; i++) { + tags[i] = null; } + + this.size = 0; } } @@ -56,17 +116,40 @@ let CURRENT_TRACKER: Tracker | null = null; const OPEN_TRACK_FRAMES: (Tracker | null)[] = []; +/** + * Frames are strictly nested, so the tracker of a frame at depth `n` is free + * when that frame ends. One tracker for each depth is enough. + */ +const TRACKER_POOL: Tracker[] = []; + +let FRAME_COUNT = 0; + export function beginTrackFrame(debuggingContext?: string | false): void { + let depth = OPEN_TRACK_FRAMES.length; + OPEN_TRACK_FRAMES.push(CURRENT_TRACKER); - CURRENT_TRACKER = new Tracker(); + let tracker = TRACKER_POOL[depth]; + + if (tracker === undefined) { + tracker = TRACKER_POOL[depth] = new Tracker(); + } + + tracker.frame = ++FRAME_COUNT; + CURRENT_TRACKER = tracker; if (DEBUG) { unwrap(debug.beginTrackingTransaction)(debuggingContext); } } -export function endTrackFrame(): Tag { +/** + * Closes the current frame and returns its combined tag. + * + * Pass the tag that the same frame produced the last time it ran. + * If the frame consumed the same tags again, that tag is the result. + */ +export function endTrackFrame(previous?: Tag): Tag { let current = CURRENT_TRACKER; if (DEBUG) { @@ -79,7 +162,7 @@ export function endTrackFrame(): Tag { CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() || null; - return unwrap(current).combine(); + return unwrap(current).combine(previous); } export function beginUntrackFrame(): void { @@ -101,6 +184,10 @@ export function resetTracking(): string | void { OPEN_TRACK_FRAMES.pop(); } + for (let tracker of TRACKER_POOL) { + tracker.clear(); + } + CURRENT_TRACKER = null; if (DEBUG) { @@ -175,7 +262,7 @@ export function getValue(cache: Cache): T | undefined { try { cache[LAST_VALUE] = fn(); } finally { - tag = endTrackFrame(); + tag = endTrackFrame(tag); cache[TAG] = tag; cache[SNAPSHOT] = valueForTag(tag); consumeTag(tag); diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index 66c7f1ac1cb..dcceebac9d0 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -114,6 +114,11 @@ class MonomorphicTagImpl { public subtag: Tag | Tag[] | null = null; private subtagBufferCache: Revision | null = null; + /** + * The number of the last tracking frame that consumed this tag. + */ + public lastFrame = 0; + declare [TYPE]: T; constructor(type: T) { @@ -138,9 +143,8 @@ class MonomorphicTagImpl { if (subtag !== null) { if (Array.isArray(subtag)) { - for (const tag of subtag) { - let value = tag[COMPUTE](); - revision = Math.max(value, revision); + for (let i = 0; i < subtag.length; i++) { + revision = Math.max((subtag[i] as Tag)[COMPUTE](), revision); } } else { let subtagValue = subtag[COMPUTE](); @@ -254,6 +258,7 @@ const VOLATILE_TAG_ID: IVOLATILE_TAG_ID = 100; export class VolatileTag implements Tag { readonly [TYPE] = VOLATILE_TAG_ID; + lastFrame = 0; [COMPUTE](): Revision { return VOLATILE; } @@ -267,6 +272,7 @@ const CURRENT_TAG_ID: ICURRENT_TAG_ID = 101; export class CurrentTag implements Tag { readonly [TYPE] = CURRENT_TAG_ID; + lastFrame = 0; [COMPUTE](): Revision { return $REVISION; } @@ -278,6 +284,23 @@ export const CURRENT_TAG = new CurrentTag(); export const combine = MonomorphicTagImpl.combine; +/** + * Whether `tag` is the combination of the first `size` entries of `tags`, in order. + */ +export function isCombinationOf(tag: Tag, tags: (Tag | null)[], size: number): boolean { + if (tag[TYPE] !== COMBINATOR_TAG_ID) return false; + + let subtags = (tag as MonomorphicTagImpl).subtag as Tag[]; + + if (subtags.length !== size) return false; + + for (let i = 0; i < size; i++) { + if (subtags[i] !== tags[i]) return false; + } + + return true; +} + // Warm let tag1 = createUpdatableTag(); diff --git a/packages/@glimmer/validator/test/tracking-test.ts b/packages/@glimmer/validator/test/tracking-test.ts index 1a0fc4c1dc0..bf8a85399c3 100644 --- a/packages/@glimmer/validator/test/tracking-test.ts +++ b/packages/@glimmer/validator/test/tracking-test.ts @@ -10,6 +10,7 @@ import { getValue, isConst, isTracking, + resetTracking, track, trackedData, untrack, @@ -252,6 +253,114 @@ module('@glimmer/validator: tracking', () => { assert.notOk(validateTag(combined, snapshot)); }); + test('it returns the tag itself if the frame consumed one tag many times', (assert) => { + let tag = createTag(); + + beginTrackFrame(); + + consumeTag(tag); + consumeTag(tag); + + assert.strictEqual(endTrackFrame(), tag); + }); + + test('it keeps a tag that a nested frame consumed between two consumptions', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + + beginTrackFrame(); + + consumeTag(tag1); + consumeTag(tag2); + + beginTrackFrame(); + consumeTag(tag1); + let inner = endTrackFrame(); + + consumeTag(tag1); + + let outer = endTrackFrame(); + + assert.strictEqual(inner, tag1); + + let snapshot = valueForTag(outer); + dirtyTag(tag1); + assert.notOk(validateTag(outer, snapshot)); + + snapshot = valueForTag(outer); + dirtyTag(tag2); + assert.notOk(validateTag(outer, snapshot)); + }); + + test('it returns the previous tag if the frame consumed the same tags', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + + beginTrackFrame(); + consumeTag(tag1); + consumeTag(tag2); + let first = endTrackFrame(); + + beginTrackFrame(); + consumeTag(tag1); + consumeTag(tag2); + let second = endTrackFrame(first); + + assert.strictEqual(second, first); + }); + + test('it returns a new tag if the frame consumed other tags', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + let tag3 = createTag(); + + beginTrackFrame(); + consumeTag(tag1); + consumeTag(tag2); + let first = endTrackFrame(); + + beginTrackFrame(); + consumeTag(tag1); + consumeTag(tag3); + let second = endTrackFrame(first); + + assert.notStrictEqual(second, first); + + let snapshot = valueForTag(second); + dirtyTag(tag2); + assert.ok(validateTag(second, snapshot)); + + dirtyTag(tag3); + assert.notOk(validateTag(second, snapshot)); + + beginTrackFrame(); + consumeTag(tag1); + consumeTag(tag3); + consumeTag(tag2); + let third = endTrackFrame(second); + + assert.notStrictEqual(third, second); + + snapshot = valueForTag(third); + dirtyTag(tag2); + assert.notOk(validateTag(third, snapshot)); + }); + + test('it does not keep the tags of a frame that did not end', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + + beginTrackFrame(); + consumeTag(tag1); + + resetTracking(); + + beginTrackFrame(); + consumeTag(tag2); + + assert.strictEqual(endTrackFrame(), tag2); + }); + test('isTracking works within a track', (assert) => { assert.notOk(isTracking()); @@ -362,6 +471,39 @@ module('@glimmer/validator: tracking', () => { assert.deepEqual(getValue(outerCache), [3, 2], 'memoized result returned correctly'); }); + test('it tracks the new tags if the tags change between two runs', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + let tag3 = createTag(); + let useThird = false; + let count = 0; + + let cache = createCache(() => { + consumeTag(tag1); + consumeTag(useThird ? tag3 : tag2); + + return ++count; + }); + + assert.strictEqual(getValue(cache), 1); + + dirtyTag(tag2); + assert.strictEqual(getValue(cache), 2, 'the cache ran again with the same tags'); + + dirtyTag(tag2); + assert.strictEqual(getValue(cache), 3, 'the reused tag is still dirtied by its tags'); + + useThird = true; + dirtyTag(tag1); + assert.strictEqual(getValue(cache), 4); + + dirtyTag(tag2); + assert.strictEqual(getValue(cache), 4, 'the cache does not depend on the old tag'); + + dirtyTag(tag3); + assert.strictEqual(getValue(cache), 5, 'the cache depends on the new tag'); + }); + test('isTracking works within a memoized function and untrack frame', (assert) => { assert.notOk(isTracking()); From c59a238d61ef23c629ae64a5c2fb12f0af6fdc90 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 2 Oct 2026 01:05:00 -0400 Subject: [PATCH 2/8] Make the functions of a TrackedValue on their first use A TrackedValue made four arrow functions and one options object for each instance. A value that the code reads and writes through `value` did not use any of them. `get`, `set`, `update` and `freeze` are now accessors that make the bound function on its first read, and keep it. They are bound as before, and each read gives the same function. `trackedValue(value)` with no options shares one options object. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../@glimmer/validator/lib/tracked-value.ts | 75 +++++++++++++------ .../validator/test/tracked-value-test.ts | 19 +++++ 2 files changed, 71 insertions(+), 23 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracked-value.ts b/packages/@glimmer/validator/lib/tracked-value.ts index b08c964c6c7..bc905b4c359 100644 --- a/packages/@glimmer/validator/lib/tracked-value.ts +++ b/packages/@glimmer/validator/lib/tracked-value.ts @@ -28,6 +28,18 @@ 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. + */ + #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 +60,15 @@ export class TrackedValue implements Reactive { } set value(value: Value) { - this.set(value); + this.#write(value); } /** * Function short-hand for reading `value`. */ - get = (): Value => { - return this.value; - }; + get get(): () => Value { + return (this.#get ??= () => this.value); + } /** * Function short-hand for assigning `value`. @@ -64,7 +76,30 @@ 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)); + } + + /** + * 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)); + }); + } + + /** + * Prevents further updates, making the TrackedValue behave as a + * ReadOnlyReactive. + */ + get freeze(): () => void { + return (this.#freeze ??= () => { + this.#isFrozen = true; + }); + } + + #write(value: Value): boolean { if (this.#isFrozen) { throw new Error( `Cannot update a frozen TrackedValue${ @@ -82,30 +117,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 = { + 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..b7962546ab7 100644 --- a/packages/@glimmer/validator/test/tracked-value-test.ts +++ b/packages/@glimmer/validator/test/tracked-value-test.ts @@ -101,6 +101,25 @@ 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('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 c78ece77d3b8c9809178dbf3cf7e20c5b3d99ea8 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:22:33 -0400 Subject: [PATCH 3/8] Find a repeat tag by its index in the tracker The tracker wrote the number of its frame on each tag. That counter leaves the small-integer range of V8 after about one billion frames, and the benchmark is then 1.1 to 1.3 times slower. A nested frame also wrote its own number on a shared tag, so the outer frame took that tag again each time: a computed that reads five computeds of one tag had a combined tag with five entries. A tag now keeps the index at which a tracker took it. A tracker has the tag if its entry at that index is the tag. This needs no counter, and a stale index cannot hide a tag, because the entry is then another tag or no tag. A nested frame that takes the tag at the same index keeps the check true, so the five computeds give one tag again. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/@glimmer/validator/lib/tracking.ts | 35 ++++++------ packages/@glimmer/validator/lib/validators.ts | 8 +-- .../@glimmer/validator/test/tracking-test.ts | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index 2dc83da893a..3c0cfdeeed3 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -15,13 +15,11 @@ import { } from './validators'; /** - * A tag, with the number of the last tracking frame that consumed it. - * - * The tag classes of this package declare `lastFrame`. - * A tag from another implementation gets the property on its first consumption. + * A tag of this package. Each tag class declares `slot`: + * the index of the tag in the tracker that took it last. */ interface ConsumedTag extends Tag { - lastFrame?: number; + slot: number; } /** @@ -41,27 +39,29 @@ class Tracker { private size = 0; /** - * The number of the frame that uses this tracker. - * - * `add` writes it on each tag that it takes, + * A tag keeps the index at which a tracker took it last. + * If this tracker has the tag at that index, the frame consumed the tag before, * so a tag that the frame consumes again costs one comparison. * - * A tag that a nested frame consumes between two consumptions of this frame - * is taken two times. A combined tag with a duplicate has the same revision. + * The index can come from another tracker. + * The entry at that index is then another tag or no tag, and this tracker takes the tag. + * So a tag that a nested frame takes at another index, between two consumptions + * of this frame, is taken two times. A combined tag with a duplicate has the same revision. */ - frame = 0; - - add(tag: ConsumedTag) { + add(tag: Tag) { if (tag === CONSTANT_TAG) return; if (DEBUG) { unwrap(debug.markTagAsConsumed)(tag); } - if (tag.lastFrame === this.frame) return; + let { tags, size } = this; + + if (tags[(tag as ConsumedTag).slot] === tag) return; - tag.lastFrame = this.frame; - this.tags[this.size++] = tag; + (tag as ConsumedTag).slot = size; + tags[size] = tag; + this.size = size + 1; } /** @@ -122,8 +122,6 @@ const OPEN_TRACK_FRAMES: (Tracker | null)[] = []; */ const TRACKER_POOL: Tracker[] = []; -let FRAME_COUNT = 0; - export function beginTrackFrame(debuggingContext?: string | false): void { let depth = OPEN_TRACK_FRAMES.length; @@ -135,7 +133,6 @@ export function beginTrackFrame(debuggingContext?: string | false): void { tracker = TRACKER_POOL[depth] = new Tracker(); } - tracker.frame = ++FRAME_COUNT; CURRENT_TRACKER = tracker; if (DEBUG) { diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index dcceebac9d0..5079368403b 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -115,9 +115,9 @@ class MonomorphicTagImpl { private subtagBufferCache: Revision | null = null; /** - * The number of the last tracking frame that consumed this tag. + * The index of this tag in the tracker that took it last. */ - public lastFrame = 0; + public slot = 0; declare [TYPE]: T; @@ -258,7 +258,7 @@ const VOLATILE_TAG_ID: IVOLATILE_TAG_ID = 100; export class VolatileTag implements Tag { readonly [TYPE] = VOLATILE_TAG_ID; - lastFrame = 0; + slot = 0; [COMPUTE](): Revision { return VOLATILE; } @@ -272,7 +272,7 @@ const CURRENT_TAG_ID: ICURRENT_TAG_ID = 101; export class CurrentTag implements Tag { readonly [TYPE] = CURRENT_TAG_ID; - lastFrame = 0; + slot = 0; [COMPUTE](): Revision { return $REVISION; } diff --git a/packages/@glimmer/validator/test/tracking-test.ts b/packages/@glimmer/validator/test/tracking-test.ts index bf8a85399c3..3ff057039e5 100644 --- a/packages/@glimmer/validator/test/tracking-test.ts +++ b/packages/@glimmer/validator/test/tracking-test.ts @@ -292,6 +292,20 @@ module('@glimmer/validator: tracking', () => { assert.notOk(validateTag(outer, snapshot)); }); + test('it takes a tag one time if nested frames consume it at the same index', (assert) => { + let tag = createTag(); + + beginTrackFrame(); + + for (let i = 0; i < 3; i++) { + beginTrackFrame(); + consumeTag(tag); + consumeTag(endTrackFrame()); + } + + assert.strictEqual(endTrackFrame(), tag); + }); + test('it returns the previous tag if the frame consumed the same tags', (assert) => { let tag1 = createTag(); let tag2 = createTag(); @@ -504,6 +518,46 @@ module('@glimmer/validator: tracking', () => { assert.strictEqual(getValue(cache), 5, 'the cache depends on the new tag'); }); + test('an outer cache runs again if a tag of a reused inner tag changes', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + let tag3 = createTag(); + let innerCount = 0; + let outerCount = 0; + + let inner = createCache(() => { + consumeTag(tag1); + consumeTag(tag2); + + return ++innerCount; + }); + + let outer = createCache(() => { + consumeTag(tag3); + getValue(inner); + + return ++outerCount; + }); + + assert.strictEqual(getValue(outer), 1); + + dirtyTag(tag1); + assert.strictEqual(getValue(outer), 2, 'the inner cache ran again with the same tags'); + assert.strictEqual(innerCount, 2); + + dirtyTag(tag2); + assert.strictEqual(getValue(outer), 3, 'the outer cache follows the reused inner tag'); + assert.strictEqual(innerCount, 3); + + dirtyTag(tag3); + assert.strictEqual(getValue(outer), 4); + assert.strictEqual(innerCount, 3, 'the inner cache did not run again'); + + dirtyTag(tag1); + assert.strictEqual(getValue(outer), 5, 'the outer cache still follows the inner tag'); + assert.strictEqual(innerCount, 4); + }); + test('isTracking works within a memoized function and untrack frame', (assert) => { assert.notOk(isTracking()); From c5544a269937da783460adf8679c98842b81342e Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Fri, 2 Oct 2026 06:22:33 -0400 Subject: [PATCH 4/8] Keep the functions of a TrackedValue assignable `get`, `set`, `update` and `freeze` were own properties before, so code could assign to them. Each accessor now has a setter. A write through `value` uses an assigned `set`, as it did before. The shared default options are frozen. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../@glimmer/validator/lib/tracked-value.ts | 30 +++++++++++++++++-- .../validator/test/tracked-value-test.ts | 18 +++++++++++ 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracked-value.ts b/packages/@glimmer/validator/lib/tracked-value.ts index bc905b4c359..87e8718cdb8 100644 --- a/packages/@glimmer/validator/lib/tracked-value.ts +++ b/packages/@glimmer/validator/lib/tracked-value.ts @@ -34,6 +34,8 @@ export class TrackedValue implements Reactive { * * 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; @@ -60,7 +62,13 @@ export class TrackedValue implements Reactive { } set value(value: Value) { - this.#write(value); + let set = this.#set; + + if (set === undefined) { + this.#write(value); + } else { + set(value); + } } /** @@ -70,6 +78,10 @@ export class TrackedValue implements Reactive { return (this.#get ??= () => this.value); } + set get(fn: () => Value) { + this.#get = fn; + } + /** * Function short-hand for assigning `value`. * @@ -80,6 +92,10 @@ 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. */ @@ -89,6 +105,10 @@ 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. @@ -99,6 +119,10 @@ export class TrackedValue implements Reactive { }); } + set freeze(fn: () => void) { + this.#freeze = fn; + } + #write(value: Value): boolean { if (this.#isFrozen) { throw new Error( @@ -120,10 +144,10 @@ export class TrackedValue implements Reactive { } } -const DEFAULT_OPTIONS: ReactiveOptions = { +const DEFAULT_OPTIONS: ReactiveOptions = Object.freeze({ equals: Object.is, description: undefined, -}; +}); export function trackedValue( value: Value, diff --git a/packages/@glimmer/validator/test/tracked-value-test.ts b/packages/@glimmer/validator/test/tracked-value-test.ts index b7962546ab7..833f6b59cfa 100644 --- a/packages/@glimmer/validator/test/tracked-value-test.ts +++ b/packages/@glimmer/validator/test/tracked-value-test.ts @@ -110,6 +110,24 @@ 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; From 50eef0f8c14c80743a602145373559848a34347a Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:12:24 -0400 Subject: [PATCH 5/8] Skip the holes of the tracker pool in resetTracking An untrack frame takes a depth but no tracker. A track frame that begins inside one, at a depth that never had a tracker, leaves a hole below it. resetTracking then called clear() on undefined. The QUnit setup calls resetTracking after each test, so a filtered run of the tracking module stopped at the first test after the hole. The full suite fills every depth before that, so it did not show the error. Co-Authored-By: Claude Opus 5.5 (1M context) --- packages/@glimmer/validator/lib/tracking.ts | 8 ++++++-- packages/@glimmer/validator/test/tracking-test.ts | 14 ++++++++++++++ 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index 3c0cfdeeed3..ac555a3e5ff 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -119,8 +119,10 @@ const OPEN_TRACK_FRAMES: (Tracker | null)[] = []; /** * Frames are strictly nested, so the tracker of a frame at depth `n` is free * when that frame ends. One tracker for each depth is enough. + * + * An untrack frame takes a depth but no tracker, so the pool can have holes. */ -const TRACKER_POOL: Tracker[] = []; +const TRACKER_POOL: (Tracker | undefined)[] = []; export function beginTrackFrame(debuggingContext?: string | false): void { let depth = OPEN_TRACK_FRAMES.length; @@ -182,7 +184,9 @@ export function resetTracking(): string | void { } for (let tracker of TRACKER_POOL) { - tracker.clear(); + if (tracker !== undefined) { + tracker.clear(); + } } CURRENT_TRACKER = null; diff --git a/packages/@glimmer/validator/test/tracking-test.ts b/packages/@glimmer/validator/test/tracking-test.ts index 3ff057039e5..5f9a7b4dd12 100644 --- a/packages/@glimmer/validator/test/tracking-test.ts +++ b/packages/@glimmer/validator/test/tracking-test.ts @@ -1,6 +1,7 @@ import { DEBUG } from '@glimmer/env'; import { beginTrackFrame, + beginUntrackFrame, consumeTag, createCache, createTag, @@ -375,6 +376,19 @@ module('@glimmer/validator: tracking', () => { assert.strictEqual(endTrackFrame(), tag2); }); + test('it resets after a frame that began inside untrack frames', (assert) => { + // deeper than any other test goes, so these depths have no tracker yet + for (let i = 0; i < 100; i++) { + beginUntrackFrame(); + } + + beginTrackFrame(); + + resetTracking(); + + assert.notOk(isTracking()); + }); + test('isTracking works within a track', (assert) => { assert.notOk(isTracking()); From 28d8a42a17f6870aeb31f21f9698521dca24b4a5 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Wed, 9 Sep 2026 20:52:07 -0400 Subject: [PATCH 6/8] [PERF] Skip unchanged blocks in the updating VM with a per-block tag Every block opcode (try, list, list item) now records the combined tag of what its render or last update consumed, the way a component cache group does. On update, a block whose tag still validates is skipped as a whole, and its tag is consumed into the parent frame so parents stay correct. Before, an unchanged row in a {{#each}} cost one validation per dynamic reference: on the js-framework-benchmark row that is five validateTag calls and five megamorphic opcode evaluations per row per update. Now it costs one validation, and the five opcodes never run. The append VM opens the frame before a block's opcode is constructed, because the list block reads its iterable in the constructor, and closes it when the block exits. On the updating side the frame is closed when the block's updating frame finishes. When a block re-renders after a thrown assertion, the append VM closes the frame on exit and the updating VM pops the frame without closing it again. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq --- packages/@glimmer/runtime/lib/vm/append.ts | 23 ++++++ packages/@glimmer/runtime/lib/vm/update.ts | 83 +++++++++++++++++++--- 2 files changed, 95 insertions(+), 11 deletions(-) diff --git a/packages/@glimmer/runtime/lib/vm/append.ts b/packages/@glimmer/runtime/lib/vm/append.ts index b27b704785b..cf0716c4a4a 100644 --- a/packages/@glimmer/runtime/lib/vm/append.ts +++ b/packages/@glimmer/runtime/lib/vm/append.ts @@ -74,6 +74,7 @@ class Stacks { readonly cache = new Stack(); readonly list = new Stack(); readonly destroyable = new Stack(); + readonly block = new Stack(); constructor(scope: Scope, dynamicScope: DynamicScope) { this.scope.push(scope); @@ -402,6 +403,8 @@ export class VM { enter(args: number) { let updating: UpdatingOpcode[] = []; + beginTrackFrame(); + let state = this.capture(args); let block = this.tree().pushResettableBlock(); @@ -438,6 +441,8 @@ export class VM { enterItem({ key, value, memo }: OpaqueIterationItem): ListItemOpcode { let { stack } = this; + beginTrackFrame(); + let valueRef = createIteratorItemRef(value); let memoRef = createIteratorItemRef(memo); @@ -479,6 +484,10 @@ export class VM { enterList(iterableRef: Reference, offset: number) { let updating: ListItemOpcode[] = []; + // The list block reads its iterable in the constructor, so the frame must + // be open before that read. + beginTrackFrame(); + let addr = this.lowlevel.target(offset); let state = this.capture(0, addr); let list = this.tree().pushBlockList(updating) as AppendingBlockList; @@ -509,10 +518,23 @@ export class VM { private didEnter(opcode: BlockOpcode) { this.associateDestroyable(opcode); this.#stacks.destroyable.push(opcode); + this.#stacks.block.push(opcode); this.updateWith(opcode); this.pushUpdating(opcode.children); } + /** + * Re-enter a block that the updating VM is re-rendering. Its tracking frame + * is already open, opened by the updating VM before it evaluated the block. + * + * [!] push Block Stack <- `opcode` + * [!] push Updating Stack <- `children` + */ + resumeBlock(opcode: BlockOpcode, children: UpdatingOpcode[]) { + this.#stacks.block.push(opcode); + this.pushUpdating(children); + } + /** * ## Opcodes * @@ -529,6 +551,7 @@ export class VM { this.#stacks.destroyable.pop(); this.#tree.popBlock(); this.popUpdating(); + expect(this.#stacks.block.pop(), 'VM BUG: expected a block to exit').didExit(); } /** diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index ca239649fd3..c1ab66ef546 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -11,6 +11,7 @@ import type { ResettableBlock, Scope, SimpleComment, + Tag, UpdatingOpcode, UpdatingVM as IUpdatingVM, } from '@glimmer/interfaces'; @@ -24,7 +25,13 @@ import { updateRef, valueForRef } from '@glimmer/reference/lib/reference'; import { logStep } from '@glimmer/util/lib/debug-steps'; import { StackImpl as Stack } from '@glimmer/util/lib/collections'; import { debug } from '@glimmer/validator/lib/debug'; -import { resetTracking } from '@glimmer/validator/lib/tracking'; +import { + beginTrackFrame, + consumeTag, + endTrackFrame, + resetTracking, +} from '@glimmer/validator/lib/tracking'; +import { INITIAL, validateTag, valueForTag } from '@glimmer/validator/lib/validators'; import type { Closure } from './append'; import type { AppendingBlockList } from './element-builder'; @@ -75,10 +82,12 @@ export class UpdatingVM implements IUpdatingVM { this.try(opcodes, handler); while (!frameStack.isEmpty()) { - let opcode = this.frame.nextStatement(); + let frame = this.frame; + let opcode = frame.nextStatement(); if (opcode === undefined) { frameStack.pop(); + frame.finish(); continue; } @@ -94,10 +103,19 @@ export class UpdatingVM implements IUpdatingVM { this.frame.goto(index); } - try(ops: UpdatingOpcode[], handler: Nullable) { - this.frameStack.push(new UpdatingVMFrame(ops, handler)); + try( + ops: UpdatingOpcode[], + handler: Nullable, + block: Nullable = null + ) { + this.frameStack.push(new UpdatingVMFrame(ops, handler, block)); } + /* + * The handler re-renders its block with the append VM, and that render ends + * the block's tracking frame itself when it exits the block. So the frame is + * popped without `finish()`. + */ throw() { this.frame.handleException(); this.frameStack.pop(); @@ -111,11 +129,23 @@ export interface VMState { readonly stack: unknown[]; } +/* + * Every block records the combined tag of what its render consumed, the same + * way a component cache group does. While that tag validates, the updating VM + * skips the block's children, so a list of unchanged rows costs one validation + * per row instead of one per dynamic reference. + * + * Introduction and background in https://github.com/emberjs/ember.js/pull/21596 + */ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { [DESTROYABLE_META_KEY]: object | undefined; public children: UpdatingOpcode[]; + /** Combined tag of everything consumed during the last render or update of this block. */ + private tag: Tag | null = null; + private lastRevision = INITIAL; + protected readonly bounds: AppendingBlock; constructor( @@ -141,7 +171,31 @@ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { } evaluate(vm: UpdatingVM) { - vm.try(this.children, null); + let { tag } = this; + + if (tag !== null && !vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { + consumeTag(tag); + return; + } + + beginTrackFrame(); + this.evaluateChildren(vm); + } + + protected evaluateChildren(vm: UpdatingVM) { + vm.try(this.children, null, this); + } + + /** + * Called when the block's tracking frame ends: from the append VM when the + * block exits, and from the updating VM when the block's frame finishes. + */ + didExit() { + let tag = endTrackFrame(); + + this.tag = tag; + this.lastRevision = valueForTag(tag); + consumeTag(tag); } } @@ -150,8 +204,8 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { declare protected bounds: ResettableBlock; // Shadows property on base class - override evaluate(vm: UpdatingVM) { - vm.try(this.children, this); + protected override evaluateChildren(vm: UpdatingVM) { + vm.try(this.children, this, this); } handleException() { @@ -170,7 +224,7 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { let result = vm.execute((vm) => { vm.updateWith(this); - vm.pushUpdating(children); + vm.resumeBlock(this, children); }); associateDestroyableChild(this, result.drop); @@ -227,7 +281,7 @@ export class ListBlockOpcode extends BlockOpcode { this.opcodeMap.set(opcode.key, opcode); } - override evaluate(vm: UpdatingVM) { + protected override evaluateChildren(vm: UpdatingVM) { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { @@ -249,7 +303,7 @@ export class ListBlockOpcode extends BlockOpcode { } // Run now-updated updating opcodes - super.evaluate(vm); + super.evaluateChildren(vm); } private sync(iterator: OpaqueIterator) { @@ -431,9 +485,16 @@ class UpdatingVMFrame { constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + private block: Nullable ) {} + finish() { + if (this.block !== null) { + this.block.didExit(); + } + } + goto(index: number) { this.current = index; } From 88cb6b3c473246105053d91556019b5337174ce9 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Thu, 10 Sep 2026 12:13:25 -0400 Subject: [PATCH 7/8] [PERF] Reuse block and cache group tags; drop a missed guard, re-arm it later Blocks and component cache groups pass their previous tag to endTrackFrame(), so a block that re-renders with the same dependencies keeps its tag and its memoized revision. The tag comparison against the tracker's live entries comes from #21650, which is below this commit. A guard that fails is dropped on the spot, with no tracking frame: a block that changed is likely to change again, and a frame on every miss is what made all-rows-change workloads slower. A dropped guard comes back after eight unguarded updates, so a block that changed once and then stayed still is skipped again, while a block that changes on every update pays for one frame in every eight. A re-render re-arms the guard right away. An unguarded block costs what it did before guards existed: one frame push. A re-render of a dropped block opens its own tracking frame, because the append VM closes one when the block exits. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_012wPae8QnFbKHKn5xbgaQVq --- .../runtime/lib/compiled/opcodes/vm.ts | 7 +- packages/@glimmer/runtime/lib/vm/update.ts | 122 ++++++++++++++---- 2 files changed, 100 insertions(+), 29 deletions(-) diff --git a/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts b/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts index a505a3ff5b6..eb58c29dc12 100644 --- a/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts +++ b/packages/@glimmer/runtime/lib/compiled/opcodes/vm.ts @@ -284,7 +284,7 @@ export class AssertFilter implements UpdatingOpcode { } export class JumpIfNotModifiedOpcode implements UpdatingOpcode { - private tag: Tag = CONSTANT_TAG; + tag: Tag = CONSTANT_TAG; private lastRevision: Revision = INITIAL; private target?: number; @@ -321,7 +321,8 @@ export class EndTrackFrameOpcode implements UpdatingOpcode { constructor(private target: JumpIfNotModifiedOpcode) {} evaluate() { - let tag = endTrackFrame(); - this.target.didModify(tag); + let { target } = this; + let tag = endTrackFrame(target.tag); + target.didModify(tag); } } diff --git a/packages/@glimmer/runtime/lib/vm/update.ts b/packages/@glimmer/runtime/lib/vm/update.ts index c1ab66ef546..3b2f05c8cce 100644 --- a/packages/@glimmer/runtime/lib/vm/update.ts +++ b/packages/@glimmer/runtime/lib/vm/update.ts @@ -87,7 +87,11 @@ export class UpdatingVM implements IUpdatingVM { if (opcode === undefined) { frameStack.pop(); - frame.finish(); + + if (frame.block !== null) { + frame.block.didExit(); + } + continue; } @@ -130,10 +134,15 @@ export interface VMState { } /* - * Every block records the combined tag of what its render consumed, the same - * way a component cache group does. While that tag validates, the updating VM - * skips the block's children, so a list of unchanged rows costs one validation - * per row instead of one per dynamic reference. + * A block records the combined tag of what its render consumed, the same way + * a component cache group does. While that tag validates, the updating VM + * skips the block's children, so a list of unchanged rows costs one + * validation per row instead of one per dynamic reference. + * + * A guard only pays when it skips more work than its own validation. A block + * with one opcode, or with fewer than three consumed tags, is not guarded: a + * condition plus one component cache group is the common two-tag shape, and + * the group already guards itself. * * Introduction and background in https://github.com/emberjs/ember.js/pull/21596 */ @@ -142,9 +151,14 @@ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { public children: UpdatingOpcode[]; - /** Combined tag of everything consumed during the last render or update of this block. */ - private tag: Tag | null = null; - private lastRevision = INITIAL; + /** + * Combined tag of everything consumed during the last render or update of + * this block, or null when the block is not guarded. + */ + protected tag: Tag | null = null; + protected lastRevision = INITIAL; + /** Updates evaluated without a guard since the guard was dropped. */ + protected unguarded = 0; protected readonly bounds: AppendingBlock; @@ -170,29 +184,65 @@ export abstract class BlockOpcode implements UpdatingOpcode, Bounds { return this.bounds.lastNode(); } + /** + * An unguarded block costs what it did before guards existed: one frame + * push. A guarded block validates first, and opens a tracking frame that + * the updating VM closes through `didExit` when the block's opcodes are done. + */ evaluate(vm: UpdatingVM) { let { tag } = this; - if (tag !== null && !vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { + if (tag === null) { + if (this.rearm()) { + beginTrackFrame(); + vm.try(this.children, null, this); + } else { + vm.try(this.children, null, null); + } + return; + } + + if (!vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { consumeTag(tag); return; } - beginTrackFrame(); - this.evaluateChildren(vm); + // A block that changed is likely to change again, and a guard on it only + // costs. Drop it without a frame; `rearm` opens one later. + this.tag = null; + vm.try(this.children, null, null); } - protected evaluateChildren(vm: UpdatingVM) { - vm.try(this.children, null, this); + /** + * A dropped guard comes back after a few unguarded updates, so a block + * that changed once and then stayed still is skipped again, while a block + * that changes on every update pays for one frame in every few. + */ + protected rearm(): boolean { + if (++this.unguarded < REARM_AFTER) return false; + + this.unguarded = 0; + return true; + } + + /** + * Whether the block's tracking frame is open. The append VM always opens one + * before it enters a block; the updating VM opens one when it validates a + * guard or re-arms one, and a re-render of a dropped block opens its own. + */ + protected get framed(): boolean { + return this.tag !== null; } /** * Called when the block's tracking frame ends: from the append VM when the - * block exits, and from the updating VM when the block's frame finishes. + * block exits, and from the updating VM when a guarded block's frame + * finishes. Decides whether the block stays guarded. */ didExit() { - let tag = endTrackFrame(); + let tag = endTrackFrame(this.tag ?? undefined); + this.unguarded = 0; this.tag = tag; this.lastRevision = valueForTag(tag); consumeTag(tag); @@ -204,8 +254,26 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { declare protected bounds: ResettableBlock; // Shadows property on base class - protected override evaluateChildren(vm: UpdatingVM) { - vm.try(this.children, this, this); + override evaluate(vm: UpdatingVM) { + let { tag } = this; + + if (tag === null) { + if (this.rearm()) { + beginTrackFrame(); + vm.try(this.children, this, this); + } else { + vm.try(this.children, this, null); + } + return; + } + + if (!vm.alwaysRevalidate && validateTag(tag, this.lastRevision)) { + consumeTag(tag); + return; + } + + this.tag = null; + vm.try(this.children, this, null); } handleException() { @@ -215,6 +283,12 @@ export class TryOpcode extends BlockOpcode implements ExceptionHandler { context: { env }, } = this; + // The re-render exits the block through the append VM, which closes one + // frame; a dropped guard has none open. + if (!this.framed) { + beginTrackFrame(); + } + destroyChildren(this); let tree = NewTreeBuilder.resume(env, bounds); @@ -281,7 +355,7 @@ export class ListBlockOpcode extends BlockOpcode { this.opcodeMap.set(opcode.key, opcode); } - protected override evaluateChildren(vm: UpdatingVM) { + override evaluate(vm: UpdatingVM) { let iterator = valueForRef(this.iterableRef); if (this.lastIterator !== iterator) { @@ -303,7 +377,7 @@ export class ListBlockOpcode extends BlockOpcode { } // Run now-updated updating opcodes - super.evaluateChildren(vm); + vm.try(this.children, null, null); } private sync(iterator: OpaqueIterator) { @@ -480,21 +554,17 @@ export class ListBlockOpcode extends BlockOpcode { } } +const REARM_AFTER = 8; + class UpdatingVMFrame { private current = 0; constructor( private ops: UpdatingOpcode[], private exceptionHandler: Nullable, - private block: Nullable + readonly block: Nullable ) {} - finish() { - if (this.block !== null) { - this.block.didExit(); - } - } - goto(index: number) { this.current = index; } From 4ed19218aecd2d51337d8495ac120005b83713a1 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Mon, 5 Oct 2026 14:01:17 -0400 Subject: [PATCH 8/8] Test blocks through more updates than a guard needs to come back A dropped guard comes back after 8 updates. Each test runs a block, a nested block, or a list through 20 updates of one kind and then changes something else, so the block is skipped, dropped, and guarded again while the test checks the output. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../test/block-guards-test.ts | 150 ++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts diff --git a/packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts b/packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts new file mode 100644 index 00000000000..8b81e72f522 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/block-guards-test.ts @@ -0,0 +1,150 @@ +import { jitSuite, RenderTest, test, tracked } from '@glimmer-workspace/integration-tests'; + +// More updates than a dropped guard needs to come back. +const MANY = 20; + +class Item { + @tracked text: string; + + constructor( + public id: number, + text: string + ) { + this.text = text; + } +} + +class BlockGuardsTest extends RenderTest { + static suiteName = 'Block guards'; + + @test + 'a block updates after many renders that did not change it'() { + this.render('{{#if this.show}}

{{this.inner}}

{{/if}}{{this.other}}', { + show: true, + inner: 'a', + other: 0, + }); + this.assertHTML('

a

0'); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ other: i }); + this.assertHTML(`

a

${i}`); + } + + this.rerender({ inner: 'b' }); + this.assertHTML(`

b

${MANY}`); + this.assertStableNodes(); + } + + @test + 'a block that changes on every render updates, rests, and updates again'() { + this.render('{{#if this.show}}

{{this.inner}}

{{/if}}{{this.other}}', { + show: true, + inner: 'a', + other: 0, + }); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ inner: `v${i}` }); + this.assertHTML(`

v${i}

0`); + } + + for (let i = 1; i <= MANY; i++) { + this.rerender({ other: i }); + this.assertHTML(`

v${MANY}

${i}`); + } + + this.rerender({ inner: 'last' }); + this.assertHTML(`

last

${MANY}`); + this.assertStableNodes(); + } + + @test + 'a block that renders again on every update keeps the right content'() { + this.render('{{#if this.cond}}

yes {{this.text}}

{{else}}

no {{this.text}}

{{/if}}', { + cond: true, + text: 'a', + }); + this.assertHTML('

yes a

'); + + for (let i = 1; i <= MANY; i++) { + let cond = i % 2 === 0; + + this.rerender({ cond, text: `t${i}` }); + this.assertHTML(`

${cond ? 'yes' : 'no'} t${i}

`); + } + + for (let i = 1; i <= MANY; i++) { + this.rerender({ text: `u${i}` }); + this.assertHTML(`

yes u${i}

`); + } + + this.rerender({ cond: false }); + this.assertHTML(`

no u${MANY}

`); + } + + @test + 'a nested block updates when the outer block has nothing else to do'() { + this.render( + '{{#if this.outer}}
{{#if this.inner}}

{{this.text}}

{{/if}}
{{/if}}{{this.other}}', + { outer: true, inner: true, text: 'a', other: 0 } + ); + this.assertHTML('

a

0'); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ other: i }); + } + + this.rerender({ text: 'b' }); + this.assertHTML(`

b

${MANY}`); + + for (let i = 1; i <= MANY; i++) { + this.rerender({ text: `c${i}` }); + this.assertHTML(`

c${i}

${MANY}`); + } + + this.rerender({ inner: false }); + this.assertHTML(`
${MANY}`); + + this.rerender({ inner: true, text: 'd' }); + this.assertHTML(`

d

${MANY}`); + } + + @test + 'an item of a list updates after many renders that changed another item'() { + let items = [new Item(1, 'a'), new Item(2, 'b'), new Item(3, 'c')]; + + this.render('{{#each this.items key="id" as |item|}}

{{item.text}}

{{/each}}', { items }); + this.assertHTML('

a

b

c

'); + + for (let i = 1; i <= MANY; i++) { + items[0]!.text = `a${i}`; + this.rerender(); + this.assertHTML(`

a${i}

b

c

`); + } + + items[2]!.text = 'z'; + this.rerender(); + this.assertHTML(`

a${MANY}

b

z

`); + this.assertStableNodes(); + } + + @test + 'a list updates its items after many renders with new arrays'() { + let items = [new Item(1, 'a'), new Item(2, 'b'), new Item(3, 'c')]; + + this.render('{{#each this.items key="id" as |item|}}

{{item.text}}

{{/each}}', { items }); + + for (let i = 1; i <= MANY; i++) { + items = [new Item(1, 'a'), new Item(2, `b${i}`), new Item(3, 'c')]; + this.rerender({ items }); + this.assertHTML(`

a

b${i}

c

`); + } + + items = [new Item(3, 'c'), new Item(1, 'first'), new Item(4, 'new')]; + this.rerender({ items }); + this.assertHTML('

c

first

new

'); + } +} + +jitSuite(BlockGuardsTest);