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
65 changes: 53 additions & 12 deletions packages/@ember/-internals/metal/lib/tracked.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +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 { 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';
import { trackedValue, TrackedValue } from '@glimmer/validator/lib/tracked-value';
import type { ElementDescriptor } from '..';
import { CHAIN_PASS_THROUGH } from './chain-tags';
import type { ExtendedMethodDecorator, DecoratorPropertyDescriptor } from './decorator';
Expand Down Expand Up @@ -324,6 +325,12 @@ if (DEBUG) {
setClassicDecorator(tracked);
}

// A tracked field notifies on every write, also when the value is the same:
// `this.items = this.items` is how code reports a change inside the value.
function neverEqual(): boolean {
return false;
}

function descriptorForField(
[target, key, desc]: ElementDescriptor,
options?: { equals?: (a: any, b: any) => boolean; description?: string }
Expand All @@ -333,11 +340,33 @@ function descriptorForField(
!desc || (!desc.value && !desc.get && !desc.set)
);

let { getter, setter } = trackedData<any, any>(key, desc ? desc.initializer : undefined);
let initializer = desc ? desc.initializer : undefined;
let hasInitializer = typeof initializer === 'function';
let equals = options?.equals;

// Each instance gets one TrackedValue for the field, at its first read or
// write. All of them share these options.
let cells = new WeakMap<object, TrackedValue<unknown>>();
let cellOptions = { equals: equals ?? neverEqual, description: options?.description };

function createCell(self: object, value: unknown): TrackedValue<unknown> {
// Other code can ask the registry for the tag of the field before the
// first read or write, so the value takes its tag from there.
let cell = new TrackedValue(value, cellOptions, tagFor(self, key) as UpdatableTag);

cells.set(self, cell);

return cell;
}

function get(this: object): unknown {
let value = getter(this);
let cell = cells.get(this);

if (cell === undefined) {
cell = createCell(this, hasInitializer ? initializer!.call(this) : undefined);
}

let value = cell.value;

// Add the tag of the returned value if it is an array, since arrays
// should always cause updates if they are consumed and then changed
Expand All @@ -349,17 +378,29 @@ function descriptorForField(
}

function set(this: object, newValue: unknown): void {
if (
equals !== undefined &&
equals(
untrack(() => getter(this)),
newValue
)
) {
let cell = cells.get(this);

if (cell === undefined) {
// A first write does not run the initializer, unless `equals` needs the
// initial value to compare with.
cell = createCell(
this,
equals !== undefined && hasInitializer ? untrack(() => initializer!.call(this)) : undefined
);
}

if (DEBUG) {
// TrackedValue has the same assertion, but it cannot name the object
// and the key.
debug.assertTagNotConsumed?.(tagFor(this, key), this, key);
}

if (equals === undefined) {
cell.value = newValue;
} else if (!cell.set(newValue)) {
return;
}

setter(this, newValue);
dirtyTagFor(this, SELF_TAG);
}

Expand Down
51 changes: 51 additions & 0 deletions packages/@ember/-internals/metal/tests/tracked/validation_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,57 @@ moduleFor(
assert.equal(validateTag(tag, snapshot), true);
}

[`@test a write dirties the tag that tagForProperty gave before the first read or write`](
assert
) {
class Tracked {
@tracked first = 'Tom';
}

let obj = new Tracked();

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

obj.first = 'Thomas';

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

[`@test notifyPropertyChange dirties the tag that a read of a tracked field consumed`](assert) {
class Tracked {
@tracked first = 'Tom';
}

let obj = new Tracked();

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

notifyPropertyChange(obj, 'first');

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

[`@test a first write does not run the initializer of a tracked field`](assert) {
let runs = 0;

class Tracked {
@tracked first = ++runs;
}

let obj = new Tracked();

// Stable decorators run the initializer in the constructor.
let expected = import.meta.env.VITE_STABLE_DECORATORS ? 1 : 0;

obj.first = 10;

assert.equal(runs, expected);
assert.equal(obj.first, 10);
assert.equal(runs, expected);
}

[`@test autotracking should work with initializers (${import.meta.env.VITE_STABLE_DECORATORS ? 'stable' : 'legacy'} decorators)`](
assert
) {
Expand Down
8 changes: 6 additions & 2 deletions packages/@glimmer/validator/lib/tracked-value.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,11 @@ export class TrackedValue<Value = unknown> implements Reactive<Value> {
#update: ((updater: (value: Value) => Value) => void) | undefined;
#freeze: (() => void) | undefined;

constructor(value: Value, options: ReactiveOptions<Value>) {
/**
* The `@tracked` decorator passes the tag that the tag registry has for the field.
* `tagFor` and `dirtyTagFor` then work on the tag of the value.
*/
constructor(value: Value, options: ReactiveOptions<Value>, tag?: UpdatableTag) {
/**
* If we let V8 try to completely optimize TrackedValue for
* for each type of `value`,
Expand All @@ -58,7 +62,7 @@ export class TrackedValue<Value = unknown> implements Reactive<Value> {
this.#value = 0 as Value;
this.#value = value;
this.#options = options;
this.#tag = createUpdatableTag();
this.#tag = tag === undefined ? createUpdatableTag() : tag;
}

/**
Expand Down
Loading