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/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..3ac41431d97 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.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) {