diff --git a/packages/@ember/-internals/glimmer/lib/glimmer-tracking-docs.ts b/packages/@ember/-internals/glimmer/lib/glimmer-tracking-docs.ts
index 87dbdce419a..3de79c00e32 100644
--- a/packages/@ember/-internals/glimmer/lib/glimmer-tracking-docs.ts
+++ b/packages/@ember/-internals/glimmer/lib/glimmer-tracking-docs.ts
@@ -149,6 +149,67 @@
});
```
+ Calling `tracked` with an initial value creates a standalone reactive
+ value, usable outside of classes:
+
+ ```js
+ import { tracked } from '@glimmer/tracking';
+
+ const count = tracked(0);
+
+ count.value; // read the value, entangling with any tracking context
+ count.value = 1; // write the value, notifying consumers
+ count.get(); // function shorthand for reading
+ count.set(2); // function shorthand for writing
+ count.update((n) => n + 1); // write based on the current value, without entangling
+ count.freeze(); // prevent all future writes
+ ```
+
+ Reading `value` in a template (or in a getter used by a template) will
+ rerender just like a `@tracked` property:
+
+ ```gjs
+ import { tracked } from '@glimmer/tracking';
+ import { on } from '@ember/modifier';
+
+ const count = tracked(0);
+ const increment = () => count.value++;
+
+
+ Count is: {{count.value}}
+
+
+
+ ```
+
+ This form accepts an options object containing an `equals` function, which
+ decides whether a written value should notify consumers (it defaults to
+ `Object.is`), and a `description` used for debugging:
+
+ ```js
+ const count = tracked(0, { equals: (a, b) => a === b });
+
+ count.value = 0; // does not notify consumers, the value did not change
+ ```
+
+ The `@tracked` decorator accepts the same options. By default, setting a
+ `@tracked` property always notifies consumers, even when setting the
+ property to the same value; passing `equals` opts in to equality-based
+ notification instead:
+
+ ```js
+ import { tracked } from '@glimmer/tracking';
+
+ class Counter {
+ @tracked({ equals: (a, b) => a === b }) count = 0;
+
+ noop = () => {
+ // does not notify consumers, the value did not change
+ this.count = this.count;
+ };
+ }
+ ```
+
@method tracked
@static
@for @glimmer/tracking
diff --git a/packages/@ember/-internals/glimmer/tests/integration/components/tracked-test.js b/packages/@ember/-internals/glimmer/tests/integration/components/tracked-test.js
index 550ee0278d4..c1d91f6f668 100644
--- a/packages/@ember/-internals/glimmer/tests/integration/components/tracked-test.js
+++ b/packages/@ember/-internals/glimmer/tests/integration/components/tracked-test.js
@@ -5,6 +5,8 @@ import PromiseProxyMixin from '@ember/object/promise-proxy-mixin';
import { tracked } from '@ember/-internals/metal';
import { computed, get, set } from '@ember/object';
import { Promise } from 'rsvp';
+import { fn } from '@ember/helper';
+import { on } from '@ember/modifier';
import { moduleFor, RenderingTestCase, strip, runTask } from 'internal-test-helpers';
import GlimmerishComponent from '../../utils/glimmerish-component';
import { Component } from '../../utils/helpers';
@@ -204,6 +206,112 @@ moduleFor(
this.assertText('1');
}
+ '@test standalone tracked values rerender when updated'() {
+ class CountComponent extends Component {
+ count = tracked(0);
+
+ increment = () => {
+ this.count.value++;
+ };
+ }
+
+ this.owner.register(
+ 'component:counter',
+ setComponentTemplate(
+ precompileTemplate(''),
+ CountComponent
+ )
+ );
+
+ this.render('');
+
+ this.assertText('0');
+
+ runTask(() => this.$('button').click());
+
+ this.assertText('1');
+ }
+
+ '@test standalone tracked values in module scope rerender when updated'() {
+ let count = tracked(0);
+
+ class CountComponent extends Component {
+ count = count;
+ }
+
+ this.owner.register(
+ 'component:counter',
+ setComponentTemplate(precompileTemplate('{{this.count.value}}'), CountComponent)
+ );
+
+ this.render('');
+
+ this.assertText('0');
+
+ runTask(() => count.set(1));
+
+ this.assertText('1');
+
+ runTask(() => count.update((value) => value + 1));
+
+ this.assertText('2');
+ }
+
+ '@test standalone tracked values do not rerender when set to an equal value'(assert) {
+ let count = tracked(0);
+ let evaluations = 0;
+
+ class CountComponent extends Component {
+ get count() {
+ evaluations++;
+ return count.value;
+ }
+ }
+
+ this.owner.register(
+ 'component:counter',
+ setComponentTemplate(precompileTemplate('{{this.count}}'), CountComponent)
+ );
+
+ this.render('');
+
+ this.assertText('0');
+ assert.strictEqual(evaluations, 1, 'rendered once');
+
+ runTask(() => count.set(0));
+
+ this.assertText('0');
+ assert.strictEqual(evaluations, 1, 'setting an equal value does not rerender');
+
+ runTask(() => count.set(1));
+
+ this.assertText('1');
+ assert.strictEqual(evaluations, 2, 'setting a new value rerenders');
+ }
+
+ '@test tracked can be used as a helper in templates'() {
+ let increment = (count) => count.value++;
+
+ this.owner.register(
+ 'component:counter',
+ setComponentTemplate(
+ precompileTemplate(
+ '{{#let (tracked 0) as |count|}}{{/let}}',
+ { strictMode: true, scope: () => ({ tracked, on, fn, increment }) }
+ ),
+ class extends GlimmerishComponent {}
+ )
+ );
+
+ this.render('');
+
+ this.assertText('0');
+
+ runTask(() => this.$('button').click());
+
+ this.assertText('1');
+ }
+
'@test tracked properties rerender when updated outside of a runloop'(assert) {
let done = assert.async();
diff --git a/packages/@ember/-internals/metal/lib/properties.ts b/packages/@ember/-internals/metal/lib/properties.ts
index 968b10d1752..8db9845d246 100644
--- a/packages/@ember/-internals/metal/lib/properties.ts
+++ b/packages/@ember/-internals/metal/lib/properties.ts
@@ -95,17 +95,10 @@ export function defineDecorator(
desc: ExtendedMethodDecorator,
meta: Meta
) {
- let propertyDesc;
-
- if (DEBUG) {
- propertyDesc = desc(obj, keyName, undefined, meta, true);
- } else {
- propertyDesc = desc(obj, keyName, undefined, meta);
- }
+ let propertyDesc = desc(obj, keyName, undefined, meta, true);
Object.defineProperty(obj, keyName, propertyDesc as PropertyDescriptor);
- // pass the decorator function forward for backwards compat
return desc;
}
diff --git a/packages/@ember/-internals/metal/lib/tracked.ts b/packages/@ember/-internals/metal/lib/tracked.ts
index 71dc16cbe75..3073455e75a 100644
--- a/packages/@ember/-internals/metal/lib/tracked.ts
+++ b/packages/@ember/-internals/metal/lib/tracked.ts
@@ -2,9 +2,10 @@ 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 { consumeTag } from '@glimmer/validator/lib/tracking';
+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 type { ElementDescriptor } from '..';
import { CHAIN_PASS_THROUGH } from './chain-tags';
import type { ExtendedMethodDecorator, DecoratorPropertyDescriptor } from './decorator';
@@ -73,73 +74,121 @@ import { SELF_TAG } from './tags';
@param dependencies Optional dependents to be tracked.
*/
-export function tracked(propertyDesc: {
- value: any;
- initializer: () => any;
-}): ExtendedMethodDecorator;
+interface TrackedFieldOptions {
+ value?: any;
+ initializer?: () => any;
+ // Method syntax (rather than a function-typed property) is load-bearing:
+ // it keeps parameter checking bivariant so that a typed `equals` callback
+ // still selects this overload rather than falling through to the
+ // standalone-value overload.
+ equals?(a: any, b: any): boolean;
+ description?: string;
+}
+
+export function tracked(propertyDesc: TrackedFieldOptions): ExtendedMethodDecorator;
export function tracked(target: object, key: string): void;
export function tracked(
target: object,
key: string,
desc: DecoratorPropertyDescriptor
): DecoratorPropertyDescriptor;
-export function tracked(...args: any[]): ExtendedMethodDecorator | DecoratorPropertyDescriptor {
+export function tracked(
+ initialValue: Value,
+ options?: { equals?: (a: Value, b: Value) => boolean; description?: string }
+): TrackedValue;
+export function tracked(
+ ...args: any[]
+): ExtendedMethodDecorator | DecoratorPropertyDescriptor | TrackedValue {
assert(
`@tracked can only be used directly as a native decorator. If you're using tracked in classic classes, add parenthesis to call it like a function: tracked()`,
!(isElementDescriptor(args.slice(0, 3)) && args.length === 5 && args[4] === true)
);
- if (!isElementDescriptor(args)) {
- let propertyDesc = args[0];
+ if (isElementDescriptor(args)) {
+ return descriptorForField(args);
+ }
- assert(
- `tracked() may only receive an options object containing 'value' or 'initializer', received ${propertyDesc}`,
- args.length === 0 || (typeof propertyDesc === 'object' && propertyDesc !== null)
- );
+ if (args.length === 0 || (args.length === 1 && isDecoratorOptions(args[0]))) {
+ return makeTrackedDecorator(args[0]);
+ }
- if (DEBUG && propertyDesc) {
- let keys = Object.keys(propertyDesc);
+ let [initialValue, options] = args;
- assert(
- `The options object passed to tracked() may only contain a 'value' or 'initializer' property, not both. Received: [${keys}]`,
- keys.length <= 1 &&
- (keys[0] === undefined || keys[0] === 'value' || keys[0] === 'initializer')
- );
+ assert(
+ `tracked() may only receive an options object containing 'equals' or 'description' as its second argument, received ${options}`,
+ options === undefined || (typeof options === 'object' && options !== null)
+ );
- assert(
- `The initializer passed to tracked must be a function. Received ${propertyDesc.initializer}`,
- !('initializer' in propertyDesc) || typeof propertyDesc.initializer === 'function'
- );
- }
+ return trackedValue(initialValue, options);
+}
+
+const DECORATOR_OPTION_KEYS = ['value', 'initializer', 'equals', 'description'];
+
+function isDecoratorOptions(value: unknown): value is TrackedFieldOptions {
+ if (typeof value !== 'object' || value === null) {
+ return false;
+ }
+
+ let proto = Object.getPrototypeOf(value);
- let initializer = propertyDesc ? propertyDesc.initializer : undefined;
- let value = propertyDesc ? propertyDesc.value : undefined;
+ if (proto !== Object.prototype && proto !== null) {
+ return false;
+ }
- let decorator = function (
- target: object,
- key: string,
- _desc?: DecoratorPropertyDescriptor,
- _meta?: any,
- isClassicDecorator?: boolean
- ): DecoratorPropertyDescriptor {
- assert(
- `You attempted to set a default value for ${key} with the @tracked({ value: 'default' }) syntax. You can only use this syntax with classic classes. For native classes, you can use class initializers: @tracked field = 'default';`,
- isClassicDecorator
- );
+ return Object.keys(value).every((key) => DECORATOR_OPTION_KEYS.includes(key));
+}
- let fieldDesc = {
- initializer: initializer || (() => value),
- };
+function makeTrackedDecorator(propertyDesc?: TrackedFieldOptions): ExtendedMethodDecorator {
+ if (DEBUG && propertyDesc) {
+ assert(
+ `The options object passed to tracked() may only contain a 'value' or an 'initializer' property, not both. Received: [${Object.keys(
+ propertyDesc
+ )}]`,
+ !('value' in propertyDesc && 'initializer' in propertyDesc)
+ );
- return descriptorForField([target, key, fieldDesc]);
- };
+ assert(
+ `The initializer passed to tracked must be a function. Received ${propertyDesc.initializer}`,
+ !('initializer' in propertyDesc) || typeof propertyDesc.initializer === 'function'
+ );
- setClassicDecorator(decorator);
+ assert(
+ `The 'equals' option passed to tracked must be a function. Received ${propertyDesc.equals}`,
+ !('equals' in propertyDesc) || typeof propertyDesc.equals === 'function'
+ );
- return decorator;
+ assert(
+ `The 'description' option passed to tracked must be a string. Received ${propertyDesc.description}`,
+ !('description' in propertyDesc) || typeof propertyDesc.description === 'string'
+ );
}
- return descriptorForField(args);
+ let initializer = propertyDesc ? propertyDesc.initializer : undefined;
+ let value = propertyDesc ? propertyDesc.value : undefined;
+ let hasInitialValue =
+ propertyDesc !== undefined && ('value' in propertyDesc || 'initializer' in propertyDesc);
+ let options = { equals: propertyDesc?.equals, description: propertyDesc?.description };
+
+ let decorator = function (
+ target: object,
+ key: string,
+ desc?: DecoratorPropertyDescriptor,
+ _meta?: any,
+ isClassicDecorator?: boolean
+ ): DecoratorPropertyDescriptor {
+ assert(
+ `You attempted to set a default value for ${key} with the @tracked({ value: 'default' }) syntax. You can only use this syntax with classic classes. For native classes, you can use class initializers: @tracked field = 'default';`,
+ isClassicDecorator || !hasInitialValue
+ );
+
+ let fieldDesc = isClassicDecorator ? { initializer: initializer || (() => value) } : desc;
+
+ return descriptorForField([target, key, fieldDesc], options);
+ };
+
+ setClassicDecorator(decorator);
+
+ return decorator;
}
if (DEBUG) {
@@ -148,13 +197,17 @@ if (DEBUG) {
setClassicDecorator(tracked);
}
-function descriptorForField([target, key, desc]: ElementDescriptor): DecoratorPropertyDescriptor {
+function descriptorForField(
+ [target, key, desc]: ElementDescriptor,
+ options?: { equals?: (a: any, b: any) => boolean; description?: string }
+): DecoratorPropertyDescriptor {
assert(
`You attempted to use @tracked on ${key}, but that element is not a class field. @tracked is only usable on class fields. Native getters and setters will autotrack add any tracked fields they encounter, so there is no need mark getters and setters with @tracked.`,
!desc || (!desc.value && !desc.get && !desc.set)
);
let { getter, setter } = trackedData(key, desc ? desc.initializer : undefined);
+ let equals = options?.equals;
function get(this: object): unknown {
let value = getter(this);
@@ -169,6 +222,16 @@ function descriptorForField([target, key, desc]: ElementDescriptor): DecoratorPr
}
function set(this: object, newValue: unknown): void {
+ if (
+ equals !== undefined &&
+ equals(
+ untrack(() => getter(this)),
+ newValue
+ )
+ ) {
+ return;
+ }
+
setter(this, newValue);
dirtyTagFor(this, SELF_TAG);
}
diff --git a/packages/@ember/-internals/metal/tests/tracked/classic_classes_test.js b/packages/@ember/-internals/metal/tests/tracked/classic_classes_test.js
index 550bf55d330..3f693666e99 100644
--- a/packages/@ember/-internals/metal/tests/tracked/classic_classes_test.js
+++ b/packages/@ember/-internals/metal/tests/tracked/classic_classes_test.js
@@ -82,25 +82,7 @@ moduleFor(
}, "@tracked can only be used directly as a native decorator. If you're using tracked in classic classes, add parenthesis to call it like a function: tracked()");
}
- [`@test errors on any keys besides 'value', 'get', or 'set' being passed`]() {
- expectAssertion(() => {
- class Tracked {
- get full() {
- return `${this.first} ${this.last}`;
- }
- }
-
- defineProperty(
- Tracked.prototype,
- 'first',
- tracked({
- foo() {},
- })
- );
- }, "The options object passed to tracked() may only contain a 'value' or 'initializer' property, not both. Received: [foo]");
- }
-
- [`@test errors if 'value' and 'get'/'set' are passed together`]() {
+ [`@test errors if 'value' and 'initializer' are passed together`]() {
expectAssertion(() => {
class Tracked {
get full() {
@@ -116,19 +98,30 @@ moduleFor(
initializer: () => 123,
})
);
- }, "The options object passed to tracked() may only contain a 'value' or 'initializer' property, not both. Received: [value,initializer]");
+ }, "The options object passed to tracked() may only contain a 'value' or an 'initializer' property, not both. Received: [value,initializer]");
}
- [`@test errors on anything besides an options object being passed`]() {
- expectAssertion(() => {
- class Tracked {
- get full() {
- return `${this.first} ${this.last}`;
- }
- }
+ [`@test can pass an 'equals' option alongside a default value`](assert) {
+ class Tracked {}
- defineProperty(Tracked.prototype, 'first', tracked(null));
- }, "tracked() may only receive an options object containing 'value' or 'initializer', received null");
+ defineProperty(
+ Tracked.prototype,
+ 'first',
+ tracked({ value: 'Tom', equals: (a, b) => a === b })
+ );
+
+ let obj = new Tracked();
+
+ let tag = track(() => obj.first);
+ let snapshot = valueForTag(tag);
+
+ assert.equal(obj.first, 'Tom', 'default value is assigned');
+
+ obj.first = 'Tom';
+ assert.equal(validateTag(tag, snapshot), true, 'setting an equal value does not invalidate');
+
+ obj.first = 'Thomas';
+ assert.equal(validateTag(tag, snapshot), false, 'setting a new value invalidates');
}
}
);
@@ -136,10 +129,10 @@ moduleFor(
moduleFor(
'@tracked decorator - native decorator behavior',
class extends AbstractTestCase {
- [`@test errors if options are passed to native decorator`]() {
+ [`@test errors if a default value is passed to native decorator`]() {
expectAssertion(() => {
class Tracked {
- @tracked() first;
+ @tracked({ value: 'default' }) first;
get full() {
return `${this.first} ${this.last}`;
@@ -150,6 +143,24 @@ moduleFor(
}, "You attempted to set a default value for first with the @tracked({ value: 'default' }) syntax. You can only use this syntax with classic classes. For native classes, you can use class initializers: @tracked field = 'default';");
}
+ [`@test @tracked() with no options works as a native decorator`](assert) {
+ class Tracked {
+ @tracked() value = 1;
+ }
+
+ let obj = new Tracked();
+
+ let tag = track(() => obj.value);
+ let snapshot = valueForTag(tag);
+
+ assert.equal(obj.value, 1, 'initial value is assigned');
+
+ obj.value = 2;
+
+ assert.equal(validateTag(tag, snapshot), false, 'setting a value invalidates');
+ assert.equal(obj.value, 2);
+ }
+
[`@test errors if options are passed to native decorator (GH#17764)`](assert) {
class Tracked {
@tracked value;
diff --git a/packages/@ember/-internals/metal/tests/tracked/options_test.js b/packages/@ember/-internals/metal/tests/tracked/options_test.js
new file mode 100644
index 00000000000..149f99f1ac3
--- /dev/null
+++ b/packages/@ember/-internals/metal/tests/tracked/options_test.js
@@ -0,0 +1,88 @@
+import { AbstractTestCase, moduleFor } from 'internal-test-helpers';
+import { tracked } from '../..';
+
+import { track, valueForTag, validateTag } from '@glimmer/validator';
+
+moduleFor(
+ '@tracked decorator - options',
+ class extends AbstractTestCase {
+ ['@test equals option prevents dirtying when values are equal'](assert) {
+ class Tracked {
+ @tracked({ equals: (a, b) => a === b }) value = 0;
+ }
+
+ let obj = new Tracked();
+
+ let tag = track(() => obj.value);
+ let snapshot = valueForTag(tag);
+
+ assert.strictEqual(obj.value, 0, 'initializer ran');
+
+ obj.value = 0;
+ assert.true(validateTag(tag, snapshot), 'setting an equal value does not invalidate');
+
+ obj.value = 1;
+ assert.false(validateTag(tag, snapshot), 'setting a new value invalidates');
+ assert.strictEqual(obj.value, 1);
+ }
+
+ ['@test without an equals option, self-assignment dirties'](assert) {
+ class Tracked {
+ @tracked value = 0;
+ }
+
+ let obj = new Tracked();
+
+ let tag = track(() => obj.value);
+ let snapshot = valueForTag(tag);
+
+ let current = obj.value;
+ obj.value = current;
+ assert.false(validateTag(tag, snapshot), 'a no-op set invalidates');
+ }
+
+ ['@test equals option is compared against the initial value before any read'](assert) {
+ let compared = [];
+
+ class Tracked {
+ @tracked({
+ equals: (a, b) => {
+ compared.push([a, b]);
+ return a === b;
+ },
+ })
+ value = 0;
+ }
+
+ let obj = new Tracked();
+
+ obj.value = 0;
+
+ assert.deepEqual(compared, [[0, 0]], 'the initial value was used for comparison');
+ assert.strictEqual(obj.value, 0);
+ }
+
+ ['@test description option is accepted'](assert) {
+ class Tracked {
+ @tracked({ description: 'my value' }) value = 0;
+ }
+
+ let obj = new Tracked();
+
+ obj.value = 1;
+ assert.strictEqual(obj.value, 1);
+ }
+
+ ['@test errors when equals is not a function']() {
+ expectAssertion(() => {
+ tracked({ equals: true });
+ }, "The 'equals' option passed to tracked must be a function. Received true");
+ }
+
+ ['@test errors when description is not a string']() {
+ expectAssertion(() => {
+ tracked({ description: 123 });
+ }, "The 'description' option passed to tracked must be a string. Received 123");
+ }
+ }
+);
diff --git a/packages/@ember/-internals/metal/tests/tracked/standalone_test.js b/packages/@ember/-internals/metal/tests/tracked/standalone_test.js
new file mode 100644
index 00000000000..0d781938072
--- /dev/null
+++ b/packages/@ember/-internals/metal/tests/tracked/standalone_test.js
@@ -0,0 +1,103 @@
+import { AbstractTestCase, moduleFor } from 'internal-test-helpers';
+import { tracked } from '../..';
+
+import { track, valueForTag, validateTag } from '@glimmer/validator';
+
+moduleFor(
+ 'tracked() - standalone usage',
+ class extends AbstractTestCase {
+ ['@test creates a reactive value'](assert) {
+ let count = tracked(0);
+
+ assert.strictEqual(count.value, 0, 'initial value is readable via .value');
+ assert.strictEqual(count.get(), 0, 'initial value is readable via .get()');
+
+ count.value = 1;
+ assert.strictEqual(count.value, 1, 'value can be assigned');
+
+ count.set(2);
+ assert.strictEqual(count.value, 2, 'value can be set via .set()');
+
+ count.update((value) => value + 1);
+ assert.strictEqual(count.value, 3, 'value can be updated via .update()');
+ }
+
+ ['@test reading consumes and writing dirties'](assert) {
+ let count = tracked(0);
+
+ let tag = track(() => count.value);
+ let snapshot = valueForTag(tag);
+
+ assert.true(validateTag(tag, snapshot), 'tag is valid before a change');
+
+ count.value = 1;
+ assert.false(validateTag(tag, snapshot), 'tag is invalidated by a change');
+ }
+
+ ['@test default equality is Object.is'](assert) {
+ let count = tracked(0);
+
+ let tag = track(() => count.value);
+ let snapshot = valueForTag(tag);
+
+ count.value = 0;
+ assert.true(validateTag(tag, snapshot), 'setting an equal value does not invalidate');
+
+ count.value = 1;
+ assert.false(validateTag(tag, snapshot), 'setting a new value invalidates');
+ }
+
+ ['@test equality can be customized'](assert) {
+ let state = tracked({ id: 1 }, { equals: (a, b) => a.id === b.id });
+
+ let tag = track(() => state.value);
+ let snapshot = valueForTag(tag);
+
+ state.value = { id: 1 };
+ assert.true(validateTag(tag, snapshot), 'setting an equal value does not invalidate');
+
+ state.value = { id: 2 };
+ assert.false(validateTag(tag, snapshot), 'setting a different value invalidates');
+ }
+
+ ['@test always-dirty equality'](assert) {
+ let count = tracked(0, { equals: () => false });
+
+ let tag = track(() => count.value);
+ let snapshot = valueForTag(tag);
+
+ count.value = 0;
+ assert.false(validateTag(tag, snapshot), 'a no-op set invalidates');
+ }
+
+ ['@test freeze() prevents further updates'](assert) {
+ let count = tracked(0);
+
+ count.freeze();
+
+ assert.throws(() => count.set(1), /frozen/);
+ assert.strictEqual(count.value, 0);
+ }
+
+ ['@test works with null and undefined initial values'](assert) {
+ let a = tracked(null);
+ let b = tracked(undefined);
+
+ assert.strictEqual(a.value, null);
+ assert.strictEqual(b.value, undefined);
+
+ a.value = 1;
+ b.value = 2;
+
+ assert.strictEqual(a.value, 1);
+ assert.strictEqual(b.value, 2);
+ }
+
+ ['@test wraps arbitrary objects'](assert) {
+ let initial = { foo: 1 };
+ let state = tracked(initial);
+
+ assert.strictEqual(state.value, initial, 'the object itself is the value');
+ }
+ }
+);
diff --git a/packages/@glimmer-workspace/integration-tests/test/tracked-value-test.ts b/packages/@glimmer-workspace/integration-tests/test/tracked-value-test.ts
new file mode 100644
index 00000000000..014cb70a212
--- /dev/null
+++ b/packages/@glimmer-workspace/integration-tests/test/tracked-value-test.ts
@@ -0,0 +1,94 @@
+import { trackedValue } from '@glimmer/validator';
+import {
+ defineComponent,
+ GlimmerishComponent as Component,
+ jitSuite,
+ RenderTest,
+ test,
+} from '@glimmer-workspace/integration-tests';
+
+class TrackedValueTest extends RenderTest {
+ static suiteName = `trackedValue() (rendering)`;
+
+ @test
+ 'renders and updates when set'() {
+ const count = trackedValue(0);
+
+ const Counter = defineComponent({ count }, '{{count.value}}');
+
+ this.renderComponent(Counter);
+
+ this.assertHTML('0');
+
+ count.set(1);
+ this.rerender();
+
+ this.assertHTML('1');
+ this.assertStableRerender();
+ }
+
+ @test
+ 'default equals does not dirty on no-op changes'(assert: Assert) {
+ const count = trackedValue(0);
+ const step = () => {
+ assert.step(String(count.value));
+ return count.value;
+ };
+
+ const Counter = defineComponent({ step }, '{{ (step) }}');
+
+ this.renderComponent(Counter);
+
+ this.assertHTML('0');
+ assert.verifySteps(['0']);
+
+ count.set(0);
+ this.rerender();
+
+ this.assertHTML('0');
+ this.assertStableRerender();
+ assert.verifySteps([]);
+ }
+
+ @test
+ 'options.equals: () => false dirties on every set'(assert: Assert) {
+ const count = trackedValue(0, { equals: () => false });
+ const step = () => {
+ assert.step(String(count.value));
+ return count.value;
+ };
+
+ const Counter = defineComponent({ step }, '{{ (step) }}');
+
+ this.renderComponent(Counter);
+
+ this.assertHTML('0');
+ assert.verifySteps(['0']);
+
+ count.set(0);
+ this.rerender();
+
+ this.assertHTML('0');
+ this.assertStableRerender();
+ assert.verifySteps(['0']);
+ }
+
+ @test
+ 'value assignment'() {
+ this.assertReactivity(
+ class extends Component {
+ count = trackedValue(0);
+
+ get value() {
+ return this.count.value;
+ }
+
+ update() {
+ this.count.value++;
+ }
+ }
+ );
+ }
+}
+
+jitSuite(TrackedValueTest);
diff --git a/packages/@glimmer/tracking/index.ts b/packages/@glimmer/tracking/index.ts
index 78a4a196e95..1ccf56e7258 100644
--- a/packages/@glimmer/tracking/index.ts
+++ b/packages/@glimmer/tracking/index.ts
@@ -142,6 +142,67 @@ export { cached } from '@ember/-internals/metal/lib/cached';
});
```
+ Calling `tracked` with an initial value creates a standalone reactive
+ value, usable outside of classes:
+
+ ```js
+ import { tracked } from '@glimmer/tracking';
+
+ const count = tracked(0);
+
+ count.value; // read the value, entangling with any tracking context
+ count.value = 1; // write the value, notifying consumers
+ count.get(); // function shorthand for reading
+ count.set(2); // function shorthand for writing
+ count.update((n) => n + 1); // write based on the current value, without entangling
+ count.freeze(); // prevent all future writes
+ ```
+
+ Reading `value` in a template (or in a getter used by a template) will
+ rerender just like a `@tracked` property:
+
+ ```gjs
+ import { tracked } from '@glimmer/tracking';
+ import { on } from '@ember/modifier';
+
+ const count = tracked(0);
+ const increment = () => count.value++;
+
+
+ Count is: {{count.value}}
+
+
+
+ ```
+
+ This form accepts an options object containing an `equals` function, which
+ decides whether a written value should notify consumers (it defaults to
+ `Object.is`), and a `description` used for debugging:
+
+ ```js
+ const count = tracked(0, { equals: (a, b) => a === b });
+
+ count.value = 0; // does not notify consumers, the value did not change
+ ```
+
+ The `@tracked` decorator accepts the same options. By default, setting a
+ `@tracked` property always notifies consumers, even when setting the
+ property to the same value; passing `equals` opts in to equality-based
+ notification instead:
+
+ ```js
+ import { tracked } from '@glimmer/tracking';
+
+ class Counter {
+ @tracked({ equals: (a, b) => a === b }) count = 0;
+
+ noop = () => {
+ // does not notify consumers, the value did not change
+ this.count = this.count;
+ };
+ }
+ ```
+
@method tracked
@static
@for @glimmer/tracking
diff --git a/packages/@glimmer/validator/index.ts b/packages/@glimmer/validator/index.ts
index a3abcaa5007..920b34148d7 100644
--- a/packages/@glimmer/validator/index.ts
+++ b/packages/@glimmer/validator/index.ts
@@ -17,6 +17,12 @@ 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';
+export {
+ type Reactive,
+ type ReadOnlyReactive,
+ TrackedValue,
+ trackedValue,
+} from './lib/tracked-value';
export {
beginTrackFrame,
beginUntrackFrame,
diff --git a/packages/@glimmer/validator/lib/tracked-value.ts b/packages/@glimmer/validator/lib/tracked-value.ts
new file mode 100644
index 00000000000..b08c964c6c7
--- /dev/null
+++ b/packages/@glimmer/validator/lib/tracked-value.ts
@@ -0,0 +1,111 @@
+import type { UpdatableTag } from '@glimmer/interfaces';
+
+import type { ReactiveOptions } from './collections/types';
+
+import { consumeTag } from './tracking';
+import { createUpdatableTag, DIRTY_TAG } from './validators';
+
+/**
+ * A mutable reactive value.
+ *
+ * Reading `value` consumes the underlying tag (entangling with any active
+ * tracking frame), and writing `value` dirties it.
+ */
+export interface Reactive {
+ value: Value;
+}
+
+/**
+ * A reactive value that can only be read.
+ */
+export interface ReadOnlyReactive extends Reactive {
+ readonly value: Value;
+}
+
+export class TrackedValue implements Reactive {
+ #isFrozen = false;
+ #value: Value;
+ readonly #options: ReactiveOptions;
+ readonly #tag: UpdatableTag;
+
+ constructor(value: Value, options: ReactiveOptions) {
+ this.#value = value;
+ this.#options = options;
+ this.#tag = createUpdatableTag();
+ }
+
+ /**
+ * The underlying value.
+ *
+ * Reading entangles with the current tracking frame, and writing notifies
+ * consumers (unless the configured `equals` deems the new value equal to
+ * the current one).
+ */
+ get value(): Value {
+ consumeTag(this.#tag);
+
+ return this.#value;
+ }
+
+ set value(value: Value) {
+ this.set(value);
+ }
+
+ /**
+ * Function short-hand for reading `value`.
+ */
+ get = (): Value => {
+ return this.value;
+ };
+
+ /**
+ * Function short-hand for assigning `value`.
+ *
+ * Returns `true` if the value changed (and consumers were notified),
+ * `false` if the new value was equal to the current one.
+ */
+ set = (value: Value): boolean => {
+ if (this.#isFrozen) {
+ throw new Error(
+ `Cannot update a frozen TrackedValue${
+ this.#options.description ? ` (\`${this.#options.description}\`)` : ''
+ }`
+ );
+ }
+
+ if (this.#options.equals(this.#value, value)) {
+ return false;
+ }
+
+ this.#value = value;
+
+ DIRTY_TAG(this.#tag);
+
+ return true;
+ };
+
+ /**
+ * Update the value based on the current value, without consuming it.
+ */
+ update = (updater: (value: Value) => Value): void => {
+ this.set(updater(this.#value));
+ };
+
+ /**
+ * Prevents further updates, making the TrackedValue behave as a
+ * ReadOnlyReactive.
+ */
+ freeze = (): void => {
+ this.#isFrozen = true;
+ };
+}
+
+export function trackedValue(
+ value: Value,
+ options?: { equals?: (a: Value, b: Value) => boolean; description?: string }
+): TrackedValue {
+ return new TrackedValue(value, {
+ equals: options?.equals ?? Object.is,
+ description: options?.description,
+ });
+}
diff --git a/packages/@glimmer/validator/test/tracked-value-test.ts b/packages/@glimmer/validator/test/tracked-value-test.ts
new file mode 100644
index 00000000000..abcd6c6b63e
--- /dev/null
+++ b/packages/@glimmer/validator/test/tracked-value-test.ts
@@ -0,0 +1,114 @@
+import { track, trackedValue, validateTag, valueForTag } from '@glimmer/validator';
+
+import { module, test } from './-utils';
+
+module('@glimmer/validator: trackedValue()', () => {
+ test('creates reactive storage', (assert) => {
+ const x = trackedValue('hello');
+
+ assert.strictEqual(x.value, 'hello');
+ assert.strictEqual(x.get(), 'hello');
+ });
+
+ test('updates when value is set', (assert) => {
+ const x = trackedValue('hello');
+
+ x.value = 'world';
+ assert.strictEqual(x.value, 'world');
+ });
+
+ test('updates when set() is called', (assert) => {
+ const x = trackedValue('hello');
+
+ assert.true(x.set('world'), 'set() returns true when the value changes');
+ assert.strictEqual(x.value, 'world');
+
+ assert.false(x.set('world'), 'set() returns false when the value is equal');
+ });
+
+ test('updates when update() is called', (assert) => {
+ const x = trackedValue('hello');
+
+ x.update((value) => value + ' world');
+ assert.strictEqual(x.value, 'hello world');
+ });
+
+ test('cannot be updated when frozen', (assert) => {
+ const x = trackedValue('hello');
+
+ x.freeze();
+
+ assert.throws(() => x.set('world'), /frozen/u);
+ assert.throws(() => (x.value = 'world'), /frozen/u);
+ assert.strictEqual(x.value, 'hello');
+ });
+
+ test('reading consumes and writing dirties', (assert) => {
+ const x = trackedValue(0);
+
+ const tag = track(() => x.value);
+ let snapshot = valueForTag(tag);
+
+ assert.true(validateTag(tag, snapshot), 'tag is valid before a change');
+
+ x.value = 1;
+ assert.false(validateTag(tag, snapshot), 'tag is invalidated by a change');
+
+ snapshot = valueForTag(tag);
+ assert.true(validateTag(tag, snapshot), 'tag is valid after snapshotting');
+ });
+
+ test('default equals (Object.is) does not dirty on no-op changes', (assert) => {
+ const x = trackedValue(0);
+
+ const tag = track(() => x.value);
+ const snapshot = valueForTag(tag);
+
+ x.value = 0;
+ assert.true(validateTag(tag, snapshot), 'tag is still valid after setting an equal value');
+ });
+
+ test('options.equals: () => false dirties on every set', (assert) => {
+ const x = trackedValue(0, { equals: () => false });
+
+ const tag = track(() => x.value);
+ const snapshot = valueForTag(tag);
+
+ x.value = 0;
+ assert.false(validateTag(tag, snapshot), 'tag is invalidated by a no-op set');
+ });
+
+ test('options.equals: custom comparisons are respected', (assert) => {
+ const x = trackedValue({ id: 1 }, { equals: (a, b) => a.id === b.id });
+
+ const tag = track(() => x.value);
+ const snapshot = valueForTag(tag);
+
+ x.value = { id: 1 };
+ assert.true(validateTag(tag, snapshot), 'tag is still valid after setting an equal value');
+
+ x.value = { id: 2 };
+ assert.false(validateTag(tag, snapshot), 'tag is invalidated by a different value');
+ });
+
+ test('update() reads without consuming', (assert) => {
+ const x = trackedValue(0);
+
+ const tag = track(() => x.update((value) => value));
+ const snapshot = valueForTag(tag);
+
+ x.value = 1;
+ assert.true(validateTag(tag, snapshot), 'update() did not entangle with the value');
+ });
+
+ test('methods can be detached from the instance', (assert) => {
+ const x = trackedValue(0);
+ const { get, set, update } = x;
+
+ set(1);
+ assert.strictEqual(get(), 1);
+
+ update((value) => value + 1);
+ assert.strictEqual(get(), 2);
+ });
+});
diff --git a/type-tests/@glimmer/tracking-test.ts b/type-tests/@glimmer/tracking-test.ts
new file mode 100644
index 00000000000..5ba022a4b26
--- /dev/null
+++ b/type-tests/@glimmer/tracking-test.ts
@@ -0,0 +1,37 @@
+import { tracked } from '@glimmer/tracking';
+import { expectTypeOf } from 'expect-type';
+
+// ------- standalone form -------
+const count = tracked(0);
+
+expectTypeOf(count.value).toEqualTypeOf();
+expectTypeOf(count.get()).toEqualTypeOf();
+expectTypeOf(count.set(1)).toEqualTypeOf();
+expectTypeOf(count.update((n) => n + 1)).toEqualTypeOf();
+expectTypeOf(count.freeze()).toEqualTypeOf();
+
+// @ts-expect-error -- the value type is inferred from the initial value
+count.set('hello');
+
+// ------- standalone form with options -------
+const greeting = tracked('hello', {
+ equals: (a, b) => a === b,
+ description: 'a greeting',
+});
+
+expectTypeOf(greeting.value).toEqualTypeOf();
+
+// @ts-expect-error -- equals must compare the value type
+tracked(0, { equals: (a: string, b: string) => a === b });
+
+// ------- decorator forms -------
+class Counter {
+ @tracked count = 0;
+}
+
+expectTypeOf(new Counter().count).toEqualTypeOf();
+
+// classic class form returns a decorator
+expectTypeOf(tracked({ value: 'Zoey' })).toMatchTypeOf();
+expectTypeOf(tracked({ initializer: () => 'Zoey' })).toMatchTypeOf();
+expectTypeOf(tracked({ equals: (a: number, b: number) => a === b })).toMatchTypeOf();