diff --git a/features.json b/features.json index 215861fb5f1..96fe7cfec06 100644 --- a/features.json +++ b/features.json @@ -9,7 +9,8 @@ "ember-engines-mount-params": true, "ember-module-unification": null, "glimmer-custom-component-manager": null, - "ember-template-block-let-helper": null + "ember-template-block-let-helper": null, + "ember-metal-tracked-properties": null }, "deprecations": { "container-lookupFactory": "2.12.0", diff --git a/packages/ember-glimmer/tests/integration/application/engine-test.js b/packages/ember-glimmer/tests/integration/application/engine-test.js index 1bfbcd3aa73..4df8805363a 100644 --- a/packages/ember-glimmer/tests/integration/application/engine-test.js +++ b/packages/ember-glimmer/tests/integration/application/engine-test.js @@ -717,8 +717,8 @@ moduleFor('Application test: engine rendering', class extends ApplicationTest { let href1337 = this.element.querySelector('.author-1337').href; // check if link ends with the suffix - assert.ok(this.stringsEndWith(href1, suffix1)); - assert.ok(this.stringsEndWith(href1337, suffix1337)); + assert.ok(this.stringsEndWith(href1, suffix1), `${href1} ends with ${suffix1}`); + assert.ok(this.stringsEndWith(href1337, suffix1337), `${href1337} ends with ${suffix1337}`); }); } diff --git a/packages/ember-metal/externs.d.ts b/packages/ember-metal/externs.d.ts new file mode 100644 index 00000000000..f6f86f405c0 --- /dev/null +++ b/packages/ember-metal/externs.d.ts @@ -0,0 +1,12 @@ +declare module 'ember/features' { + export const EMBER_TEMPLATE_BLOCK_LET_HELPER: boolean | null; + export const EMBER_MODULE_UNIFICATION: boolean | null; + export const GLIMMER_CUSTOM_COMPONENT_MANAGER: boolean | null; + export const EMBER_ENGINES_MOUNT_PARAMS: boolean | null; + export const EMBER_GLIMMER_DETECT_BACKTRACKING_RERENDER: boolean | null; + export const MANDATORY_SETTER: boolean | null; +} + +declare module 'ember-env-flags' { + export const DEBUG: boolean; +} diff --git a/packages/ember-metal/lib/computed.js b/packages/ember-metal/lib/computed.js index ac0e9a5ef77..42c3b783c9e 100644 --- a/packages/ember-metal/lib/computed.js +++ b/packages/ember-metal/lib/computed.js @@ -2,6 +2,7 @@ import { inspect } from 'ember-utils'; import { assert, warn, Error as EmberError } from 'ember-debug'; import { set } from './property_set'; import { meta as metaFor, peekMeta } from './meta'; +import { EMBER_METAL_TRACKED_PROPERTIES } from 'ember/features'; import expandProperties from './expand_properties'; import { Descriptor, @@ -14,6 +15,8 @@ import { addDependentKeys, removeDependentKeys } from './dependent_keys'; +import { getCurrentTracker, setCurrentTracker } from './tracked'; +import { tagForProperty, update } from './tags'; /** @module @ember/object @@ -150,6 +153,10 @@ class ComputedProperty extends Descriptor { this._meta = undefined; this._volatile = false; + if (EMBER_METAL_TRACKED_PROPERTIES) { + this._auto = false; + } + this._dependentKeys = opts && opts.dependentKeys; this._readOnly = opts && hasGetterOnly && opts.readOnly === true; } @@ -328,13 +335,48 @@ class ComputedProperty extends Descriptor { } let cache = getCacheFor(obj); + let propertyTag; + + if (EMBER_METAL_TRACKED_PROPERTIES) { + propertyTag = tagForProperty(obj, keyName); + + if (cache.has(keyName)) { + // special-case for computed with no dependent keys used to + // trigger cacheable behavior. + if (!this._auto && (!this._dependentKeys || this._dependentKeys.length === 0)) { + return cache.get(keyName); + } + + let lastRevision = getLastRevisionFor(obj, keyName); + if (propertyTag.validate(lastRevision)) { + return cache.get(keyName); + } + } + } else { + if (cache.has(keyName)) { + return cache.get(keyName); + } + } + + let parent; + let tracker; - if (cache.has(keyName)) { - return cache.get(keyName); + if (EMBER_METAL_TRACKED_PROPERTIES) { + parent = getCurrentTracker(); + tracker = setCurrentTracker(); } let ret = this._getter.call(obj, keyName); + if (EMBER_METAL_TRACKED_PROPERTIES) { + setCurrentTracker(parent); + let tag = tracker.combine(); + if (parent) parent.add(tag); + + update(propertyTag, tag); + setLastRevisionFor(obj, keyName, propertyTag.value()); + } + cache.set(keyName, ret); let meta = metaFor(obj); @@ -409,6 +451,11 @@ class ComputedProperty extends Descriptor { notifyPropertyChange(obj, keyName, meta); + if (EMBER_METAL_TRACKED_PROPERTIES) { + let propertyTag = tagForProperty(obj, keyName); + setLastRevisionFor(obj, keyName, propertyTag.value()); + } + return ret; } @@ -423,6 +470,14 @@ class ComputedProperty extends Descriptor { } } } + +if (EMBER_METAL_TRACKED_PROPERTIES) { + ComputedProperty.prototype.auto = function() { + this._auto = true; + return this; + }; +} + /** This helper returns a new property descriptor that wraps the passed computed property function. You can use this helper to define properties @@ -524,6 +579,7 @@ export default function computed(...args) { } const COMPUTED_PROPERTY_CACHED_VALUES = new WeakMap(); +const COMPUTED_PROPERTY_LAST_REVISION = EMBER_METAL_TRACKED_PROPERTIES ? new WeakMap() : undefined; /** Returns the cached value for a property, if one exists. @@ -544,6 +600,11 @@ export function getCacheFor(obj) { let cache = COMPUTED_PROPERTY_CACHED_VALUES.get(obj); if (cache === undefined) { cache = new Map(); + + if (EMBER_METAL_TRACKED_PROPERTIES) { + COMPUTED_PROPERTY_LAST_REVISION.set(obj, new Map()); + } + COMPUTED_PROPERTY_CACHED_VALUES.set(obj, cache); } return cache; @@ -556,6 +617,25 @@ export function getCachedValueFor(obj, key) { } } +export let setLastRevisionFor; +export let getLastRevisionFor; + +if (EMBER_METAL_TRACKED_PROPERTIES) { + setLastRevisionFor = (obj, key, revision) => { + let lastRevision = COMPUTED_PROPERTY_LAST_REVISION.get(obj); + lastRevision.set(key, revision); + }; + + getLastRevisionFor = (obj, key) => { + let cache = COMPUTED_PROPERTY_LAST_REVISION.get(obj); + if (cache == undefined) { + return 0; + } else { + return cache.get(key); + } + }; +} + export function peekCacheFor(obj) { return COMPUTED_PROPERTY_CACHED_VALUES.get(obj); } diff --git a/packages/ember-metal/lib/index.js b/packages/ember-metal/lib/index.js index d5e1d0842ac..bce88e8255c 100644 --- a/packages/ember-metal/lib/index.js +++ b/packages/ember-metal/lib/index.js @@ -135,3 +135,4 @@ export { setProxy } from './is_proxy'; export { default as descriptor } from './descriptor'; +export { tracked } from './tracked'; diff --git a/packages/ember-metal/lib/property_get.js b/packages/ember-metal/lib/property_get.js index 393c28462f5..ea2d4d7a03c 100644 --- a/packages/ember-metal/lib/property_get.js +++ b/packages/ember-metal/lib/property_get.js @@ -4,9 +4,11 @@ import { assert, deprecate } from 'ember-debug'; import { HAS_NATIVE_PROXY, symbol } from 'ember-utils'; -import { DESCRIPTOR_TRAP, EMBER_METAL_ES5_GETTERS, MANDATORY_GETTER } from 'ember/features'; +import { DESCRIPTOR_TRAP, EMBER_METAL_ES5_GETTERS, EMBER_METAL_TRACKED_PROPERTIES, MANDATORY_GETTER } from 'ember/features'; import { isPath } from './path_cache'; import { isDescriptor, isDescriptorTrap, DESCRIPTOR, descriptorFor } from './meta'; +import { getCurrentTracker } from './tracked'; +import { tagForProperty } from './tags'; const ALLOWABLE_TYPES = { object: true, @@ -86,6 +88,11 @@ export function get(obj, keyName) { let value; if (isObjectLike) { + if (EMBER_METAL_TRACKED_PROPERTIES) { + let tracker = getCurrentTracker(); + if (tracker) tracker.add(tagForProperty(obj, keyName)); + } + if (EMBER_METAL_ES5_GETTERS) { descriptor = descriptorFor(obj, keyName); } diff --git a/packages/ember-metal/lib/tags.js b/packages/ember-metal/lib/tags.js index a888509f5e8..94f783b77e9 100644 --- a/packages/ember-metal/lib/tags.js +++ b/packages/ember-metal/lib/tags.js @@ -1,4 +1,5 @@ -import { CONSTANT_TAG, DirtyableTag } from '@glimmer/reference'; +import { CONSTANT_TAG, UpdatableTag, DirtyableTag, combine } from '@glimmer/reference'; +import { EMBER_METAL_TRACKED_PROPERTIES } from 'ember/features'; import { meta as metaFor } from './meta'; import { isProxy } from './is_proxy'; import run from './run_loop'; @@ -13,6 +14,8 @@ function makeTag() { return DirtyableTag.create(); } +export const TRACKED_GETTERS = EMBER_METAL_TRACKED_PROPERTIES ? new WeakMap() : undefined; + export function tagForProperty(object, propertyKey, _meta) { if (typeof object !== 'object' || object === null) { return CONSTANT_TAG; } @@ -25,7 +28,12 @@ export function tagForProperty(object, propertyKey, _meta) { let tag = tags[propertyKey]; if (tag) { return tag; } - return tags[propertyKey] = makeTag(); + if (EMBER_METAL_TRACKED_PROPERTIES) { + let pair = combine([makeTag(), UpdatableTag.create(CONSTANT_TAG)]); + return tags[propertyKey] = pair; + } else { + return tags[propertyKey] = makeTag(); + } } export function tagFor(object, _meta) { @@ -37,6 +45,23 @@ export function tagFor(object, _meta) { } } +export let dirty; +export let update; + +if (EMBER_METAL_TRACKED_PROPERTIES) { + dirty = (tag) => { + tag.inner.first.inner.dirty(); + }; + + update = (outer, inner) => { + outer.inner.second.inner.update(inner); + }; +} else { + dirty = (tag) => { + tag.inner.dirty(); + }; +} + export function markObjectAsDirty(obj, propertyKey, meta) { let objectTag = meta.readableTag(); @@ -52,7 +77,7 @@ export function markObjectAsDirty(obj, propertyKey, meta) { let propertyTag = tags !== undefined ? tags[propertyKey] : undefined; if (propertyTag !== undefined) { - propertyTag.inner.dirty(); + dirty(propertyTag); } if (objectTag !== undefined || propertyTag !== undefined) { diff --git a/packages/ember-metal/lib/tracked.js b/packages/ember-metal/lib/tracked.js new file mode 100644 index 00000000000..051942fba81 --- /dev/null +++ b/packages/ember-metal/lib/tracked.js @@ -0,0 +1,202 @@ +import { combine, CONSTANT_TAG } from '@glimmer/reference'; +import { tagFor, tagForProperty, dirty, update } from './tags'; +/** + An object that that tracks @tracked properties that were consumed. + + @private + */ +class Tracker { + constructor() { + this.tags = new Set(); + this.last = null; + } + add(tag) { + this.tags.add(tag); + this.last = tag; + } + get size() { + return this.tags.size; + } + combine() { + if (this.tags.size === 0) { + return CONSTANT_TAG; + } + else if (this.tags.size === 1) { + return this.last; + } + else { + let tags = []; + this.tags.forEach(tag => tags.push(tag)); + return combine(tags); + } + } +} +/** + @decorator + @private + + Marks a property as tracked. + + By default, a component's properties are expected to be static, + meaning you are not able to update them and have the template update accordingly. + Marking a property as tracked means that when that property changes, + a rerender of the component is scheduled so the template is kept up to date. + + There are two usages for the `@tracked` decorator, shown below. + + @example No dependencies + + If you don't pass an argument to `@tracked`, only changes to that property + will be tracked: + + ```typescript + import Component, { tracked } from '@glimmer/component'; + + export default class MyComponent extends Component { + @tracked + remainingApples = 10 + } + ``` + + When something changes the component's `remainingApples` property, the rerender + will be scheduled. + + @example Dependents + + In the case that you have a computed property that depends other + properties, you want to track both so that when one of the + dependents change, a rerender is scheduled. + + In the following example we have two properties, + `eatenApples`, and `remainingApples`. + + ```typescript + import Component, { tracked } from '@glimmer/component'; + + const totalApples = 100; + + export default class MyComponent extends Component { + @tracked + eatenApples = 0 + + @tracked('eatenApples') + get remainingApples() { + return totalApples - this.eatenApples; + } + + increment() { + this.eatenApples = this.eatenApples + 1; + } + } + ``` + + @param dependencies Optional dependents to be tracked. + */ +export function tracked(target, key, descriptor) { + if ('value' in descriptor) { + return descriptorForDataProperty(key, descriptor); + } + else { + return descriptorForAccessor(key, descriptor); + } +} +/** + @private + + 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 = null; +export function getCurrentTracker() { + return CURRENT_TRACKER; +} +export function setCurrentTracker(tracker = new Tracker()) { + return CURRENT_TRACKER = tracker; +} +function descriptorForAccessor(key, descriptor) { + let get = descriptor.get; + let set = descriptor.set; + function getter() { + // 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. + update(tagForProperty(this, key), tag); + return ret; + } + function setter() { + // Mark the UpdatableTag for this property with the current tag. + dirty(tagForProperty(this, key)); + set.apply(this, arguments); + } + return { + enumerable: true, + configurable: false, + get: get && getter, + set: set && setter + }; +} +/** + @private + + A getter/setter for change tracking for a particular key. The accessor + acts just like a normal property, but it triggers the `propertyDidChange` + hook when written to. + + Values are saved on the object using a "shadow key," or a symbol based on the + tracked property name. Sets write the value to the shadow key, and gets read + from it. + */ +function descriptorForDataProperty(key, descriptor) { + let shadowKey = Symbol(key); + return { + enumerable: true, + configurable: true, + get() { + if (CURRENT_TRACKER) + CURRENT_TRACKER.add(tagForProperty(this, key)); + if (!(shadowKey in this)) { + this[shadowKey] = descriptor.value; + } + return this[shadowKey]; + }, + set(newValue) { + tagFor(this).inner.dirty(); + dirty(tagForProperty(this, key)); + this[shadowKey] = newValue; + propertyDidChange(); + } + }; +} +let propertyDidChange = function () { }; +export function setPropertyDidChange(cb) { + propertyDidChange = cb; +} +export class UntrackedPropertyError extends Error { + constructor(target, key, message) { + super(message); + this.target = target; + this.key = key; + } + static for(obj, key) { + return new UntrackedPropertyError(obj, key, `The property '${key}' on ${obj} was changed after being rendered. If you want to change a property used in a template after the component has rendered, mark the property as a tracked property with the @tracked decorator.`); + } +} diff --git a/packages/ember-metal/lib/tracked.ts b/packages/ember-metal/lib/tracked.ts new file mode 100644 index 00000000000..37e40789f80 --- /dev/null +++ b/packages/ember-metal/lib/tracked.ts @@ -0,0 +1,246 @@ +import { combine, CONSTANT_TAG, CURRENT_TAG, DirtyableTag, Tag, TagWrapper, UpdatableTag } from '@glimmer/reference'; + +import { + MANDATORY_SETTER +} from 'ember/features'; +import { meta as metaFor } from './meta'; +import { dirty, markObjectAsDirty, tagFor, tagForProperty, TRACKED_GETTERS, update } from './tags'; + +type Option = T | null; +type unknown = null | undefined | void | {}; + +interface Dict { + [key: string]: T; +} + +/** + An object that that tracks @tracked properties that were consumed. + + @private + */ +class Tracker { + private tags = new Set(); + private last: Option = null; + + add(tag: Tag) { + this.tags.add(tag); + this.last = tag; + } + + get size() { + return this.tags.size; + } + + combine(): Tag { + if (this.tags.size === 0) { + return CONSTANT_TAG; + } else if (this.tags.size === 1) { + return this.last; + } else { + let tags: Tag[] = []; + this.tags.forEach(tag => tags.push(tag)); + return combine(tags); + } + } +} + +/** + @decorator + @private + + Marks a property as tracked. + + By default, a component's properties are expected to be static, + meaning you are not able to update them and have the template update accordingly. + Marking a property as tracked means that when that property changes, + a rerender of the component is scheduled so the template is kept up to date. + + There are two usages for the `@tracked` decorator, shown below. + + @example No dependencies + + If you don't pass an argument to `@tracked`, only changes to that property + will be tracked: + + ```typescript + import Component, { tracked } from '@glimmer/component'; + + export default class MyComponent extends Component { + @tracked + remainingApples = 10 + } + ``` + + When something changes the component's `remainingApples` property, the rerender + will be scheduled. + + @example Dependents + + In the case that you have a computed property that depends other + properties, you want to track both so that when one of the + dependents change, a rerender is scheduled. + + In the following example we have two properties, + `eatenApples`, and `remainingApples`. + + ```typescript + import Component, { tracked } from '@glimmer/component'; + + const totalApples = 100; + + export default class MyComponent extends Component { + @tracked + eatenApples = 0 + + @tracked('eatenApples') + get remainingApples() { + return totalApples - this.eatenApples; + } + + increment() { + this.eatenApples = this.eatenApples + 1; + } + } + ``` + + @param dependencies Optional dependents to be tracked. + */ +export function tracked(target: object, key: string | symbol, descriptor: PropertyDescriptor): PropertyDescriptor { + if ('value' in descriptor) { + return descriptorForDataProperty(key, descriptor); + } else { + return descriptorForAccessor(key, descriptor); + } +} + +/** + @private + + 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 = null; + +export function getCurrentTracker(): Option { + return CURRENT_TRACKER; +} + +export function setCurrentTracker(tracker: Tracker = new Tracker()): Tracker { + return CURRENT_TRACKER = tracker; +} + +function descriptorForAccessor(key: string | symbol, descriptor: PropertyDescriptor): PropertyDescriptor { + 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. + update(tagForProperty(this, key), tag); + + return ret; + } + + function setter(this: unknown) { + dirty(tagForProperty(this, key)); + set.apply(this, arguments); + } + + return { + enumerable: true, + configurable: false, + get: get && getter, + set: set && setter + }; +} + +export type Key = string; + +/** + @private + + A getter/setter for change tracking for a particular key. The accessor + acts just like a normal property, but it triggers the `propertyDidChange` + hook when written to. + + Values are saved on the object using a "shadow key," or a symbol based on the + tracked property name. Sets write the value to the shadow key, and gets read + from it. + */ + +function descriptorForDataProperty(key, descriptor) { + let shadowKey = Symbol(key); + + return { + enumerable: true, + configurable: true, + + get() { + if (CURRENT_TRACKER) CURRENT_TRACKER.add(tagForProperty(this, key)); + + if (!(shadowKey in this)) { + this[shadowKey] = descriptor.value; + } + + return this[shadowKey]; + }, + + set(newValue) { + tagFor(this).inner.dirty(); + dirty(tagForProperty(this, key)); + this[shadowKey] = newValue; + propertyDidChange(); + } + }; +} + +export interface Interceptors { + [key: string]: boolean; +} + +let propertyDidChange = function() {}; + +export function setPropertyDidChange(cb: () => void) { + propertyDidChange = cb; +} + +export class UntrackedPropertyError extends Error { + static for(obj: any, key: string): UntrackedPropertyError { + return new UntrackedPropertyError(obj, key, `The property '${key}' on ${obj} was changed after being rendered. If you want to change a property used in a template after the component has rendered, mark the property as a tracked property with the @tracked decorator.`); + } + + constructor(public target: any, public key: string, message: string) { + super(message); + } +} + +/** + * Function that can be used in development mode to generate more meaningful + * error messages. + */ +export interface UntrackedPropertyErrorThrower { + (obj: any, key: string): void; +} diff --git a/packages/ember-metal/tests/tracked/computed_test.js b/packages/ember-metal/tests/tracked/computed_test.js new file mode 100644 index 00000000000..def110d0f7d --- /dev/null +++ b/packages/ember-metal/tests/tracked/computed_test.js @@ -0,0 +1,68 @@ +import { createWithDescriptors } from './support'; +import { get, set, tracked } from '../..'; + +import { EMBER_METAL_TRACKED_PROPERTIES } from 'ember/features'; + +if (EMBER_METAL_TRACKED_PROPERTIES) { + + QUnit.module('tracked getters'); + + QUnit.test('works without get', assert => { + let count = 0; + + class Count { + get foo() { + count++; + return `computed foo`; + } + } + + tracked(Count.prototype, 'foo', Object.getOwnPropertyDescriptor(Count.prototype, 'foo')); + + let obj = new Count(); + + assert.equal(obj.foo, 'computed foo', 'should return value'); + assert.equal(count, 1, 'should have invoked computed property'); + }); + + + QUnit.test('defining computed property should invoke property on get', function(assert) { + let count = 0; + + class Count { + get foo() { + count++; + return `computed foo`; + } + } + + tracked(Count.prototype, 'foo', Object.getOwnPropertyDescriptor(Count.prototype, 'foo')); + + let obj = new Count(); + + assert.equal(get(obj, 'foo'), 'computed foo', 'should return value'); + assert.equal(count, 1, 'should have invoked computed property'); + }); + + + QUnit.test('defining computed property should invoke property on set', function(assert) { + let count = 0; + + let obj = createWithDescriptors({ + get foo() { + return this.__foo; + }, + + set foo(value) { + count++; + this.__foo = `computed ${value}`; + } + }); + + + assert.equal(set(obj, 'foo', 'bar'), 'bar', 'should return set value'); + assert.equal(count, 1, 'should have invoked computed property'); + assert.equal(get(obj, 'foo'), 'computed bar', 'should return new value'); + }); + +} diff --git a/packages/ember-metal/tests/tracked/get_test.js b/packages/ember-metal/tests/tracked/get_test.js new file mode 100644 index 00000000000..d5bccbb29bd --- /dev/null +++ b/packages/ember-metal/tests/tracked/get_test.js @@ -0,0 +1,78 @@ +import { + get, + getWithDefault, + tracked +} from '../..'; + +import { createTracked } from './support'; + +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; + +import { EMBER_METAL_TRACKED_PROPERTIES } from 'ember/features'; + +if (EMBER_METAL_TRACKED_PROPERTIES) { + + moduleFor('tracked get', class extends AbstractTestCase { + ['@test should get arbitrary properties on an object'](assert) { + let obj = createTracked({ + string: 'string', + number: 23, + boolTrue: true, + boolFalse: false, + nullValue: null + }); + + for (let key in obj) { + assert.equal(get(obj, key), obj[key], key); + } + } + + ['@test should retrieve a number key on an object'](assert) { + let obj = createTracked({ 1: 'first' }); + + assert.equal(get(obj, 1), 'first'); + } + + ['@test should not access a property more than once'](assert) { + let count = 20; + + class Count { + get id() { + return ++count; + } + } + + tracked(Count.prototype, 'id', Object.getOwnPropertyDescriptor(Count.prototype, 'id')); + + let obj = new Count(); + + get(obj, 'id'); + + assert.equal(count, 21); + } + }); + + moduleFor('tracked getWithDefault', class extends AbstractTestCase { + ['@test should get arbitrary properties on an object'](assert) { + let obj = createTracked({ + string: 'string', + number: 23, + boolTrue: true, + boolFalse: false, + nullValue: null + }); + + for (let key in obj) { + assert.equal(getWithDefault(obj, key, 'fail'), obj[key], key); + } + + obj = createTracked({ + undef: undefined + }); + + assert.equal(getWithDefault(obj, 'undef', 'default'), 'default', 'explicit undefined retrieves the default'); + assert.equal(getWithDefault(obj, 'not-present', 'default'), 'default', 'non-present key retrieves the default'); + } + }); + +} diff --git a/packages/ember-metal/tests/tracked/set_test.js b/packages/ember-metal/tests/tracked/set_test.js new file mode 100644 index 00000000000..79f312c8f62 --- /dev/null +++ b/packages/ember-metal/tests/tracked/set_test.js @@ -0,0 +1,47 @@ +import { + get, + set, + setHasViews +} from '../..'; +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; + +import { createTracked } from './support'; + +import { EMBER_METAL_TRACKED_PROPERTIES } from 'ember/features'; + +if (EMBER_METAL_TRACKED_PROPERTIES) { + + moduleFor('tracked set', class extends AbstractTestCase { + teardown() { + setHasViews(() => false); + } + + ['@test should set arbitrary properties on an object'](assert) { + let obj = createTracked({ + string: 'string', + number: 23, + boolTrue: true, + boolFalse: false, + nullValue: null, + undefinedValue: undefined + }); + + let newObj = createTracked({ + undefinedValue: 'emberjs' + }); + + for (let key in obj) { + assert.equal(set(newObj, key, obj[key]), obj[key], 'should return value'); + assert.equal(get(newObj, key), obj[key], 'should set value'); + } + } + + ['@test should set a number key on an object'](assert) { + let obj = createTracked({ 1: 'original' }); + + set(obj, 1, 'first'); + assert.equal(obj[1], 'first'); + } + }); + +} diff --git a/packages/ember-metal/tests/tracked/support.js b/packages/ember-metal/tests/tracked/support.js new file mode 100644 index 00000000000..30221acd21a --- /dev/null +++ b/packages/ember-metal/tests/tracked/support.js @@ -0,0 +1,30 @@ +import { + tracked +} from '../..'; + +export function createTracked(values, proto = {}) { + function Class() { + for (let prop in values) { + this[prop] = values[prop]; + } + } + + for (let prop in values) { + Object.defineProperty(proto, prop, tracked(proto, prop, { enumerable: true, configurable: true, writable: true, value: values[prop] })); + } + + Class.prototype = proto; + + return new Class(); +} + +export function createWithDescriptors(values) { + function Class() {} + + for (let prop in values) { + let descriptor = Object.getOwnPropertyDescriptor(values, prop); + Object.defineProperty(Class.prototype, prop, tracked(Class.prototype, prop, descriptor)); + } + + return new Class(); +} diff --git a/packages/ember-metal/tests/tracked/validation_test.js b/packages/ember-metal/tests/tracked/validation_test.js new file mode 100644 index 00000000000..153d0f3a1a4 --- /dev/null +++ b/packages/ember-metal/tests/tracked/validation_test.js @@ -0,0 +1,214 @@ +import { + computed, + defineProperty, + get, + set, + tracked +} from '../..'; + +import { moduleFor, AbstractTestCase } from 'internal-test-helpers'; +import { tagForProperty } from '../..'; + +import { EMBER_METAL_TRACKED_PROPERTIES } from 'ember/features'; + +if (EMBER_METAL_TRACKED_PROPERTIES) { + + moduleFor('tracked get validation', class extends AbstractTestCase { + [`@test validators for tracked getters with dependencies should invalidate when the dependencies invalidate`](assert) { + class Tracked { + constructor(first, last) { + this.first = first; + this.last = last; + } + } + + track(Tracked, ['first', 'last'], { + get full() { + return `${this.first} ${this.last}`; + } + }); + + let obj = new Tracked('Tom', 'Dale'); + + let tag = tagForProperty(obj, 'full'); + let snapshot = tag.value(); + + let full = obj.full; + assert.equal(full, 'Tom Dale', 'The full name starts correct'); + assert.equal(tag.validate(snapshot), true); + + snapshot = tag.value(); + assert.equal(tag.validate(snapshot), true); + + obj.first = 'Thomas'; + assert.equal(tag.validate(snapshot), false); + + assert.equal(obj.full, 'Thomas Dale'); + snapshot = tag.value(); + + assert.equal(tag.validate(snapshot), true); + } + + [`@test interaction with Ember object model (tracked property depending on Ember property)`](assert) { + class Tracked { + constructor(name) { + this.name = name; + } + } + + track(Tracked, ['name'], { + get full() { + return `${get(this.name, 'first')} ${get(this.name, 'last')}`; + } + }); + + let tom = { first: 'Tom', last: 'Dale' }; + + let obj = new Tracked(tom); + + let tag = tagForProperty(obj, 'full'); + let snapshot = tag.value(); + + let full = obj.full; + assert.equal(full, 'Tom Dale'); + assert.equal(tag.validate(snapshot), true); + + snapshot = tag.value(); + assert.equal(tag.validate(snapshot), true); + + set(tom, 'first', 'Thomas'); + assert.equal(tag.validate(snapshot), false, 'invalid after setting with Ember set'); + + assert.equal(obj.full, 'Thomas Dale'); + snapshot = tag.value(); + + assert.equal(tag.validate(snapshot), true); + } + + [`@test interaction with Ember object model (Ember computed property depending on tracked property)`](assert) { + class EmberObject { + constructor(name) { + this.name = name; + } + } + + defineProperty(EmberObject.prototype, 'full', computed('name', function() { + let name = get(this, 'name'); + return `${name.first} ${name.last}`; + })); + + class Name { + constructor(first, last) { + this.first = first; + this.last = last; + } + } + + track(Name, ['first', 'last']); + + let tom = new Name('Tom', 'Dale'); + let obj = new EmberObject(tom); + + let tag = tagForProperty(obj, 'full'); + let snapshot = tag.value(); + + let full = get(obj, 'full'); + assert.equal(full, 'Tom Dale'); + assert.equal(tag.validate(snapshot), true); + + snapshot = tag.value(); + assert.equal(tag.validate(snapshot), true); + + tom.first = 'Thomas'; + assert.equal(tag.validate(snapshot), false, 'invalid after setting with tracked properties'); + + assert.equal(get(obj, 'full'), 'Thomas Dale'); + snapshot = tag.value(); + + // assert.equal(tag.validate(snapshot), true); + } + + ['@test interaction with the Ember object model (paths going through tracked properties)'](assert) { + class EmberObject { + constructor(contact) { + this.contact = contact; + } + } + + defineProperty(EmberObject.prototype, 'full', computed('contact.name.first', 'contact.name.last', function() { + let contact = get(this, 'contact'); + return `${get(contact.name, 'first')} ${get(contact.name, 'last')}`; + })); + + class Contact { + constructor(name) { + this.name = name; + } + } + + track(Contact, ['name']); + + class EmberName { + constructor(first, last) { + this.first = first; + this.last = last; + } + } + + let tom = new EmberName('Tom', 'Dale'); + let contact = new Contact(tom); + let obj = new EmberObject(contact); + + let tag = tagForProperty(obj, 'full'); + let snapshot = tag.value(); + + let full = get(obj, 'full'); + assert.equal(full, 'Tom Dale'); + assert.equal(tag.validate(snapshot), true); + + snapshot = tag.value(); + assert.equal(tag.validate(snapshot), true); + + set(tom, 'first', 'Thomas'); + assert.equal(tag.validate(snapshot), false, 'invalid after setting with Ember.set'); + + assert.equal(get(obj, 'full'), 'Thomas Dale'); + snapshot = tag.value(); + + tom = contact.name = new EmberName('T', 'Dale'); + assert.equal(tag.validate(snapshot), false, 'invalid after setting with Ember.set'); + + assert.equal(get(obj, 'full'), 'T Dale'); + snapshot = tag.value(); + + set(tom, 'first', 'Tizzle'); + assert.equal(tag.validate(snapshot), false, 'invalid after setting with Ember.set'); + + assert.equal(get(obj, 'full'), 'Tizzle Dale'); + } + }); + +} + +function track(Class, properties, accessors = {}) { + let proto = Class.prototype; + + properties.forEach(prop => defineData(proto, prop)); + + let keys = Object.getOwnPropertyNames(accessors); + + keys.forEach(key => defineAccessor(proto, key, Object.getOwnPropertyDescriptor(accessors, key))); +} + +function defineData(prototype, property) { + Object.defineProperty(prototype, property, tracked(prototype, property, { + enumerable: true, + configurable: true, + writable: true, + value: undefined + })); +} + +function defineAccessor(prototype, property, descriptor) { + Object.defineProperty(prototype, property, tracked(prototype, property, descriptor)); +} diff --git a/packages/ember-runtime/tests/computed/reduce_computed_macros_test.js b/packages/ember-runtime/tests/computed/reduce_computed_macros_test.js index c77a9acf5c1..5696410fb9c 100644 --- a/packages/ember-runtime/tests/computed/reduce_computed_macros_test.js +++ b/packages/ember-runtime/tests/computed/reduce_computed_macros_test.js @@ -29,6 +29,7 @@ import { } from '../../computed/reduce_computed_macros'; import { isArray } from '../../utils'; import { A as emberA, removeAt } from '../../mixins/array'; +import { EMBER_METAL_TRACKED_PROPERTIES } from 'ember/features'; let obj; QUnit.module('map', { @@ -1299,19 +1300,25 @@ QUnit.test('changing item properties specified via @each triggers a resort of th assert.deepEqual(obj.get('sortedItems').mapBy('fname'), ['Jaime', 'Tyrion', 'Bran', 'Robb'], 'updating a specified property on an item resorts it'); }); -QUnit.test('changing item properties not specified via @each does not trigger a resort', function(assert) { - let items = obj.get('items'); - let cersei = items[1]; +if (!EMBER_METAL_TRACKED_PROPERTIES) { + QUnit.test('changing item properties not specified via @each does not trigger a resort', function(assert) { + let items = obj.get('items'); + let cersei = items[1]; - assert.deepEqual(obj.get('sortedItems').mapBy('fname'), ['Cersei', 'Jaime', 'Bran', 'Robb'], 'precond - array is initially sorted'); + assert.deepEqual(obj.get('sortedItems').mapBy('fname'), ['Cersei', 'Jaime', 'Bran', 'Robb'], 'precond - array is initially sorted'); - set(cersei, 'lname', 'Stark'); // plot twist! (possibly not canon) + set(cersei, 'lname', 'Stark'); // plot twist! (possibly not canon) - // The array has become unsorted. If your sort function is sensitive to - // properties, they *must* be specified as dependent item property keys or - // we'll be doing binary searches on unsorted arrays. - assert.deepEqual(obj.get('sortedItems').mapBy('fname'), ['Cersei', 'Jaime', 'Bran', 'Robb'], 'updating an unspecified property on an item does not resort it'); -}); + // The array has become unsorted. If your sort function is sensitive to + // properties, they *must* be specified as dependent item property keys or + // we'll be doing binary searches on unsorted arrays. + assert.deepEqual(obj.get('sortedItems').mapBy('fname'), ['Cersei', 'Jaime', 'Bran', 'Robb'], 'updating an unspecified property on an item does not resort it'); + }); +} else { + QUnit.skip('changing item properties not specified via @each does not trigger a resort', assert => { + assert.ok(false, 'It is unclear whether changing this behavior should be considered a breaking change, and whether it catches more bugs than it causes'); + }); +} QUnit.module('sort - stability', { beforeEach() {