Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 152 additions & 14 deletions packages/@glimmer/validator/lib/tracking.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,34 +8,127 @@ import { unwrap } from './utils';
import { combine, CONSTANT_TAG, isConstTag, validateTag, valueForTag } from './validators';

/**
* An object that that tracks @tracked properties that were consumed.
* A tag with the `slot` field that `Tracker#add` reads and writes.
*
* Each tag class of this package declares the field,
* so a tag has it from its construction.
*/
class Tracker {
private tags = new Set<Tag>();
private last: Tag | null = null;
interface ConsumedTag extends Tag {
slot: number;
}

/**
* An object that tracks @tracked properties that were consumed.
*
* A tracker collects the tags that one tracking frame consumes.
* When the frame ends, `combine()` makes one tag from them.
*
* A tracker is not made for each frame.
* `beginTrackFrame` takes it from `TRACKER_POOL`, so the next frame
* at the same depth uses the same tracker again.
*/
class Tracker {
/**
* The tags that the current frame consumed.
* Each tag is at the position of its first consumption.
* `size` is the number of tags.
*
* tags: [ a, b, c, null, null ]
* size: 3
*
* The entries from `size` up are `null`.
* They are left from an earlier frame that consumed more tags.
*
* The array never shrinks.
* A write to `length` is a slow path in V8, and this code runs for every frame.
*/
private tags: (Tag | null)[] = [];
private size = 0;

/**
* Adds a tag to the frame, one time.
*
* A frame can consume one tag many times,
* so the tracker must find out if it has the tag already.
* It does that with no `Set`: the tag keeps the index
* at which a tracker put it last, in `tag.slot`.
*
* consume a tags: [ a ] a.slot = 0
* consume b tags: [ a, b ] b.slot = 1
* consume a tags[a.slot] is a, so the frame has it. No change.
*
* The check is `tags[tag.slot] === tag`.
* It compares the entry with the tag, so a `slot` that is out of date
* cannot hide a tag. The worst case is a tag that is in the array two times.
*
* That case needs a nested frame.
* The nested frame has its own tracker, and that tracker also writes `slot`:
*
* 1. outer frame consumes a, b outer tags: [ a, b ] b.slot = 1
* 2. inner frame consumes b inner tags: [ b ] b.slot = 0
* 3. outer frame consumes b outer tags[0] is a, not b.
* outer tags: [ a, b, b ] b.slot = 2
*
* The duplicate does no harm.
* The revision of a combined tag is the highest revision of its tags,
* and a tag that is there two times does not change the highest.
*
* If the inner frame puts the tag at the index that the outer frame used,
* the check of the outer frame still passes, and there is no duplicate.
*/
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;

if (tags[(tag as ConsumedTag).slot] === tag) return;

(tag as ConsumedTag).slot = size;
tags[size] = tag;
this.size = size + 1;
}

combine(): Tag {
let { tags } = this;
let { tags, size } = this;
let result: Tag;

if (tags.size === 0) {
return CONSTANT_TAG;
} else if (tags.size === 1) {
return this.last as Tag;
if (size === 0) {
result = CONSTANT_TAG;
} else if (size === 1) {
result = tags[0] as Tag;
} else {
return combine(Array.from(this.tags));
result = combine(tags.slice(0, size) as Tag[]);
}

this.clear();

return result;
}

/**
* Empties the tracker for the next frame at the same depth.
*
* before: tags: [ a, b, c ] size: 3
* after: tags: [ null, null, null ] size: 0
*
* The entries must be `null`, and not only ignored:
*
* - an entry that stays is a match for `tags[tag.slot] === tag`,
* so the next frame would not add that tag
* - an entry that stays keeps its tag alive after the frame
*/
clear(): void {
let { tags, size } = this;

for (let i = 0; i < size; i++) {
tags[i] = null;
}

this.size = 0;
}
}

Expand All @@ -56,10 +149,43 @@ let CURRENT_TRACKER: Tracker | null = null;

const OPEN_TRACK_FRAMES: (Tracker | null)[] = [];

/**
* The trackers, by the depth of the frame that uses them.
* The depth of a frame is the number of frames that are open around it.
*
* Frames are strictly nested: a frame ends before the frame around it ends.
* So two frames at the same depth are never open at the same time,
* and one tracker for each depth is enough.
*
* frame A depth 0 TRACKER_POOL[0]
* |- frame B depth 1 TRACKER_POOL[1]
* | `- frame C depth 2 TRACKER_POOL[2]
* `- frame D depth 1 TRACKER_POOL[1], which B used before
*
* A tracker is made the first time that a frame opens at its depth.
* After that, a frame at that depth allocates no tracker.
*
* An untrack frame takes a depth but no tracker, so the pool can have holes:
*
* untrack frame depth 0 no tracker
* `- frame E depth 1 TRACKER_POOL[1]
*
* TRACKER_POOL: [ <empty>, tracker ]
*/
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);
Expand Down Expand Up @@ -101,6 +227,18 @@ export function resetTracking(): string | void {
OPEN_TRACK_FRAMES.pop();
}

/**
* A frame that did not end left its tags in its tracker.
* The next frame at that depth must start with no tag.
*
* The pool can have holes, see `TRACKER_POOL`.
*/
for (let tracker of TRACKER_POOL) {
if (tracker !== undefined) {
tracker.clear();
}
}

CURRENT_TRACKER = null;

if (DEBUG) {
Expand Down
21 changes: 21 additions & 0 deletions packages/@glimmer/validator/lib/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,15 @@ class MonomorphicTagImpl<T extends MonomorphicTagId = MonomorphicTagId> {
public subtag: Tag | Tag[] | null = null;
private subtagBufferCache: Revision | null = null;

/**
* The index at which a tracker put this tag last.
* `Tracker#add` uses it to find out if a tracking frame has the tag already.
*
* The start value 0 is safe:
* the tracker compares its entry at that index with the tag.
*/
public slot = 0;

declare [TYPE]: T;

constructor(type: T) {
Expand Down Expand Up @@ -254,6 +263,12 @@ const VOLATILE_TAG_ID: IVOLATILE_TAG_ID = 100;

export class VolatileTag implements Tag {
readonly [TYPE] = VOLATILE_TAG_ID;

/**
* See `slot` of `MonomorphicTagImpl`.
*/
slot = 0;

[COMPUTE](): Revision {
return VOLATILE;
}
Expand All @@ -267,6 +282,12 @@ const CURRENT_TAG_ID: ICURRENT_TAG_ID = 101;

export class CurrentTag implements Tag {
readonly [TYPE] = CURRENT_TAG_ID;

/**
* See `slot` of `MonomorphicTagImpl`.
*/
slot = 0;

[COMPUTE](): Revision {
return $REVISION;
}
Expand Down
86 changes: 86 additions & 0 deletions packages/@glimmer/validator/test/tracking-test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DEBUG } from '@glimmer/env';
import {
beginTrackFrame,
beginUntrackFrame,
consumeTag,
createCache,
createTag,
Expand All @@ -10,6 +11,7 @@ import {
getValue,
isConst,
isTracking,
resetTracking,
track,
trackedData,
untrack,
Expand Down Expand Up @@ -252,6 +254,90 @@ 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 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());

Expand Down
Loading