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/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());