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
6 changes: 3 additions & 3 deletions internal-docs/guides/reactivity/autotracked-rendering.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ There are a few systems at play for autotracking:
5. the low-level VM is the actual VirtualMachine which inteprets all our opcodes -- it iterates until there are no more opcodes

- **read: count**
- access `count`, which `@tracked`'s getter [defers to `trackedData`](https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/metal/lib/tracked.ts#L155C28-L155C39)
- the [`trackedData`](https://github.com/emberjs/ember.js/blob/132b66a768a9cabd461908682ef331f35637d5e9/packages/%40ember/-internals/metal/lib/tracked.ts#L5) is in `@glimmer/validator` instead of using tags _directly_.
- `trackedData` calls `consumeTag` when [the value is access](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/validator/lib/tracked-data.ts#L15)
- access `count`, which goes to the getter of `@tracked` in `packages/@ember/-internals/metal/lib/tracked.ts`
- the getter reads the `TrackedValue` that holds the field for this instance (`packages/@glimmer/validator/lib/tracked-value.ts`)
- `TrackedValue` calls `consumeTag` when the value is read
- `consumeTag` adds the tag to the [`CURRENT_TRACKER`](https://github.com/glimmerjs/glimmer-vm/blob/main/packages/%40glimmer/validator/lib/tracking.ts#L116)
- this is so that when any `{{ }}` regions of a template "detect" a dirty tag, they can individually re-render

Expand Down
132 changes: 94 additions & 38 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,46 @@ 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 readCell(cell: TrackedValue<unknown>): unknown {
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
if (Array.isArray(value) || isEmberArray(value)) {
consumeTag(tagFor(value, '[]'));
}

return value;
}

function writeCell(
self: object,
key: string | symbol,
cell: TrackedValue<unknown>,
newValue: unknown,
hasEquals: boolean
): void {
if (DEBUG) {
// TrackedValue has the same assertion, but it cannot name the object
// and the key.
debug.assertTagNotConsumed?.(tagFor(self, key), self, key);
}

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

dirtyTagFor(self, SELF_TAG);
}

function descriptorForField(
[target, key, desc]: ElementDescriptor,
options?: { equals?: (a: any, b: any) => boolean; description?: string }
Expand All @@ -333,34 +374,48 @@ 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);

// 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
if (Array.isArray(value) || isEmberArray(value)) {
consumeTag(tagFor(value, '[]'));
if (cell === undefined) {
cell = createCell(this, hasInitializer ? initializer!.call(this) : undefined);
}

return value;
return readCell(cell);
}

function set(this: object, newValue: unknown): void {
if (
equals !== undefined &&
equals(
untrack(() => getter(this)),
newValue
)
) {
return;
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
);
}

setter(this, newValue);
dirtyTagFor(this, SELF_TAG);
writeCell(this, key, cell, newValue, equals !== undefined);
}

let newDesc = {
Expand Down Expand Up @@ -416,29 +471,30 @@ function tracked2023(
});
return;
case 'accessor': {
let equals = options?.equals;
let name = dec.context.name;
let hasEquals = options?.equals !== undefined;
let cellOptions = {
equals: options?.equals ?? neverEqual,
description: options?.description,
};

// The storage of the accessor holds the TrackedValue, so a read or a
// write needs no map lookup.
return {
init(this: object, initial: unknown) {
return new TrackedValue(initial, cellOptions, tagFor(this, name) as UpdatableTag);
},
get(this: object) {
consumeTag(tagFor(this, dec.context.name));
let value = dec.value.get.call(this);
if (Array.isArray(value) || isEmberArray(value)) {
consumeTag(tagFor(value, '[]'));
}
return value;
return readCell(dec.value.get.call(this) as TrackedValue<unknown>);
},
set(this: object, value: unknown) {
if (
equals !== undefined &&
equals(
untrack(() => dec.value.get.call(this)),
value
)
) {
return;
}
dirtyTagFor(this, dec.context.name);
dirtyTagFor(this, SELF_TAG);
return dec.value.set.call(this, value);
writeCell(
this,
name,
dec.value.get.call(this) as TrackedValue<unknown>,
value,
hasEquals
);
},
};
}
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
15 changes: 7 additions & 8 deletions packages/@ember/-internals/routing/route-managers/root-outlet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,7 @@ import { setComponentTemplate } from '@glimmer/manager/lib/public/template';
import { precompileTemplate } from '@ember/template-compilation';
import { DEBUG } from '@glimmer/env';
import type { OutletParent, OutletState } from './outlet-state';
import { consumeTag } from '@glimmer/validator/lib/tracking';
import { createTag, DIRTY_TAG as dirtyTag } from '@glimmer/validator/lib/validators';
import { TrackedValue } from '@glimmer/validator/lib/tracked-value';

// `this` == <@outlet />; returned by `getSelf`
const ROOT_OUTLET_TEMPLATE = precompileTemplate('{{this}}', {
Expand Down Expand Up @@ -91,25 +90,25 @@ export interface UpdatableOutletRootState {
set(root: OutletState): void;
}

/** A set of the root always notifies, also with the same state object. */
const ROOT_OPTIONS = Object.freeze({ equals: () => false, description: undefined });

/** The chain's head: a reactive first level. */
export function createRootOutletState(initial: OutletState): UpdatableOutletRootState {
let tag = createTag();
let current = initial;
let current = new TrackedValue(initial, ROOT_OPTIONS);

let state: OutletParent = {
outlets: {
get main(): OutletState {
consumeTag(tag);
return current;
return current.value;
},
},
};

return {
state,
set(root: OutletState) {
current = root;
dirtyTag(tag);
current.value = root;
},
};
}
Expand Down
23 changes: 10 additions & 13 deletions packages/@glimmer/reference/lib/iterable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import type { Nullable } from '@glimmer/interfaces';
import { getPath, toIterator } from '@glimmer/global-context';
import { EMPTY_ARRAY } from '@glimmer/util/lib/array-utils';
import { isIndexable } from '@glimmer/util/lib/collections';
import { consumeTag } from '@glimmer/validator/lib/tracking';
import { createTag, DIRTY_TAG as dirtyTag } from '@glimmer/validator/lib/validators';
import { TrackedValue } from '@glimmer/validator/lib/tracked-value';

import type { Reference, ReferenceEnvironment } from './reference';

Expand Down Expand Up @@ -159,20 +158,18 @@ export function createIteratorRef(listRef: Reference, key: string) {
});
}

export function createIteratorItemRef(_value: unknown) {
let value = _value;
let tag = createTag();
const ITEM_OPTIONS = Object.freeze({
equals: (a: unknown, b: unknown) => a === b,
description: undefined,
});

export function createIteratorItemRef(value: unknown) {
let item = new TrackedValue(value, ITEM_OPTIONS);

return createComputeRef(
() => {
consumeTag(tag);
return value;
},
() => item.value,
(newValue) => {
if (value !== newValue) {
value = newValue;
dirtyTag(tag);
}
item.value = newValue;
}
);
}
Expand Down
1 change: 0 additions & 1 deletion packages/@glimmer/validator/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export { trackedWeakMap } from './lib/collections/weak-map';
export { trackedWeakSet } from './lib/collections/weak-set';
export { debug } from './lib/debug';
export { dirtyTagFor, tagFor, type TagMeta, tagMetaFor } from './lib/meta';
export { trackedData } from './lib/tracked-data';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this package is not public, so 🙈

export {
type Reactive,
type ReadOnlyReactive,
Expand Down
24 changes: 3 additions & 21 deletions packages/@glimmer/validator/lib/collections/array.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
// and it will blow up in JS in exactly the same way, so it is safe to assume
// that properties within the getter have the correct type in TS.

import { dirtyTagFor, tagFor } from '../meta';
import { consumeTag } from '../tracking';
import { createUpdatableTag, DIRTY_TAG } from '../validators';

Expand Down Expand Up @@ -75,7 +76,7 @@ class TrackedArray<T = unknown> {
const index = convertToInt(prop);

if (index !== null) {
self.#readStorageFor(index);
consumeTag(tagFor(self, index, self.#storages));
consumeTag(self.#collection);

return target[index];
Expand Down Expand Up @@ -137,7 +138,7 @@ class TrackedArray<T = unknown> {
const index = convertToInt(prop);

if (index !== null) {
self.#dirtyStorageFor(index);
dirtyTagFor(self, index, self.#storages);
self.#dirtyCollection();
} else if (prop === 'length') {
self.#dirtyCollection();
Expand All @@ -156,25 +157,6 @@ class TrackedArray<T = unknown> {

#storages = new Map<number, ReturnType<typeof createUpdatableTag>>();

#readStorageFor(index: number) {
let storage = this.#storages.get(index);

if (storage === undefined) {
storage = createUpdatableTag();
this.#storages.set(index, storage);
}

consumeTag(storage);
}

#dirtyStorageFor(index: number): void {
const storage = this.#storages.get(index);

if (storage) {
DIRTY_TAG(storage);
}
}

#dirtyCollection() {
DIRTY_TAG(this.#collection);
this.#storages.clear();
Expand Down
Loading
Loading