Skip to content
Draft
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
94 changes: 81 additions & 13 deletions packages/@ember/-internals/metal/lib/tracked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,11 @@ import { meta as metaFor } from '@ember/-internals/meta/lib/meta';
import { isEmberArray } from '@ember/array/-internals';
import { assert } from '@ember/debug';
import { DEBUG } from '@glimmer/env';
import type { UpdatableTag } from '@glimmer/interfaces';
import { debug } from '@glimmer/validator/lib/debug';
import { consumeTag, untrack } from '@glimmer/validator/lib/tracking';
import { unwrap } from '@glimmer/validator/lib/utils';
import { DIRTY_TAG } from '@glimmer/validator/lib/validators';
import { dirtyTagFor, tagFor } from '@glimmer/validator/lib/meta';
import { trackedData } from '@glimmer/validator/lib/tracked-data';
import { trackedValue, type TrackedValue } from '@glimmer/validator/lib/tracked-value';
Expand Down Expand Up @@ -396,6 +400,35 @@ export class TrackedDescriptor {
}
}

/**
* The value of one tracked accessor of one instance,
* with the tag that reports a change of it.
*
* The accessor keeps the cell in its own storage,
* so a read or a write needs no map lookup.
*
* Observers, computed chains and `notifyPropertyChange`
* find the tag of a key in the tag registry.
* So the cell uses that tag and makes none of its own.
*/
class AccessorCell {
value: unknown;
tag: UpdatableTag;

constructor(value: unknown, tag: UpdatableTag) {
/**
* All cells have one hidden class, and V8 records which kind of value
* the `value` field held so far. The first value of another kind makes
* V8 throw away the optimized code that reads the field.
*
* A number and then the value make the field general from the first cell.
*/
this.value = 0;
this.value = value;
this.tag = tag;
}
}

function tracked2023(
args: Parameters<Decorator>,
options?: { equals?: (a: any, b: any) => boolean; description?: string }
Expand All @@ -417,28 +450,63 @@ function tracked2023(
return;
case 'accessor': {
let equals = options?.equals;
let key = dec.context.name;
let storage = dec.value;

return {
get(this: object) {
consumeTag(tagFor(this, dec.context.name));
let value = dec.value.get.call(this);
let stored = storage.get.call(this);
let cell: AccessorCell;

if (stored instanceof AccessorCell) {
cell = stored;
} else {
/**
* The storage holds the plain value until the first read.
* An instance whose accessor is never read
* has no cell and no tag.
*/
cell = new AccessorCell(stored, tagFor(this, key) as UpdatableTag);
storage.set.call(this, cell);
}

consumeTag(cell.tag);

let value = cell.value;

if (Array.isArray(value) || isEmberArray(value)) {
consumeTag(tagFor(value, '[]'));
}
return value;
},
set(this: object, value: unknown) {
if (
equals !== undefined &&
equals(
untrack(() => dec.value.get.call(this)),
value
)
) {
return;
let stored = storage.get.call(this);

if (stored instanceof AccessorCell) {
if (equals !== undefined && equals(stored.value, value)) {
return;
}

if (DEBUG) {
unwrap(debug.assertTagNotConsumed)(stored.tag, this, key);
}

DIRTY_TAG(stored.tag, true);
dirtyTagFor(this, SELF_TAG);
stored.value = value;
} else {
if (equals !== undefined && equals(stored, value)) {
return;
}

/**
* No read made a cell yet. The registry can still have a tag,
* from other code that asked for it by key.
*/
dirtyTagFor(this, key);
dirtyTagFor(this, SELF_TAG);
storage.set.call(this, value);
}
dirtyTagFor(this, dec.context.name);
dirtyTagFor(this, SELF_TAG);
return dec.value.set.call(this, value);
},
};
}
Expand Down
200 changes: 200 additions & 0 deletions packages/@ember/-internals/metal/tests/tracked/accessor_test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
import { AbstractTestCase, moduleFor } from 'internal-test-helpers';
import {
computed,
defineProperty,
get,
notifyPropertyChange,
tagForProperty,
tracked,
} from '../..';

import { track, valueForTag, validateTag } from '@glimmer/validator';

/*
A class with a tracked accessor `count`, with the decorator applied by hand.

The legacy decorator build of the tests cannot compile the `accessor`
keyword, so this does what a stage 3 transform emits.
*/
function classWithTrackedAccessor(decorator, initial) {
let decorated;

class Example {
#count = decorated.init ? decorated.init.call(this, initial) : initial;

get count() {
return decorated.get.call(this);
}

set count(value) {
decorated.set.call(this, value);
}

static storage = {
get() {
return this.#count;
},
set(value) {
this.#count = value;
},
};

toString() {
return 'Example';
}
}

decorated = decorator(Example.storage, {
kind: 'accessor',
name: 'count',
static: false,
private: false,
addInitializer() {},
metadata: {},
});

return Example;
}

moduleFor(
'@tracked accessor',
class extends AbstractTestCase {
['@test a read gives the initial value and a write invalidates'](assert) {
let Example = classWithTrackedAccessor(tracked, 0);
let obj = new Example();

let tag = track(() => obj.count);
let snapshot = valueForTag(tag);

assert.strictEqual(obj.count, 0);
assert.true(validateTag(tag, snapshot));

obj.count = 1;

assert.false(validateTag(tag, snapshot));
assert.strictEqual(obj.count, 1);
}

['@test a write before the first read keeps the value'](assert) {
let Example = classWithTrackedAccessor(tracked, 0);
let obj = new Example();

obj.count = 5;

assert.strictEqual(obj.count, 5);
}

['@test instances do not share a value or a tag'](assert) {
let Example = classWithTrackedAccessor(tracked, 0);
let first = new Example();
let second = new Example();

let tag = track(() => second.count);
let snapshot = valueForTag(tag);

first.count = 1;

assert.true(validateTag(tag, snapshot));
assert.strictEqual(second.count, 0);
}

['@test a write dirties the tag that tagForProperty gave before the first read or write'](
assert
) {
let Example = classWithTrackedAccessor(tracked, 0);
let obj = new Example();

let tag = tagForProperty(obj, 'count');
let snapshot = valueForTag(tag);

obj.count = 1;

assert.false(validateTag(tag, snapshot));
}

['@test notifyPropertyChange dirties the tag that a read consumed'](assert) {
let Example = classWithTrackedAccessor(tracked, 0);
let obj = new Example();

let tag = track(() => obj.count);
let snapshot = valueForTag(tag);

notifyPropertyChange(obj, 'count');

assert.false(validateTag(tag, snapshot));
}

['@test a computed property with the accessor as a dependent key updates'](assert) {
let Example = classWithTrackedAccessor(tracked, 1);

defineProperty(
Example.prototype,
'double',
computed('count', function () {
return this.count * 2;
})
);

let obj = new Example();

assert.strictEqual(get(obj, 'double'), 2);

obj.count = 2;

assert.strictEqual(get(obj, 'double'), 4);
}

['@test self-assignment invalidates without an equals option'](assert) {
let Example = classWithTrackedAccessor(tracked, 0);
let obj = new Example();

let tag = track(() => obj.count);
let snapshot = valueForTag(tag);

obj.count = 0;

assert.false(validateTag(tag, snapshot));
}

['@test the equals option skips a write of an equal value'](assert) {
let Example = classWithTrackedAccessor(tracked({ equals: (a, b) => a === b }), 0);
let obj = new Example();

let tag = track(() => obj.count);
let snapshot = valueForTag(tag);

obj.count = 0;

assert.true(validateTag(tag, snapshot));

obj.count = 1;

assert.false(validateTag(tag, snapshot));
assert.strictEqual(obj.count, 1);
}

['@test a read of an array value consumes the tag of the array'](assert) {
let Example = classWithTrackedAccessor(tracked, []);
let obj = new Example();

let tag = track(() => obj.count);
let snapshot = valueForTag(tag);

notifyPropertyChange(obj.count, '[]');

assert.false(validateTag(tag, snapshot));
}

['@test a write after a read in the same computation names the object and the key']() {
let Example = classWithTrackedAccessor(tracked, 0);
let obj = new Example();

expectAssertion(() => {
track(() => {
obj.count;
obj.count = 1;
});
}, /You attempted to update `count` on `Example`, but it had already been used previously in the same computation/);
}
}
);
Loading