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

[BUGFIX beta] Prevents autotracking ArrayProxy creation #17834

Merged
merged 1 commit into from
Nov 22, 2019
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
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { Object as EmberObject, A } from '@ember/-internals/runtime';
import { Object as EmberObject, A, ArrayProxy, PromiseProxyMixin } from '@ember/-internals/runtime';
import {
EMBER_CUSTOM_COMPONENT_ARG_PROXY,
EMBER_METAL_TRACKED_PROPERTIES,
} from '@ember/canary-features';
import { computed, tracked, nativeDescDecorator as descriptor } from '@ember/-internals/metal';
import { Promise } from 'rsvp';
import { moduleFor, RenderingTestCase, strip, runTask } from 'internal-test-helpers';
import GlimmerishComponent from '../../utils/glimmerish-component';
import { Component } from '../../utils/helpers';
Expand Down Expand Up @@ -47,6 +48,31 @@ if (EMBER_METAL_TRACKED_PROPERTIES) {
this.assertText('max jackson | max jackson');
}

'@test creating an array proxy inside a tracking context does not trigger backtracking assertion'() {
let PromiseArray = ArrayProxy.extend(PromiseProxyMixin);

class LoaderComponent extends GlimmerishComponent {
get data() {
if (!this._data) {
this._data = PromiseArray.create({
promise: Promise.resolve([1, 2, 3]),
});
}

return this._data;
}
}

this.registerComponent('loader', {
ComponentClass: LoaderComponent,
template: '{{#each this.data as |item|}}{{item}}{{/each}}',
});

this.render('<Loader/>');

this.assertText('123');
}

'@test tracked properties that are uninitialized do not throw an error'() {
let CountComponent = Component.extend({
count: tracked(),
Expand Down
3 changes: 1 addition & 2 deletions packages/@ember/-internals/metal/lib/array.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { arrayContentDidChange, arrayContentWillChange } from './array_events';
import { addListener, removeListener } from './events';
import { notifyPropertyChange } from './property_events';
import { get } from './property_get';

const EMPTY_ARRAY = Object.freeze([]);

Expand Down Expand Up @@ -84,7 +83,7 @@ function arrayObserversHelper(
): ObjectHasArrayObservers {
let willChange = (opts && opts.willChange) || 'arrayWillChange';
let didChange = (opts && opts.didChange) || 'arrayDidChange';
let hasObservers = get(obj, 'hasArrayObservers');
let hasObservers = obj.hasArrayObservers;

operation(obj, '@array:before', target, willChange);
operation(obj, '@array:change', target, didChange);
Expand Down
14 changes: 12 additions & 2 deletions packages/@ember/-internals/metal/lib/property_get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { DEBUG } from '@glimmer/env';
import { descriptorForProperty } from './descriptor_map';
import { isPath } from './path_cache';
import { tagForProperty } from './tags';
import { consume, isTracking } from './tracked';
import { consume, deprecateMutationsInAutotrackingTransaction, isTracking } from './tracked';

export const PROXY_CONTENT = symbol('PROXY_CONTENT');

Expand Down Expand Up @@ -143,7 +143,17 @@ export function get(obj: object, keyName: string): any {
!(keyName in obj) &&
typeof (obj as MaybeHasUnknownProperty).unknownProperty === 'function'
) {
return (obj as MaybeHasUnknownProperty).unknownProperty!(keyName);
if (DEBUG) {
let ret;

deprecateMutationsInAutotrackingTransaction!(() => {
ret = (obj as MaybeHasUnknownProperty).unknownProperty!(keyName);
});

return ret;
} else {
return (obj as MaybeHasUnknownProperty).unknownProperty!(keyName);
}
}
}
return value;
Expand Down
60 changes: 37 additions & 23 deletions packages/@ember/-internals/metal/lib/tracked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ interface AutotrackingTransactionSourceData {
error: Error;
}

let WARN_IN_AUTOTRACKING_TRANSACTION = false;
let DEPRECATE_IN_AUTOTRACKING_TRANSACTION = false;
let AUTOTRACKING_TRANSACTION: WeakMap<Tag, AutotrackingTransactionSourceData> | null = null;

export let runInAutotrackingTransaction: undefined | ((fn: () => void) => void);
Expand All @@ -28,40 +28,51 @@ export let assertTagNotConsumed:
let markTagAsConsumed: undefined | ((_tag: Tag, sourceError: Error) => void);

if (DEBUG) {
/**
* Creates a global autotracking transaction. This will prevent any backflow
* in any `track` calls within the transaction, even if they are not
* externally consumed.
*
* `runInAutotrackingTransaction` can be called within itself, and it will add
* onto the existing transaction if one exists.
*
* TODO: Only throw an error if the `track` is consumed.
*/
runInAutotrackingTransaction = (fn: () => void) => {
if (AUTOTRACKING_TRANSACTION !== null) {
// already in a transaction, continue and let the original transaction finish
fn();
return;
}
let previousDeprecateState = DEPRECATE_IN_AUTOTRACKING_TRANSACTION;
let previousTransactionState = AUTOTRACKING_TRANSACTION;

AUTOTRACKING_TRANSACTION = new WeakMap();
DEPRECATE_IN_AUTOTRACKING_TRANSACTION = false;

if (previousTransactionState === null) {
// if there was no transaction start it. Otherwise, the transaction already exists.
AUTOTRACKING_TRANSACTION = new WeakMap();
}

try {
fn();
} finally {
AUTOTRACKING_TRANSACTION = null;
DEPRECATE_IN_AUTOTRACKING_TRANSACTION = previousDeprecateState;
AUTOTRACKING_TRANSACTION = previousTransactionState;
}
};

/**
* Switches to deprecating within an autotracking transaction, if one exists.
* If `runInAutotrackingTransaction` is called within the callback of this
* method, it switches back to throwing an error, allowing zebra-striping of
* the types of errors that are thrown.
*
* Does not start an autotracking transaction.
*/
deprecateMutationsInAutotrackingTransaction = (fn: () => void) => {
assert(
'deprecations can only occur inside of an autotracking transaction',
AUTOTRACKING_TRANSACTION !== null
);

if (WARN_IN_AUTOTRACKING_TRANSACTION) {
// already in a transaction, continue and let the original transaction finish
fn();
return;
}

WARN_IN_AUTOTRACKING_TRANSACTION = true;
let previousDeprecateState = DEPRECATE_IN_AUTOTRACKING_TRANSACTION;
DEPRECATE_IN_AUTOTRACKING_TRANSACTION = true;

try {
fn();
} finally {
WARN_IN_AUTOTRACKING_TRANSACTION = false;
DEPRECATE_IN_AUTOTRACKING_TRANSACTION = previousDeprecateState;
}
};

Expand Down Expand Up @@ -137,7 +148,7 @@ if (DEBUG) {

if (!sourceData) return;

if (WARN_IN_AUTOTRACKING_TRANSACTION && !forceHardError) {
if (DEPRECATE_IN_AUTOTRACKING_TRANSACTION && !forceHardError) {
deprecate(makeAutotrackingErrorMessage(sourceData, obj, keyName), false, {
id: 'autotracking.mutation-after-consumption',
until: '4.0.0',
Expand Down Expand Up @@ -354,7 +365,7 @@ function descriptorForField([_target, key, desc]: [
get(): any {
let propertyTag = tagForProperty(this, key) as UpdatableTag;

if (CURRENT_TRACKER) CURRENT_TRACKER.add(propertyTag);
consume(propertyTag);

let value;

Expand All @@ -378,6 +389,9 @@ function descriptorForField([_target, key, desc]: [

set(newValue: any): void {
if (DEBUG) {
// No matter what, attempting to update a tracked property in an
// autotracking context after it has been read is invalid, even if we
// are otherwise warning, so always assert.
assertTagNotConsumed!(tagForProperty(this, key), this, key, true);
}

Expand Down
29 changes: 28 additions & 1 deletion packages/@ember/-internals/metal/tests/accessors/get_test.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { ENV } from '@ember/-internals/environment';
import { Object as EmberObject } from '@ember/-internals/runtime';
import { get, getWithDefault, Mixin, observer, computed } from '../..';
import { get, set, track, getWithDefault, Mixin, observer, computed } from '../..';
import { moduleFor, AbstractTestCase } from 'internal-test-helpers';
import { run } from '@ember/runloop';
import { EMBER_METAL_TRACKED_PROPERTIES } from '@ember/canary-features';

function aget(x, y) {
return x[y];
Expand Down Expand Up @@ -290,6 +291,32 @@ moduleFor(
}
}

['@test gives helpful deprecation when a property tracked with `get` is mutated after access within unknownProperty within an autotracking transaction'](
assert
) {
if (!EMBER_METAL_TRACKED_PROPERTIES) {
assert.expect(0);
return;
}

class EmberObject {
foo = null;

unknownProperty() {
get(this, 'foo');
set(this, 'foo', 123);
}
}

let obj = new EmberObject();

expectDeprecation(() => {
track(() => {
get(obj, 'bar');
});
}, /You attempted to update `foo` on `EmberObject`, but it had already been used previously in the same computation/);
}

// ..........................................................
// BUGS
//
Expand Down
30 changes: 23 additions & 7 deletions packages/@ember/-internals/metal/tests/tracked/validation_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@ import { EMBER_METAL_TRACKED_PROPERTIES } from '@ember/canary-features';
import { EMBER_ARRAY } from '@ember/-internals/utils';
import { AbstractTestCase, moduleFor } from 'internal-test-helpers';
import { value, validate } from '@glimmer/reference';
import { DEBUG } from '@glimmer/env';

if (EMBER_METAL_TRACKED_PROPERTIES) {
moduleFor(
Expand Down Expand Up @@ -348,14 +347,31 @@ if (EMBER_METAL_TRACKED_PROPERTIES) {
let obj = new EmberObject();

expectAssertion(() => {
if (DEBUG) {
track(() => {
obj.value;
obj.value = 123;
});
}
track(() => {
obj.value;
obj.value = 123;
});
}, /You attempted to update `value` on `EmberObject`, but it had already been used previously in the same computation/);
}

['@test gives helpful assertion when a tracked property is mutated after access within unknownProperty within an autotracking transaction']() {
class EmberObject {
@tracked foo;

unknownProperty() {
this.foo;
this.foo = 123;
}
}

let obj = new EmberObject();

expectAssertion(() => {
track(() => {
get(obj, 'bar');
});
}, /You attempted to update `foo` on `EmberObject`, but it had already been used previously in the same computation/);
}
}
);
}
13 changes: 9 additions & 4 deletions packages/@ember/-internals/runtime/lib/mixins/array.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import {
removeArrayObserver,
arrayContentWillChange,
arrayContentDidChange,
nativeDescDecorator as descriptor,
} from '@ember/-internals/metal';
import { assert } from '@ember/debug';
import Enumerable from './enumerable';
Expand Down Expand Up @@ -564,8 +565,12 @@ const ArrayMixin = Mixin.create(Enumerable, {
@property {Boolean} hasArrayObservers
@public
*/
hasArrayObservers: nonEnumerableComputed(function() {
return hasListeners(this, '@array:change') || hasListeners(this, '@array:before');
hasArrayObservers: descriptor({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don’t we also need to add enumerable: false? Also, is caching important here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, yes, I meant to do that!

For caching, I think it depends on how many times we expect folks to run addArrayObserver and removeArrayObserver, since nothing else really uses it in the ecosystem or the codebase. It seems like that's also a relatively uncommon thing, but we could also keep it as a CP if we're worried and use untrack to access it and prevent the issue.

configurable: true,
enumerable: false,
get() {
hasListeners(this, '@array:change') || hasListeners(this, '@array:before');
},
}),

/**
Expand Down Expand Up @@ -695,7 +700,7 @@ const ArrayMixin = Mixin.create(Enumerable, {
foods.forEach((food) => food.eaten = true);
let output = '';
foods.forEach((item, index, array) =>
foods.forEach((item, index, array) =>
output += `${index + 1}/${array.length} ${item.name}\n`;
);
console.log(output);
Expand Down Expand Up @@ -725,7 +730,7 @@ const ArrayMixin = Mixin.create(Enumerable, {

/**
Alias for `mapBy`.
Returns the value of the named
property on all items in the enumeration.
Expand Down
12 changes: 6 additions & 6 deletions packages/@ember/-internals/runtime/lib/system/array_proxy.js
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,11 @@ export default class ArrayProxy extends EmberObject {
this._arrangedContentRevision = value(this._arrangedContentTag);
}

this._addArrangedContentArrayObsever();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LOL @ this typo, I stared at this line for ~ 3 minutes trying to figure out why it was in the git diff 😝

this._addArrangedContentArrayObserver();
}

willDestroy() {
this._removeArrangedContentArrayObsever();
this._removeArrangedContentArrayObserver();
}

/**
Expand Down Expand Up @@ -251,16 +251,16 @@ export default class ArrayProxy extends EmberObject {
let arrangedContent = get(this, 'arrangedContent');
let newLength = arrangedContent ? get(arrangedContent, 'length') : 0;

this._removeArrangedContentArrayObsever();
this._removeArrangedContentArrayObserver();
this.arrayContentWillChange(0, oldLength, newLength);

this._invalidate();

this.arrayContentDidChange(0, oldLength, newLength);
this._addArrangedContentArrayObsever();
this._addArrangedContentArrayObserver();
}

_addArrangedContentArrayObsever() {
_addArrangedContentArrayObserver() {
let arrangedContent = get(this, 'arrangedContent');
if (arrangedContent && !arrangedContent.isDestroyed) {
assert("Can't set ArrayProxy's content to itself", arrangedContent !== this);
Expand All @@ -275,7 +275,7 @@ export default class ArrayProxy extends EmberObject {
}
}

_removeArrangedContentArrayObsever() {
_removeArrangedContentArrayObserver() {
if (this._arrangedContent) {
removeArrayObserver(this._arrangedContent, this, ARRAY_OBSERVER_MAPPING);
}
Expand Down