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('0 ');
+
+ for (let i = 1; i <= MANY; i++) {
+ this.rerender({ other: i });
+ }
+
+ this.rerender({ text: 'b' });
+ this.assertHTML(`${MANY} `);
+
+ for (let i = 1; i <= MANY; i++) {
+ this.rerender({ text: `c${i}` });
+ this.assertHTML(`${MANY} `);
+ }
+
+ this.rerender({ inner: false });
+ this.assertHTML(`
${MANY} `);
+
+ this.rerender({ inner: true, text: 'd' });
+ this.assertHTML(`${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) {