Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Autotrack, take 2 #115

Merged
merged 2 commits into from
Feb 22, 2018
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
3 changes: 2 additions & 1 deletion packages/@glimmer/component/src/component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { assert } from "@glimmer/util";
import { metaFor } from "./tracked";
import { CURRENT_TAG } from "@glimmer/reference";

export interface Bounds {
firstNode: Node;
Expand Down Expand Up @@ -266,7 +267,7 @@ class Component {

set args(args) {
this.__args__ = args;
metaFor(this).dirtyableTagFor("args").inner.dirty();
metaFor(this).updatableTagFor("args").inner.update(CURRENT_TAG);
}

/** @private
Expand Down
113 changes: 91 additions & 22 deletions packages/@glimmer/component/src/tracked.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
import { DEBUG } from "@glimmer/env";
import { Tag, DirtyableTag, TagWrapper, combine, CONSTANT_TAG } from "@glimmer/reference";
import { dict, Dict } from "@glimmer/util";
import { Tag, DirtyableTag, UpdatableTag, TagWrapper, combine, CONSTANT_TAG, CURRENT_TAG } from "@glimmer/reference";
import { dict, Dict, Option } from "@glimmer/util";

/**
* An object that that tracks @tracked properties that were consumed.
*/
class Tracker {
private tags = new Set<Tag>();

add(tag: Tag) {
this.tags.add(tag);
}

combine(): Tag {
let tags: Tag[] = [];
this.tags.forEach(tag => tags.push(tag));
return combine(tags);
}
}

/**
* @decorator
Expand Down Expand Up @@ -82,19 +99,62 @@ export function tracked(...dependencies: any[]): any {
}
}

/**
* Whenever a tracked computed property is entered, the current tracker is
* saved off and a new tracker is replaced.
*
* Any tracked properties consumed are added to the current tracker.
*
* When a tracked computed property is exited, the tracker's tags are
* combined and added to the parent tracker.
*
* The consequence is that each tracked computed property has a tag
* that corresponds to the tracked properties consumed inside of
* itself, including child tracked computed properties.
*/
let CURRENT_TRACKER: Option<Tracker> = null;

function descriptorForTrackedComputedProperty(target: any, key: any, descriptor: PropertyDescriptor, dependencies: string[]): PropertyDescriptor {
let meta = metaFor(target);
meta.trackedProperties[key] = true;
meta.trackedPropertyDependencies[key] = dependencies || [];

let get = descriptor.get as Function;
let set = descriptor.set as Function;

function getter(this: any) {
// Swap the parent tracker for a new tracker
let old = CURRENT_TRACKER;
let tracker = CURRENT_TRACKER = new Tracker();

// Call the getter
let ret = get.call(this);

// Swap back the parent tracker
CURRENT_TRACKER = old;

// Combine the tags in the new tracker and add them to the parent tracker
let tag = tracker.combine();
if (CURRENT_TRACKER) CURRENT_TRACKER.add(tag);

// Update the UpdatableTag for this property with the tag for all of the
// consumed dependencies.
metaFor(this).updatableTagFor(key).inner.update(tag);

return ret;
}

return {
enumerable: true,
configurable: false,
get: descriptor.get,
get: getter,
set: function() {
metaFor(this).dirtyableTagFor(key).inner.dirty();
descriptor.set.apply(this, arguments);
propertyDidChange();
// Bump the global revision counter
EPOCH.inner.dirty();

// Mark the UpdatableTag for this property with the current tag.
metaFor(this).updatableTagFor(key).inner.update(CURRENT_TAG);
set.apply(this, arguments);
}
};
}
Expand Down Expand Up @@ -125,11 +185,16 @@ function installTrackedProperty(target: any, key: Key) {
configurable: true,

get() {
if (CURRENT_TRACKER) CURRENT_TRACKER.add(metaFor(this).updatableTagFor(key));
return this[shadowKey];
},

set(newValue) {
metaFor(this).dirtyableTagFor(key).inner.dirty();
// Bump the global revision counter
EPOCH.inner.dirty();

// Mark the UpdatableTag for this property with the current tag.
metaFor(this).updatableTagFor(key).inner.update(CURRENT_TAG);
this[shadowKey] = newValue;
propertyDidChange();
}
Expand All @@ -152,13 +217,13 @@ function installTrackedProperty(target: any, key: Key) {
*/
export default class Meta {
tags: Dict<Tag>;
computedPropertyTags: Dict<TagWrapper<DirtyableTag>>;
computedPropertyTags: Dict<TagWrapper<UpdatableTag>>;
trackedProperties: Dict<boolean>;
trackedPropertyDependencies: Dict<string[]>;

constructor(parent: Meta) {
this.tags = dict<Tag>();
this.computedPropertyTags = dict<TagWrapper<DirtyableTag>>();
this.computedPropertyTags = dict<TagWrapper<UpdatableTag>>();
this.trackedProperties = parent ? Object.create(parent.trackedProperties) : dict<boolean>();
this.trackedPropertyDependencies = parent ? Object.create(parent.trackedPropertyDependencies) : dict<string[]>();
}
Expand All @@ -168,8 +233,8 @@ export default class Meta {
* by e.g. Glimmer VM to detect when a property should be re-rendered. Think
* of this as the "public-facing" tag.
*
* For static tracked properties, this is a single DirtyableTag. For computed
* properties, it is a combinator of the property's DirtyableTag as well as
* For static tracked properties, this is a single UpdatableTag. For computed
* properties, it is a combinator of the property's UpdatableTag as well as
* all of its dependencies' tags.
*/
tagFor(key: Key): Tag {
Expand All @@ -186,31 +251,31 @@ export default class Meta {

/**
* The tag used internally to invalidate when a tracked property is set. For
* static properties, this is the same DirtyableTag returned from `tagFor`.
* For computed properties, it is the DirtyableTag used as one of the tags in
* static properties, this is the same UpdatableTag returned from `tagFor`.
* For computed properties, it is the UpdatableTag used as one of the tags in
* the tag combinator of the CP and its dependencies.
*/
dirtyableTagFor(key: Key): TagWrapper<DirtyableTag> {
updatableTagFor(key: Key): TagWrapper<UpdatableTag> {
let dependencies = this.trackedPropertyDependencies[key];
let tag;

if (dependencies) {
// The key is for a computed property.
tag = this.computedPropertyTags[key];
if (tag) { return tag; }
return this.computedPropertyTags[key] = DirtyableTag.create();
return this.computedPropertyTags[key] = UpdatableTag.create(CURRENT_TAG);
} else {
// The key is for a static property.
tag = this.tags[key];
if (tag) { return tag as TagWrapper<DirtyableTag>; }
return this.tags[key] = DirtyableTag.create();
if (tag) { return tag as TagWrapper<UpdatableTag>; }
return this.tags[key] = UpdatableTag.create(CURRENT_TAG);
}
}
}

function combinatorForComputedProperties(meta: Meta, key: Key, dependencies: Key[] | void): Tag {
// Start off with the tag for the CP's own dirty state.
let tags: Tag[] = [meta.dirtyableTagFor(key)];
let tags: Tag[] = [meta.updatableTagFor(key)];

// Next, add in all of the tags for its dependencies.
if (dependencies && dependencies.length) {
Expand Down Expand Up @@ -243,6 +308,8 @@ function hasOwnProperty(obj: any, key: symbol) {
return hOP.call(obj, key);
}

const EPOCH = DirtyableTag.create();

let propertyDidChange = function() {};

export function setPropertyDidChange(cb: () => void) {
Expand Down Expand Up @@ -300,7 +367,7 @@ export function tagForProperty(obj: any, key: string, throwError: UntrackedPrope
*/
function installDevModeErrorInterceptor(obj: object, key: string, throwError: UntrackedPropertyErrorThrower) {
let target = obj;
let descriptor: PropertyDescriptor;
let descriptor: Option<PropertyDescriptor> = null;

// Find the descriptor for the current property. We may need to walk the
// prototype chain to do so. If the property is undefined, we may never get a
Expand All @@ -316,16 +383,18 @@ function installDevModeErrorInterceptor(obj: object, key: string, throwError: Un
// If possible, define a property descriptor that passes through the current
// value on reads but throws an exception on writes.
if (descriptor) {
let { get, value } = descriptor;

if (descriptor.configurable || !hasOwnDescriptor) {
Object.defineProperty(obj, key, {
configurable: descriptor.configurable,
enumerable: descriptor.enumerable,

get() {
if (descriptor.get) {
return descriptor.get.call(this);
if (get) {
return get.call(this);
} else {
return descriptor.value;
return value;
}
},

Expand Down
88 changes: 82 additions & 6 deletions packages/@glimmer/component/test/browser/tracked-property-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,15 +172,14 @@ test('can track a computed property', (assert) => {

test('tracked computed properties are invalidated when their dependencies are invalidated', (assert) => {
class TrackedPerson {
@tracked('fullName')
get salutation() {
@tracked get salutation() {
return `Hello, ${this.fullName}!`;
}

@tracked('firstName', 'lastName')
get fullName() {
@tracked get fullName() {
return `${this.firstName} ${this.lastName}`;
}

set fullName(fullName) {
let [firstName, lastName] = fullName.split(' ');
this.firstName = firstName;
Expand All @@ -192,8 +191,8 @@ test('tracked computed properties are invalidated when their dependencies are in
}

let obj = new TrackedPerson();
assert.strictEqual(obj.salutation, 'Hello, Tom Dale!');
assert.strictEqual(obj.fullName, 'Tom Dale');
assert.strictEqual(obj.salutation, 'Hello, Tom Dale!', `the saluation field is valid`);
assert.strictEqual(obj.fullName, 'Tom Dale', `the fullName field is valid`);

let tag = tagForProperty(obj, 'salutation');
let snapshot = tag.value();
Expand All @@ -219,6 +218,83 @@ test('tracked computed properties are invalidated when their dependencies are in
assert.strictEqual(tag.validate(snapshot), true);
});

test('nested @tracked in multiple objects', (assert) => {
class TrackedPerson {
@tracked get salutation() {
return `Hello, ${this.fullName}!`;
}

@tracked get fullName(): string {
return `${this.firstName} ${this.lastName}`;
}

set fullName(fullName: string) {
let [firstName, lastName] = fullName.split(' ');
this.firstName = firstName;
this.lastName = lastName;
}

toString() {
return this.fullName;
}

@tracked firstName = 'Tom';
@tracked lastName = 'Dale';
}

class TrackedContact {
@tracked email: string;
@tracked person: TrackedPerson;

constructor(person: TrackedPerson, email: string) {
this.person = person;
this.email = email;
}

@tracked get contact(): string {
return `${this.person} @ ${this.email}`;
}
}

let obj = new TrackedContact(new TrackedPerson(), '[email protected]');
assert.strictEqual(obj.contact, 'Tom Dale @ [email protected]', `the contact field is valid`);
assert.strictEqual(obj.person.fullName, 'Tom Dale', `the fullName field is valid`);
let person = obj.person;

let tag = tagForProperty(obj, 'contact');
let snapshot = tag.value();
assert.ok(tag.validate(snapshot), 'tag should be valid to start');

person.firstName = 'Edsger';
person.lastName = 'Dijkstra';
assert.strictEqual(tag.validate(snapshot), false, 'tag is invalidated after nested dependency is set');
assert.strictEqual(person.fullName, 'Edsger Dijkstra');
assert.strictEqual(obj.contact, 'Edsger Dijkstra @ [email protected]');

snapshot = tag.value();
assert.strictEqual(tag.validate(snapshot), true);

person.fullName = 'Alan Kay';
assert.strictEqual(tag.validate(snapshot), false, 'tag is invalidated after chained dependency is set');
assert.strictEqual(person.fullName, 'Alan Kay');
assert.strictEqual(person.firstName, 'Alan');
assert.strictEqual(person.lastName, 'Kay');
assert.strictEqual(obj.contact, 'Alan Kay @ [email protected]');

snapshot = tag.value();
assert.strictEqual(tag.validate(snapshot), true);

obj.email = "[email protected]";
assert.strictEqual(tag.validate(snapshot), false, 'tag is invalidated after chained dependency is set');
assert.strictEqual(person.fullName, 'Alan Kay');
assert.strictEqual(person.firstName, 'Alan');
assert.strictEqual(person.lastName, 'Kay');
assert.strictEqual(obj.contact, 'Alan Kay @ [email protected]');

snapshot = tag.value();
assert.strictEqual(tag.validate(snapshot), true);
});

module('[@glimmer/component] Tracked Property Warning in Development Mode');

if (DEBUG) {
Expand Down