diff --git a/packages/@glimmer/validator/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index 1ae354a32c6..1daf6eb249a 100644 --- a/packages/@glimmer/validator/lib/tracking.ts +++ b/packages/@glimmer/validator/lib/tracking.ts @@ -5,7 +5,14 @@ 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'; /** * A tag with the `slot` field that `Tracker#add` reads and writes. @@ -17,6 +24,30 @@ interface ConsumedTag extends Tag { slot: number; } +/** + * Makes the tag of a frame that consumed two or more tags. + * + * `previous` is the tag that the frame produced the last time it ran. + * If the frame consumed the same tags again, in the same order, + * the result is `previous`. + * That tag keeps its memoized revision, and nothing is allocated. + * + * 1. last run: consumed [ a, b, c ] result: tag X. + * 2. this run: consumed [ a, b, c ] result: tag X again. + * 3. or: consumed [ a, c ] result: a new tag. + * + * This code is not in `Tracker#combineForCache`, to keep that method small. + * V8 puts a small method inline into the end of the frame. + * With this code in it, a frame that consumed one tag was 6% slower. + */ +function combineOrReuse(previous: Tag | undefined, tags: (Tag | null)[], size: number): Tag { + if (previous !== undefined && isCombinationOf(previous, tags, size)) { + return previous; + } + + return combine(tags.slice(0, size) as Tag[]); +} + /** * An object that tracks @tracked properties that were consumed. * @@ -109,6 +140,29 @@ class Tracker { return result; } + /** + * `combine()` for the frame of a cache. + * + * `previous` is the tag that the cache has from its last run. + * See `combineOrReuse`. + */ + combineForCache(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 { + result = combineOrReuse(previous, tags, size); + } + + this.clear(); + + return result; + } + /** * Empties the tracker for the next frame at the same depth. * @@ -208,6 +262,32 @@ export function endTrackFrame(): Tag { return unwrap(current).combine(); } +/** + * `endTrackFrame()` for the frame of a cache. + * + * `previous` is the tag that the cache has from its last run. + * If the cache consumed the same tags again, that tag is the result. + * + * Only `getValue` calls this. + * The frames of the render VM end in `endTrackFrame`, + * which has no code for the reuse. + */ +function endCacheFrame(previous: Tag | undefined): Tag { + let current = CURRENT_TRACKER; + + if (DEBUG) { + if (OPEN_TRACK_FRAMES.length === 0) { + throw new Error('attempted to close a tracking frame, but one was not open'); + } + + unwrap(debug.endTrackingTransaction)(); + } + + CURRENT_TRACKER = OPEN_TRACK_FRAMES.pop() || null; + + return unwrap(current).combineForCache(previous); +} + export function beginUntrackFrame(): void { OPEN_TRACK_FRAMES.push(CURRENT_TRACKER); CURRENT_TRACKER = null; @@ -313,7 +393,7 @@ export function getValue(cache: Cache): T | undefined { try { cache[LAST_VALUE] = fn(); } finally { - tag = endTrackFrame(); + tag = endCacheFrame(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 9745468c398..5fb0702b3f1 100644 --- a/packages/@glimmer/validator/lib/validators.ts +++ b/packages/@glimmer/validator/lib/validators.ts @@ -298,6 +298,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 40d8d57f18a..eedb07297d5 100644 --- a/packages/@glimmer/validator/test/tracking-test.ts +++ b/packages/@glimmer/validator/test/tracking-test.ts @@ -448,6 +448,199 @@ module('@glimmer/validator: tracking', () => { assert.deepEqual(getValue(outerCache), [3, 2], 'memoized result returned correctly'); }); + /** + * The tag that a cache has after a read. + * + * The outer frame consumes only the tag of the cache, + * and a frame with one tag returns that tag. + */ + function tagOf(cache: ReturnType) { + beginTrackFrame(); + getValue(cache); + + return endTrackFrame(); + } + + test('it keeps its tag if it consumes the same tags in the next run', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + + let cache = createCache(() => { + assert.step('cache reads tag1 and tag2'); + + consumeTag(tag1); + consumeTag(tag2); + }); + + let first = tagOf(cache); + + assert.verifySteps(['cache reads tag1 and tag2']); + + dirtyTag(tag1); + + let second = tagOf(cache); + + assert.verifySteps(['cache reads tag1 and tag2'], 'the cache ran again'); + assert.strictEqual(second, first, 'the cache has the tag of its first run'); + }); + + test('it gets a new tag if it consumes other tags in the next run', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + let tag3 = createTag(); + let useThird = false; + + let cache = createCache(() => { + consumeTag(tag1); + + if (useThird) { + assert.step('cache reads tag1 and tag3'); + consumeTag(tag3); + } else { + assert.step('cache reads tag1 and tag2'); + consumeTag(tag2); + } + }); + + let first = tagOf(cache); + + assert.verifySteps(['cache reads tag1 and tag2']); + + useThird = true; + dirtyTag(tag1); + + let second = tagOf(cache); + + assert.verifySteps(['cache reads tag1 and tag3'], 'the cache ran again'); + assert.notStrictEqual(second, first); + }); + + test('it gets a new tag if it consumes the same tags in another order', (assert) => { + let tag1 = createTag(); + let tag2 = createTag(); + let swap = false; + + let cache = createCache(() => { + if (swap) { + assert.step('cache reads tag2, then tag1'); + consumeTag(tag2); + consumeTag(tag1); + } else { + assert.step('cache reads tag1, then tag2'); + consumeTag(tag1); + consumeTag(tag2); + } + }); + + let first = tagOf(cache); + + assert.verifySteps(['cache reads tag1, then tag2']); + + swap = true; + dirtyTag(tag1); + + let second = tagOf(cache); + + assert.verifySteps(['cache reads tag2, then tag1'], 'the cache ran again'); + assert.notStrictEqual(second, first); + + let snapshot = valueForTag(second); + + dirtyTag(tag2); + assert.notOk(validateTag(second, snapshot), 'the new tag follows both tags'); + }); + + 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 cache = createCache(() => { + consumeTag(tag1); + + if (useThird) { + assert.step('cache reads tag1 and tag3'); + consumeTag(tag3); + } else { + assert.step('cache reads tag1 and tag2'); + consumeTag(tag2); + } + }); + + getValue(cache); + assert.verifySteps(['cache reads tag1 and tag2']); + + dirtyTag(tag2); + getValue(cache); + assert.verifySteps(['cache reads tag1 and tag2'], 'the cache ran again with the same tags'); + + dirtyTag(tag2); + getValue(cache); + assert.verifySteps(['cache reads tag1 and tag2'], 'the reused tag still follows its tags'); + + useThird = true; + dirtyTag(tag1); + getValue(cache); + assert.verifySteps(['cache reads tag1 and tag3']); + + dirtyTag(tag2); + getValue(cache); + assert.verifySteps([], 'the cache does not depend on the old tag'); + + dirtyTag(tag3); + getValue(cache); + assert.verifySteps(['cache reads tag1 and tag3'], '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 inner = createCache(() => { + assert.step('inner reads tag1 and tag2'); + + consumeTag(tag1); + consumeTag(tag2); + }); + + let outer = createCache(() => { + assert.step('outer reads tag3 and inner'); + + consumeTag(tag3); + getValue(inner); + }); + + getValue(outer); + assert.verifySteps(['outer reads tag3 and inner', 'inner reads tag1 and tag2']); + + dirtyTag(tag1); + getValue(outer); + assert.verifySteps( + ['outer reads tag3 and inner', 'inner reads tag1 and tag2'], + 'the inner cache ran again with the same tags' + ); + + dirtyTag(tag2); + getValue(outer); + assert.verifySteps( + ['outer reads tag3 and inner', 'inner reads tag1 and tag2'], + 'the outer cache follows the reused inner tag' + ); + + dirtyTag(tag3); + getValue(outer); + assert.verifySteps(['outer reads tag3 and inner'], 'the inner cache did not run again'); + + dirtyTag(tag1); + getValue(outer); + assert.verifySteps( + ['outer reads tag3 and inner', 'inner reads tag1 and tag2'], + 'the outer cache still follows the inner tag' + ); + }); + test('isTracking works within a memoized function and untrack frame', (assert) => { assert.notOk(isTracking());