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); 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/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..3b2f05c8cce 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,16 @@ 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(); + + if (frame.block !== null) { + frame.block.didExit(); + } + continue; } @@ -94,10 +107,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 +133,33 @@ export interface VMState { readonly stack: unknown[]; } +/* + * 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 + */ 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, 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; constructor( @@ -140,8 +184,68 @@ 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) { - vm.try(this.children, null); + let { tag } = this; + + 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; + } + + // 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); + } + + /** + * 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 a guarded block's frame + * finishes. Decides whether the block stays guarded. + */ + didExit() { + let tag = endTrackFrame(this.tag ?? undefined); + + this.unguarded = 0; + this.tag = tag; + this.lastRevision = valueForTag(tag); + consumeTag(tag); } } @@ -151,7 +255,25 @@ 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); + 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() { @@ -161,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); @@ -170,7 +298,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); @@ -249,7 +377,7 @@ export class ListBlockOpcode extends BlockOpcode { } // Run now-updated updating opcodes - super.evaluate(vm); + vm.try(this.children, null, null); } private sync(iterator: OpaqueIterator) { @@ -426,12 +554,15 @@ export class ListBlockOpcode extends BlockOpcode { } } +const REARM_AFTER = 8; + class UpdatingVMFrame { private current = 0; constructor( private ops: UpdatingOpcode[], - private exceptionHandler: Nullable + private exceptionHandler: Nullable, + readonly block: Nullable ) {} goto(index: number) { 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/lib/tracking.ts b/packages/@glimmer/validator/lib/tracking.ts index d94ae456e6b..ac555a3e5ff 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 of this package. Each tag class declares `slot`: + * the index of the tag in the tracker that took it last. */ -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. + * + * 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; + + /** + * 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. + * + * 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. + */ 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; - combine(): Tag { - let { tags } = this; + if (tags[(tag as ConsumedTag).slot] === tag) return; - if (tags.size === 0) { - return CONSTANT_TAG; - } else if (tags.size === 1) { - return this.last as Tag; + (tag as ConsumedTag).slot = size; + tags[size] = tag; + this.size = size + 1; + } + + /** + * `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,39 @@ 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. + * + * An untrack frame takes a depth but no tracker, so the pool can have holes. + */ +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); } } -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 +161,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 +183,12 @@ export function resetTracking(): string | void { OPEN_TRACK_FRAMES.pop(); } + for (let tracker of TRACKER_POOL) { + if (tracker !== undefined) { + tracker.clear(); + } + } + CURRENT_TRACKER = null; if (DEBUG) { @@ -175,7 +263,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..5079368403b 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 index of this tag in the tracker that took it last. + */ + public slot = 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; + slot = 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; + slot = 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/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; diff --git a/packages/@glimmer/validator/test/tracking-test.ts b/packages/@glimmer/validator/test/tracking-test.ts index 1a0fc4c1dc0..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, @@ -10,6 +11,7 @@ import { getValue, isConst, isTracking, + resetTracking, track, trackedData, untrack, @@ -252,6 +254,141 @@ 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 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('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()); @@ -362,6 +499,79 @@ 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('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());