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

Tracked mirror approach for prop notifications #591

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
132 changes: 122 additions & 10 deletions addon/index.js
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import { assert } from '@ember/debug';
import { dependentKeyCompat } from '@ember/object/compat';
import { BufferedChangeset } from 'validated-changeset';
import { BufferedChangeset, Change, keyInObject } from 'validated-changeset';
import ArrayProxy from '@ember/array/proxy';
import ObjectProxy from '@ember/object/proxy';
import { notifyPropertyChange } from '@ember/object';
import mergeDeep from './utils/merge-deep';
import isObject from './utils/is-object';
import { tracked } from '@glimmer/tracking';
import { tracked } from 'tracked-built-ins';
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think we would want both? Or is the tracked-built-ins intended for things like @tracked _changes; as well?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🤔 not sure, TBH! Gonna have a look

import { get as safeGet, set as safeSet } from '@ember/object';
import { addObserver, removeObserver } from '@ember/object/observers';

const CHANGES = '_changes';
const PREVIOUS_CONTENT = '_previousContent';
const CONTENT = '_content';
const VIRTUAL_MIRROR = '_virtualMirror';
const defaultValidatorFn = () => true;

export function buildOldValues(content, changes, getDeep) {
Expand All @@ -32,10 +34,80 @@ function maybeUnwrapProxy(o) {
return isProxy(o) ? maybeUnwrapProxy(safeGet(o, 'content')) : o;
}

function deepNotifyPropertyChange(changeset, path) {
let paths = path.split('.');
let maybeDynamicPathToNotify = null,
lastPath = paths.pop(),
current = changeset,
i;

for (i = 0; i < paths.length; ++i) {
if (Object.prototype.hasOwnProperty.call(current, paths[i])) {
current = safeGet(current, paths[i]);
} else {
maybeDynamicPathToNotify = paths[i];
break;
}
}
const pathToNotify = maybeDynamicPathToNotify ? maybeDynamicPathToNotify : lastPath;
notifyPropertyChange(current, pathToNotify);
Copy link
Collaborator

Choose a reason for hiding this comment

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

I wonder if we should make notifyPropertyChange opt-in in a next major 4.0 release? Generally, we gave patterns that obfuscate the need for these reactivity systems.

Copy link
Collaborator

Choose a reason for hiding this comment

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

Or am I missing a crucial need for notifyPropertyChange, even in the new reactivity world?

Copy link
Contributor Author

@betocantu93 betocantu93 May 10, 2021

Choose a reason for hiding this comment

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

🤔🤔🤔 maybe it's no longer needed, you're right, gonna have a look, one thing I still need to look for is reactivity around arrays, i.e. computeds which use [email protected]

}

function deepHasOwnProperty(obj, path) {
if (!path) {
return false;
}

let paths = path.split('.'),
current = obj,
i;

for (i = 0; i < paths.length; ++i) {
if (!current || !Object.prototype.hasOwnProperty.call(current, paths[i])) {
return false;
} else {
current = current[paths[i]];
}
}

return true;
}

function deepAddObserver(obj, path, callback) {
let paths = path.split('.');
let lastPath = paths.pop(),
current = obj,
i;
for (i = 0; i < paths.length; ++i) {
if (Object.prototype.hasOwnProperty.call(current, paths[i])) {
current = safeGet(current, paths[i]);
} else {
throw new Error('You cant add an observer to an undefined obj');
}
}
addObserver(current, lastPath, callback);
}

function deepRemoveObserver(obj, path, callback) {
let paths = path.split('.');
let lastPath = paths.pop(),
current = obj,
i;
for (i = 0; i < paths.length; ++i) {
if (Object.prototype.hasOwnProperty.call(current, paths[i])) {
current = safeGet(current, paths[i]);
} else {
throw new Error('You cant add an observer to an undefined obj');
}
}
removeObserver(current, lastPath, callback);
}

export class EmberChangeset extends BufferedChangeset {
@tracked _changes;
@tracked _errors;
@tracked _content;
@tracked _virtualMirror = {};

isObject = isObject;

Expand All @@ -56,6 +128,11 @@ export class EmberChangeset extends BufferedChangeset {
return safeSet(obj, key, value);
}

@dependentKeyCompat
get mirror() {
return this[VIRTUAL_MIRROR];
}
Copy link
Collaborator

@snewcomer snewcomer May 10, 2021

Choose a reason for hiding this comment

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

Is this meant to be accessed anywhere? I don't see this.mirror anywhere.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Mainly for public access, for the classic computeds case


/**
* @property isValid
* @type {Array}
Expand Down Expand Up @@ -110,7 +187,7 @@ export class EmberChangeset extends BufferedChangeset {
addError(key, error) {
super.addError(key, error);

notifyPropertyChange(this, key);
deepNotifyPropertyChange(this, key);
// Return passed-in `error`.
return error;
}
Expand All @@ -123,7 +200,7 @@ export class EmberChangeset extends BufferedChangeset {
pushErrors(key, ...newErrors) {
const { value, validation } = super.pushErrors(key, ...newErrors);

notifyPropertyChange(this, key);
deepNotifyPropertyChange(this, key);

return { value, validation };
}
Expand All @@ -133,9 +210,26 @@ export class EmberChangeset extends BufferedChangeset {
* Returns value or error
*/
_setProperty({ key, value, oldValue }) {
super._setProperty({ key, value, oldValue });
let changes = this[CHANGES];
let mirror = this[VIRTUAL_MIRROR];

this.setDeep(mirror, key, true, {
safeSet: this.safeSet,
});

notifyPropertyChange(this, key);
//We always set
this.setDeep(changes, key, new Change(value), {
safeSet: this.safeSet,
});

//If the newValue is equals to the wrapped value, delete key from changes
if (oldValue === value && keyInObject(changes, key)) {
this._deleteKey(CHANGES, key);
}

//Notitify deep
deepNotifyPropertyChange(this[VIRTUAL_MIRROR], key);
notifyPropertyChange(this, 'changes');
}

/**
Expand All @@ -149,7 +243,7 @@ export class EmberChangeset extends BufferedChangeset {
_notifyVirtualProperties(keys) {
keys = super._notifyVirtualProperties(keys);

(keys || []).forEach((key) => notifyPropertyChange(this, key));
(keys || []).forEach((key) => deepNotifyPropertyChange(this[VIRTUAL_MIRROR], key));

return;
}
Expand All @@ -159,9 +253,7 @@ export class EmberChangeset extends BufferedChangeset {
*/
_deleteKey(objName, key = '') {
const result = super._deleteKey(objName, key);

notifyPropertyChange(this, key);

deepNotifyPropertyChange(this[VIRTUAL_MIRROR], key);
return result;
}

Expand All @@ -188,6 +280,26 @@ export class EmberChangeset extends BufferedChangeset {

return this;
}

get(key) {
safeGet(this[VIRTUAL_MIRROR], key); //consume tag for native tracked dependencies
Copy link
Collaborator

Choose a reason for hiding this comment

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

Is this meant to be safeGet(this.mirror, key)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I followed the pattern of using this[KEYWORD] on internal usages, the get mirror(){} is meant to be used by client code, public access, like.... get data(){}

return super.get(...arguments);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

Does this handle the obj.key case as well? Or do we have the ability to handle this case?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I think yes? Because of the Proxy traps all access 🤔


addObserver(key, callback) {
if (!deepHasOwnProperty(this[VIRTUAL_MIRROR], key)) {
this.setDeep(this[VIRTUAL_MIRROR], key);
}

deepAddObserver(this[VIRTUAL_MIRROR], key, callback);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

So is changest.addObserver a common pattern that we should support and/or encourage?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Manually adding observers in general is not that common, I needed them for a very specific use case that I'm not entirely sure if it could be solved by native getters and this mirror approach, still having a way to add them gives more flexibility and an actual way to do it, which doesn't exist today, also... adding them to the content have it's own set of inconveniences


removeObserver(key, callback) {
if (!deepHasOwnProperty(this[VIRTUAL_MIRROR], key)) {
this.setDeep(this[VIRTUAL_MIRROR], key);
}
deepRemoveObserver(this[VIRTUAL_MIRROR], key, callback);
}
}

/**
Expand Down
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@
"@glimmer/tracking": "^1.0.1",
"ember-auto-import": "^1.5.2",
"ember-cli-babel": "^7.19.0",
"validated-changeset": "~0.14.4"
"validated-changeset": "~0.14.4",
"tracked-built-ins": "^1.1.1"
},
"devDependencies": {
"@ember/optional-features": "^1.0.0",
Expand Down Expand Up @@ -75,12 +76,12 @@
"release-it-lerna-changelog": "^1.0.3"
},
"engines": {
"node": "10.* || >= 12.*"
"node": ">= 12.*"
},
"ember-addon": {
"configPath": "tests/dummy/config"
},
"volta": {
"node": "10.22.0"
"node": "12.22.1"
}
}
22 changes: 20 additions & 2 deletions tests/unit/changeset-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1791,15 +1791,33 @@ module('Unit | Utility | changeset', function (hooks) {
test('observing #rollback values', async function (assert) {
let res;
let changeset = Changeset(dummyModel, dummyValidator);
changeset.addObserver('name', function () {
res = this.name;
//To consistently observe for changes (even for deep keys),
//we must adhere to a never changing value
//so the changeset.data (_content) is the silver bullet for now
//ember-changeset will keep notifying on it
//one downside is that you won't get the changeset as `this` inside the callback.
changeset.data.addObserver('name', function () {
res = changeset.get('name');
});
assert.equal(undefined, changeset.get('name'), 'initial value');
changeset.set('name', 'Jack');
assert.equal('Jack', res, 'observer fired when setting value');
changeset.rollback();
assert.equal(undefined, res, 'observer fired with the value name was rollback to');
});
test('observing deep keys on .data works', async function (assert) {
let res;
let changeset = Changeset(dummyModel, dummyValidator);
changeset.data.addObserver('some.really.nested.path', function () {
res = changeset.get('some.really.nested.path');
});
assert.equal(undefined, changeset.get('some.really.nested.path'), 'initial value');
changeset.set('some.really.nested.path', 'Jack');
assert.equal('Jack', res, 'observer fired when setting value some.really.nested.path');
changeset.rollback();
assert.equal(undefined, changeset.get('some.really.nested.path'), 'initial value is back to undefined');
assert.equal(undefined, res, 'observer fired with the value some.really.nested.path was rollback to');
});

test('can update nested keys after rollback changes.', async function (assert) {
let expectedResult = {
Expand Down
Loading