diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..1ae354a32c6 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -8,34 +8,127 @@ import { unwrap } from './utils'; import { combine, CONSTANT_TAG, isConstTag, validateTag, valueForTag } from './validators'; /** - * An object that that tracks @tracked properties that were consumed. + * A tag with the `slot` field that `Tracker#add` reads and writes. + * + * Each tag class of this package declares the field, + * so a tag has it from its construction. */ -class Tracker { - private tags = new Set(); - private last: Tag | null = null; +interface ConsumedTag extends Tag { + slot: number; +} +/** + * An object that tracks @tracked properties that were consumed. + * + * A tracker collects the tags that one tracking frame consumes. + * When the frame ends, `combine()` makes one tag from them. + * + * A tracker is not made for each frame. + * `beginTrackFrame` takes it from `TRACKER_POOL`, so the next frame + * at the same depth uses the same tracker again. + */ +class Tracker { + /** + * The tags that the current frame consumed. + * Each tag is at the position of its first consumption. + * `size` is the number of tags. + * + * tags: [ a, b, c, null, null ] + * size: 3 + * + * The entries from `size` up are `null`. + * They are left from an earlier frame that consumed more tags. + * + * The array never shrinks. + * A write to `length` is a slow path in V8, and this code runs for every frame. + */ + private tags: (Tag | null)[] = []; + private size = 0; + + /** + * Adds a tag to the frame, one time. + * + * A frame can consume one tag many times, + * so the tracker must find out if it has the tag already. + * It does that with no `Set`: the tag keeps the index + * at which a tracker put it last, in `tag.slot`. + * + * consume a tags: [ a ] a.slot = 0 + * consume b tags: [ a, b ] b.slot = 1 + * consume a tags[a.slot] is a, so the frame has it. No change. + * + * The check is `tags[tag.slot] === tag`. + * It compares the entry with the tag, so a `slot` that is out of date + * cannot hide a tag. The worst case is a tag that is in the array two times. + * + * That case needs a nested frame. + * The nested frame has its own tracker, and that tracker also writes `slot`: + * + * 1. outer frame consumes a, b outer tags: [ a, b ] b.slot = 1 + * 2. inner frame consumes b inner tags: [ b ] b.slot = 0 + * 3. outer frame consumes b outer tags[0] is a, not b. + * outer tags: [ a, b, b ] b.slot = 2 + * + * The duplicate does no harm. + * The revision of a combined tag is the highest revision of its tags, + * and a tag that is there two times does not change the highest. + * + * If the inner frame puts the tag at the index that the outer frame used, + * the check of the outer frame still passes, and there is no duplicate. + */ add(tag: Tag) { if (tag === CONSTANT_TAG) return; - this.tags.add(tag); - if (DEBUG) { unwrap(debug.markTagAsConsumed)(tag); } - this.last = tag; + let { tags, size } = this; + + if (tags[(tag as ConsumedTag).slot] === tag) return; + + (tag as ConsumedTag).slot = size; + tags[size] = tag; + this.size = size + 1; } combine(): Tag { - let { tags } = this; + let { tags, size } = this; + let result: Tag; - if (tags.size === 0) { - return CONSTANT_TAG; - } else if (tags.size === 1) { - return this.last as Tag; + if (size === 0) { + result = CONSTANT_TAG; + } else if (size === 1) { + result = tags[0] as Tag; } else { - return combine(Array.from(this.tags)); + result = combine(tags.slice(0, size) as Tag[]); + } + + this.clear(); + + return result; + } + + /** + * Empties the tracker for the next frame at the same depth. + * + * before: tags: [ a, b, c ] size: 3 + * after: tags: [ null, null, null ] size: 0 + * + * The entries must be `null`, and not only ignored: + * + * - an entry that stays is a match for `tags[tag.slot] === tag`, + * so the next frame would not add that tag + * - an entry that stays keeps its tag alive after the frame + */ + clear(): void { + let { tags, size } = this; + + for (let i = 0; i < size; i++) { + tags[i] = null; } + + this.size = 0; } } @@ -56,10 +149,43 @@ let CURRENT_TRACKER: Tracker | null = null; const OPEN_TRACK_FRAMES: (Tracker | null)[] = []; +/** + * The trackers, by the depth of the frame that uses them. + * The depth of a frame is the number of frames that are open around it. + * + * Frames are strictly nested: a frame ends before the frame around it ends. + * So two frames at the same depth are never open at the same time, + * and one tracker for each depth is enough. + * + * frame A depth 0 TRACKER_POOL[0] + * |- frame B depth 1 TRACKER_POOL[1] + * | `- frame C depth 2 TRACKER_POOL[2] + * `- frame D depth 1 TRACKER_POOL[1], which B used before + * + * A tracker is made the first time that a frame opens at its depth. + * After that, a frame at that depth allocates no tracker. + * + * An untrack frame takes a depth but no tracker, so the pool can have holes: + * + * untrack frame depth 0 no tracker + * `- frame E depth 1 TRACKER_POOL[1] + * + * TRACKER_POOL: [ , tracker ] + */ +const TRACKER_POOL: (Tracker | undefined)[] = []; + 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(); + } + + CURRENT_TRACKER = tracker; if (DEBUG) { unwrap(debug.beginTrackingTransaction)(debuggingContext); @@ -101,6 +227,18 @@ export function resetTracking(): string | void { OPEN_TRACK_FRAMES.pop(); } + /** + * A frame that did not end left its tags in its tracker. + * The next frame at that depth must start with no tag. + * + * The pool can have holes, see `TRACKER_POOL`. + */ + for (let tracker of TRACKER_POOL) { + if (tracker !== undefined) { + tracker.clear(); + } + } + CURRENT_TRACKER = null; if (DEBUG) { diff --git a/packages/@glimmer/validator/lib/validators.ts b/packages/@glimmer/validator/lib/validators.ts index 66c7f1ac1cb..01fafbfd19f 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -114,6 +114,15 @@ class MonomorphicTagImpl { public subtag: Tag | Tag[] | null = null; private subtagBufferCache: Revision | null = null; + /** + * The index at which a tracker put this tag last. + * `Tracker#add` uses it to find out if a tracking frame has the tag already. + * + * The start value 0 is safe: + * the tracker compares its entry at that index with the tag. + */ + public slot = 0; + declare [TYPE]: T; constructor(type: T) { @@ -254,6 +263,12 @@ const VOLATILE_TAG_ID: IVOLATILE_TAG_ID = 100; export class VolatileTag implements Tag { readonly [TYPE] = VOLATILE_TAG_ID; + + /** + * See `slot` of `MonomorphicTagImpl`. + */ + slot = 0; + [COMPUTE](): Revision { return VOLATILE; } @@ -267,6 +282,12 @@ const CURRENT_TAG_ID: ICURRENT_TAG_ID = 101; export class CurrentTag implements Tag { readonly [TYPE] = CURRENT_TAG_ID; + + /** + * See `slot` of `MonomorphicTagImpl`. + */ + 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 1a0fc4c1dc0..40d8d57f18a 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, @@ -10,6 +11,7 @@ import { getValue, isConst, isTracking, + resetTracking, track, trackedData, untrack, @@ -252,6 +254,90 @@ 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 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 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('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());