diff --git a/packages/@glimmer-workspace/integration-tests/lib/render-test.ts b/packages/@glimmer-workspace/integration-tests/lib/render-test.ts index c79639da6..e0eafc1c9 100644 --- a/packages/@glimmer-workspace/integration-tests/lib/render-test.ts +++ b/packages/@glimmer-workspace/integration-tests/lib/render-test.ts @@ -58,7 +58,7 @@ export class RenderTest implements IRenderTest { testType: ComponentKind = 'unknown'; protected element: SimpleElement; - protected assert = QUnit.assert; + assert = QUnit.assert; protected context: Dict = dict(); protected renderResult: Nullable = null; protected helpers = dict(); @@ -439,7 +439,7 @@ export class RenderTest implements IRenderTest { let key = Array.isArray(items[index]) ? items[index][0] : index; let value = Array.isArray(items[index]) ? items[index][1] : items[index]; - QUnit.assert.equal(el.textContent, `${key}.${value}`); + QUnit.assert.equal(el.textContent, `${key}.${value}`, `Comparing the rendered key.value`); } ); } @@ -497,7 +497,10 @@ export class RenderTest implements IRenderTest { } protected assertEachInReactivity( - Klass: new (...args: any[]) => { collection: number[]; update: () => void } + Klass: new (...args: any[]) => { + collection: (string | number)[] | Map; + update: () => void; + } ) { let instance: TestComponent | undefined; @@ -533,7 +536,9 @@ export class RenderTest implements IRenderTest { let { collection } = instance; this.assertEachCompareResults( - Symbol.iterator in collection ? Array.from(collection) : Object.entries(collection) + Symbol.iterator in collection + ? Array.from(collection as string[]) + : Object.entries(collection) ); instance.update(); @@ -541,12 +546,21 @@ export class RenderTest implements IRenderTest { this.rerender(); this.assertEachCompareResults( - Symbol.iterator in collection ? Array.from(collection) : Object.entries(collection) + Symbol.iterator in collection + ? Array.from(collection as string[]) + : Object.entries(collection) ); } protected assertEachReactivity( - Klass: new (...args: any[]) => { collection: number[]; update: () => void } + Klass: new (...args: any[]) => { + collection: + | (string | number)[] + | Set + | Map + | Record; + update: () => void; + } ) { let instance: TestComponent | undefined; @@ -579,13 +593,19 @@ export class RenderTest implements IRenderTest { throw new Error('The instance is not defined'); } - this.assertEachCompareResults(Array.from(instance.collection).map((v, i) => [i, v])); + function getEntries() { + if (!instance) return []; + + return Array.from(instance.collection as (string | number)[]); + } + + this.assertEachCompareResults((getEntries() as string[]).map((v, i) => [i, v])); instance.update(); this.rerender(); - this.assertEachCompareResults(Array.from(instance.collection).map((v, i) => [i, v])); + this.assertEachCompareResults((getEntries() as string[]).map((v, i) => [i, v])); this.assertStableRerender(); } diff --git a/packages/@glimmer-workspace/integration-tests/test/collections/map-test.ts b/packages/@glimmer-workspace/integration-tests/test/collections/map-test.ts new file mode 100644 index 000000000..c587d2e89 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/collections/map-test.ts @@ -0,0 +1,336 @@ +import { trackedMap } from '@glimmer/validator'; +import { + defineComponent, + GlimmerishComponent as Component, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +class TrackedMapTest extends RenderTest { + static suiteName = `trackedMap() (rendering)`; + + @test + 'options.equals: default equals does not dirty on no-op changes'(assert: Assert) { + const obj = trackedMap([['foo', '123']]); + const step = () => { + assert.step(obj.get('foo') ?? ''); + return obj.get('foo'); + }; + + const Foo = defineComponent({ step, obj }, '{{ (step) }}'); + + this.renderComponent(Foo); + + this.assertHTML('123'); + assert.verifySteps(['123']); + + obj.set('foo', '123'); + this.rerender(); + + this.assertHTML('123'); + this.assertStableRerender(); + assert.verifySteps([]); + } + + @test + 'options.equals: using equals can dirty on every change'(assert: Assert) { + const obj = trackedMap([['foo', '123']], { equals: () => false }); + const step = () => { + assert.step(obj.get('foo') ?? ''); + return obj.get('foo'); + }; + + const Foo = defineComponent({ step, obj }, '{{ (step) }}'); + + this.renderComponent(Foo); + + this.assertHTML('123'); + assert.verifySteps(['123']); + + obj.set('foo', '123'); + this.rerender(); + + this.assertHTML('123'); + this.assertStableRerender(); + assert.verifySteps(['123']); + } + + @test + 'get/set'() { + this.assertReactivity( + class extends Component { + map = trackedMap(); + + get value() { + return this.map.get('foo'); + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + 'get/set existing value'() { + this.assertReactivity( + class extends Component { + map = trackedMap([['foo', 456]]); + + get value() { + return this.map.get('foo'); + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + 'get/set unrelated value'() { + this.assertReactivity( + class extends Component { + map = trackedMap([['foo', 456]]); + + get value() { + return this.map.get('foo'); + } + + update() { + this.map.set('bar', 123); + } + }, + false + ); + } + + @test + has() { + this.assertReactivity( + class extends Component { + map = trackedMap(); + + get value() { + return this.map.has('foo'); + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + entries() { + this.assertReactivity( + class extends Component { + map = trackedMap(); + + get value() { + return this.map.entries(); + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + keys() { + this.assertReactivity( + class extends Component { + map = trackedMap(); + + get value() { + return this.map.keys(); + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + values() { + this.assertReactivity( + class extends Component { + map = trackedMap(); + + get value() { + return this.map.values(); + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + forEach() { + this.assertReactivity( + class extends Component { + map = trackedMap(); + + get value() { + this.map.forEach(() => { + /* no op! */ + }); + return 'test'; + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + size() { + this.assertReactivity( + class extends Component { + map = trackedMap(); + + get value() { + return this.map.size; + } + + update() { + this.map.set('foo', 123); + } + } + ); + } + + @test + delete() { + this.assertReactivity( + class extends Component { + map = trackedMap([['foo', 123]]); + + get value() { + return this.map.get('foo'); + } + + update() { + this.map.delete('foo'); + } + } + ); + } + + @test + 'delete unrelated value'() { + this.assertReactivity( + class extends Component { + map = trackedMap([ + ['foo', 123], + ['bar', 456], + ]); + + get value() { + return this.map.get('foo'); + } + + update() { + this.map.delete('bar'); + } + }, + false + ); + } + + @test + clear() { + this.assertReactivity( + class extends Component { + map = trackedMap([['foo', 123]]); + + get value() { + return this.map.get('foo'); + } + + update() { + this.map.clear(); + } + } + ); + } + + @test + 'each: set'() { + this.assertEachReactivity( + class extends Component { + collection = trackedMap([['foo', 123]]); + update() { + this.collection.set('bar', 456); + } + } + ); + } + + @test + 'each: set matching existing value'() { + this.assertEachReactivity( + class extends Component { + collection = trackedMap([['foo', 123]]); + update() { + this.collection.set('foo', 123); + } + } + ); + } + + @test + 'each: set existing value'() { + this.assertEachReactivity( + class extends Component { + collection = trackedMap([['foo', 123]], { equals: () => false }); + update() { + this.collection.set('foo', 789); + } + } + ); + } + + // SKIPPED for now because glimmer-vm doesn't implement each-in + // @test + 'each-in: set'() { + this.assertEachInReactivity( + class extends Component { + collection = trackedMap([['foo', 123]]); + + update() { + this.collection.set('bar', 456); + } + } + ); + } + + // SKIPPED for now because glimmer-vm doesn't implement each-in + // @test + 'each-in: set existing value'() { + this.assertEachInReactivity( + class extends Component { + collection = trackedMap([['foo', 123]]); + + update() { + this.collection.set('foo', 789); + } + } + ); + } +} + +jitSuite(TrackedMapTest); diff --git a/packages/@glimmer-workspace/integration-tests/test/collections/object-test.ts b/packages/@glimmer-workspace/integration-tests/test/collections/object-test.ts new file mode 100644 index 000000000..7e0bfa8b1 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/collections/object-test.ts @@ -0,0 +1,207 @@ +import { trackedObject } from '@glimmer/validator'; +import { + defineComponent, + GlimmerishComponent as Component, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +class TrackedObjectTest extends RenderTest { + static suiteName = `trackedObject() (rendering)`; + + @test + 'it works when used directly in a template'() { + const obj = trackedObject>({ foo: 123 }); + + const Foo = defineComponent({ obj }, '{{obj.foo}}'); + + this.renderComponent(Foo); + + this.assertHTML('123'); + + obj['foo'] = 456; + this.rerender(); + + this.assertHTML('456'); + this.assertStableRerender(); + } + + @test + 'options.equals: default equals does not dirty on no-op changes'(assert: Assert) { + const obj = trackedObject({ foo: '123' }); + const step = (x: string) => { + assert.step(x); + return x; + }; + + const Foo = defineComponent({ step, obj }, '{{step obj.foo}}'); + + this.renderComponent(Foo); + + this.assertHTML('123'); + assert.verifySteps(['123']); + + obj['foo'] = '123'; + this.rerender(); + + this.assertHTML('123'); + this.assertStableRerender(); + assert.verifySteps([]); + } + + @test + 'options.equals: using equals can dirty on every change'(assert: Assert) { + const obj = trackedObject({ foo: '123' }, { equals: () => false }); + const step = (x: string) => { + assert.step(x); + return x; + }; + + const Foo = defineComponent({ step, obj }, '{{step obj.foo}}'); + + this.renderComponent(Foo); + + this.assertHTML('123'); + assert.verifySteps(['123']); + + obj['foo'] = '123'; + this.rerender(); + + this.assertHTML('123'); + this.assertStableRerender(); + assert.verifySteps(['123']); + } + + @test + 'each: set existing value'() { + this.assertEachReactivity( + class extends Component { + collection = trackedObject({ foo: 123 }, { equals: () => false }); + update() { + this.collection.foo = 789; + } + } + ); + } + + // TODO: glimmer-vm does not implement each-in, so we can't test this just yet + // eachInReactivityTest( + // '{{each-in}} works with new items', + // class extends Component { + // collection = trackedObject>({ + // foo: 123, + // }); + // + // update() { + // this.collection['bar'] = 456; + // } + // } + // ); + // + // eachInReactivityTest( + // '{{each-in}} works when updating old items', + // class extends Component { + // collection = trackedObject({ + // foo: 123, + // }); + // + // update() { + // this.collection.foo = 456; + // } + // } + // ); + + @test + 'it works'() { + this.assertReactivity( + class extends Component { + obj = trackedObject<{ foo?: number }>(); + + get value() { + return this.obj['foo']; + } + + update() { + this.obj['foo'] = 123; + } + } + ); + } + + @test + 'in operator works'() { + this.assertReactivity( + class extends Component { + obj = trackedObject<{ foo?: number }>(); + + get value() { + return 'foo' in this.obj; + } + + update() { + this.obj['foo'] = 123; + } + } + ); + } + + @test + 'for in works'() { + this.assertReactivity( + class extends Component { + obj = trackedObject<{ foo?: number }>(); + + get value() { + let keys = []; + + for (let key in this.obj) { + keys.push(key); + } + + return keys; + } + + update() { + this.obj['foo'] = 123; + } + } + ); + } + + @test + 'Object.keys works'() { + this.assertReactivity( + class extends Component { + obj = trackedObject<{ foo?: number }>(); + + get value() { + return Object.keys(this.obj); + } + + update() { + this.obj['foo'] = 123; + } + } + ); + } + + @test + 'delete works'() { + this.assertReactivity( + class extends Component { + obj: { foo?: number } = trackedObject({ foo: 1 }); + + get value() { + return this.obj.foo; + } + + update() { + delete this.obj.foo; + } + } + ); + } +} + +jitSuite(TrackedObjectTest); diff --git a/packages/@glimmer-workspace/integration-tests/test/collections/set-test.ts b/packages/@glimmer-workspace/integration-tests/test/collections/set-test.ts new file mode 100644 index 000000000..7070f814b --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/collections/set-test.ts @@ -0,0 +1,412 @@ +import { trackedSet } from '@glimmer/validator'; +import { + defineComponent, + GlimmerishComponent as Component, + jitSuite, + RenderTest, + strip, + test, +} from '@glimmer-workspace/integration-tests'; + +class TrackedSetTest extends RenderTest { + static suiteName = `trackedSet() (rendering)`; + + @test + 'options.equals: default equals does not dirty on no-op changes'(assert: Assert) { + let key = { foo: '123' }; + const obj = trackedSet([key]); + const step = () => { + let str = String(obj.has(key)); + assert.step(str); + return str; + }; + + const Foo = defineComponent({ step }, '{{(step)}}'); + + this.renderComponent(Foo); + + this.assertHTML('true'); + assert.verifySteps(['true']); + + obj.add(key); + this.rerender(); + + this.assertHTML('true'); + this.assertStableRerender(); + assert.verifySteps([]); + } + + @test + 'options.equals: using equals can dirty on every change'(assert: Assert) { + let key = { foo: '123' }; + const obj = trackedSet([key], { equals: () => false }); + const step = () => { + let str = String(obj.has(key)); + assert.step(str); + return str; + }; + + const Foo = defineComponent({ step }, '{{(step)}}'); + + this.renderComponent(Foo); + + this.assertHTML('true'); + assert.verifySteps(['true']); + + obj.add(key); + this.rerender(); + + this.assertHTML('true'); + this.assertStableRerender(); + assert.verifySteps(['true']); + } + + @test + 'add/has'() { + this.assertReactivity( + class extends Component { + set = trackedSet(); + + get value() { + return this.set.has('foo'); + } + + update() { + this.set.add('foo'); + } + } + ); + } + + @test + 'add/has existing value'() { + this.assertReactivity( + class extends Component { + set = trackedSet(['foo']); + + get value() { + return this.set.has('foo'); + } + + update() { + this.set.add('foo'); + } + }, + false + ); + } + + @test + 'add/has existing value (with always-dirty)'() { + this.assertReactivity( + class extends Component { + set = trackedSet(['foo'], { equals: () => false }); + + get value() { + return this.set.has('foo'); + } + + update() { + this.set.add('foo'); + } + } + ); + } + + @test + 'add/has unrelated value'() { + this.assertReactivity( + class extends Component { + set = trackedSet(); + + get value() { + return this.set.has('foo'); + } + + update() { + this.set.add('bar'); + } + }, + false + ); + } + + @test + entries() { + this.assertReactivity( + class extends Component { + set = trackedSet(); + + get value() { + return this.set.entries(); + } + + update() { + this.set.add('foo'); + } + } + ); + } + + @test + keys() { + this.assertReactivity( + class extends Component { + set = trackedSet(); + + get value() { + return this.set.keys(); + } + + update() { + this.set.add('foo'); + } + } + ); + } + + @test + values() { + this.assertReactivity( + class extends Component { + set = trackedSet(); + + get value() { + return this.set.values(); + } + + update() { + this.set.add('foo'); + } + } + ); + } + + @test + forEach() { + this.assertReactivity( + class extends Component { + set = trackedSet(); + + get value() { + this.set.forEach(() => { + /* no-op */ + }); + return 'test'; + } + + update() { + this.set.add('foo'); + } + } + ); + } + + @test + size() { + this.assertReactivity( + class extends Component { + set = trackedSet(); + + get value() { + return this.set.size; + } + + update() { + this.set.add('foo'); + } + } + ); + } + + @test + delete() { + this.assertReactivity( + class extends Component { + set = trackedSet(['foo', 123]); + + get value() { + return this.set.has('foo'); + } + + update() { + this.set.delete('foo'); + } + } + ); + } + + @test + 'delete unrelated value'() { + this.assertReactivity( + class extends Component { + set = trackedSet(['foo', 123]); + + get value() { + return this.set.has('foo'); + } + + update() { + this.set.delete(123); + } + }, + false + ); + } + + @test + clear() { + this.assertReactivity( + class extends Component { + set = trackedSet(['foo', 123]); + + get value() { + return this.set.has('foo'); + } + + update() { + this.set.clear(); + } + } + ); + } + + @test + 'each: add'() { + this.assertEachReactivity( + class extends Component { + collection = trackedSet(['foo', 123]); + + update() { + this.collection.add('bar'); + } + } + ); + } + + @test + 'each: add existing value'() { + this.assertEachReactivity( + class extends Component { + collection = trackedSet(['foo', 123]); + + update() { + this.collection.add('foo'); + } + } + ); + } + + // TODO: These tests are currently unstable on release, turn back on once + // behavior is fixed + // eachInReactivityTest( + // 'add', + // class extends Component { + // collection = trackedSet(['foo', 123]); + // update() { + // this.collection.add('bar'); + // } + // } + // ); + // eachInReactivityTest( + // 'add existing value', + // class extends Component { + // collection = trackedSet(['foo', 123]); + // update() { + // this.collection.add('foo'); + // } + // } + // ); + + /** + * + * If any rendered change occurs at all, that's a success + * + */ + @test + union() { + this.#testIfChange.call(this, { + op: (a: Set, b: Set) => { + return a.union(b); + }, + change: (x: Set) => x.add('another'), + }); + } + + @test + intersection() { + this.#testIfChange.call(this, { + op: (a: Set, b: Set) => { + return a.intersection(b); + }, + change: (x: Set) => x.add('another'), + }); + } + + @test + difference() { + this.#testIfChange.call(this, { + op: (a: Set, b: Set) => { + return a.difference(b); + }, + change: (x: Set) => x.add('another'), + }); + } + + @test + symmetricDifference() { + this.#testIfChange.call(this, { + op: (a: Set, b: Set) => { + return a.symmetricDifference(b); + }, + change: (x: Set) => x.add('another'), + }); + } + + #testIfChange({ + op, + change, + }: { + change: (x: Set) => void; + op: (a: Set, b: Set) => Set; + }) { + let a = trackedSet(['123']); + let b = trackedSet(['abc']); + + let renderedSet = new Set(); + let steps: string[] = []; + + function verifySteps(s: string[]) { + QUnit.assert.deepEqual(steps, s); + steps = []; + } + function stepRecord(set: Set) { + renderedSet = set; + steps.push(String(set.size)); + } + + const Foo = defineComponent( + { stepRecord, op, a, b }, + strip` + {{#let (op a b) as |c|}} + {{stepRecord c}} + {{/let}} + ` + ); + + this.renderComponent(Foo); + verifySteps([String(renderedSet.size)]); + + change(a); + + this.rerender(); + verifySteps([String(renderedSet.size)]); + + change(b); + + this.rerender(); + verifySteps([String(renderedSet.size)]); + } +} + +jitSuite(TrackedSetTest); diff --git a/packages/@glimmer-workspace/integration-tests/test/collections/weak-map-test.ts b/packages/@glimmer-workspace/integration-tests/test/collections/weak-map-test.ts new file mode 100644 index 000000000..14c796a99 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/collections/weak-map-test.ts @@ -0,0 +1,213 @@ +import { trackedWeakMap } from '@glimmer/validator'; +import { + defineComponent, + GlimmerishComponent as Component, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +class TrackedWeakMapTest extends RenderTest { + static suiteName = `trackedWeakMap() (rendering)`; + + @test + 'options.equals: default equals does not dirty on no-op changes'(assert: Assert) { + const key = {}; + const obj = trackedWeakMap([[key, '123']]); + + const step = () => { + assert.step(obj.get(key) ?? ''); + return obj.get(key); + }; + + const Foo = defineComponent({ step, obj }, '{{ (step) }}'); + + this.renderComponent(Foo); + + this.assertHTML('123'); + assert.verifySteps(['123']); + + obj.set(key, '123'); + this.rerender(); + + this.assertHTML('123'); + this.assertStableRerender(); + assert.verifySteps([]); + } + + @test + 'options.equals: using equals can dirty on every change'(assert: Assert) { + const key = {}; + const obj = trackedWeakMap([[key, '123']], { equals: () => false }); + + const step = () => { + assert.step(obj.get(key) ?? ''); + return obj.get(key); + }; + + const Foo = defineComponent({ step, obj }, '{{ (step) }}'); + + this.renderComponent(Foo); + + this.assertHTML('123'); + assert.verifySteps(['123']); + + obj.set(key, '123'); + this.rerender(); + + this.assertHTML('123'); + this.assertStableRerender(); + assert.verifySteps(['123']); + } + + @test + 'get/set'() { + this.assertReactivity( + class extends Component { + obj = {}; + map = trackedWeakMap(); + + get value() { + return this.map.get(this.obj); + } + + update() { + this.map.set(this.obj, 123); + } + } + ); + } + + @test + 'get/set existing value'() { + this.assertReactivity( + class extends Component { + obj = {}; + map = trackedWeakMap([[this.obj, 456]]); + + get value() { + return this.map.get(this.obj); + } + + update() { + this.map.set(this.obj, 123); + } + } + ); + } + + @test + 'get/set existing value set to the same value'() { + this.assertReactivity( + class extends Component { + obj = {}; + map = trackedWeakMap([[this.obj, 456]]); + + get value() { + return this.map.get(this.obj); + } + + update() { + this.map.set(this.obj, 456); + } + }, + false + ); + } + + @test + 'get/set existing value set to the same value (always dirtying)'() { + this.assertReactivity( + class extends Component { + obj = {}; + map = trackedWeakMap([[this.obj, 456]], { equals: () => false }); + + get value() { + return this.map.get(this.obj); + } + + update() { + this.map.set(this.obj, 456); + } + } + ); + } + + @test + 'get/set unrelated value'() { + this.assertReactivity( + class extends Component { + obj = {}; + obj2 = {}; + map = trackedWeakMap([[this.obj, 456]]); + + get value() { + return this.map.get(this.obj); + } + + update() { + this.map.set(this.obj2, 123); + } + }, + false + ); + } + + @test has() { + this.assertReactivity( + class extends Component { + obj = {}; + map = trackedWeakMap(); + + get value() { + return this.map.has(this.obj); + } + + update() { + this.map.set(this.obj, 123); + } + } + ); + } + + @test delete() { + this.assertReactivity( + class extends Component { + obj = {}; + map = trackedWeakMap([[this.obj, 123]]); + + get value() { + return this.map.get(this.obj); + } + + update() { + this.map.delete(this.obj); + } + } + ); + } + + @test 'delete unrelated value'() { + this.assertReactivity( + class extends Component { + obj = {}; + obj2 = {}; + map = trackedWeakMap([ + [this.obj, 123], + [this.obj2, 456], + ]); + + get value() { + return this.map.get(this.obj); + } + + update() { + this.map.delete(this.obj2); + } + }, + false + ); + } +} + +jitSuite(TrackedWeakMapTest); diff --git a/packages/@glimmer-workspace/integration-tests/test/collections/weak-set-test.ts b/packages/@glimmer-workspace/integration-tests/test/collections/weak-set-test.ts new file mode 100644 index 000000000..16127f3f3 --- /dev/null +++ b/packages/@glimmer-workspace/integration-tests/test/collections/weak-set-test.ts @@ -0,0 +1,178 @@ +import { trackedWeakSet } from '@glimmer/validator'; +import { + defineComponent, + GlimmerishComponent as Component, + jitSuite, + RenderTest, + test, +} from '@glimmer-workspace/integration-tests'; + +class TrackedWeakSetTest extends RenderTest { + static suiteName = `trackedWeakSet() (rendering)`; + + @test + 'options.equals: default equals does not dirty on no-op changes'(assert: Assert) { + let key = { foo: '123' }; + const obj = trackedWeakSet([key]); + const step = () => { + let str = String(obj.has(key)); + assert.step(str); + return str; + }; + + const Foo = defineComponent({ step }, '{{(step)}}'); + + this.renderComponent(Foo); + + this.assertHTML('true'); + assert.verifySteps(['true']); + + obj.add(key); + this.rerender(); + + this.assertHTML('true'); + this.assertStableRerender(); + assert.verifySteps([]); + } + + @test + 'options.equals: using equals can dirty on every change'(assert: Assert) { + let key = { foo: '123' }; + const obj = trackedWeakSet([key], { equals: () => false }); + const step = () => { + let str = String(obj.has(key)); + assert.step(str); + return str; + }; + + const Foo = defineComponent({ step }, '{{(step)}}'); + + this.renderComponent(Foo); + + this.assertHTML('true'); + assert.verifySteps(['true']); + + obj.add(key); + this.rerender(); + + this.assertHTML('true'); + this.assertStableRerender(); + assert.verifySteps(['true']); + } + + @test + 'add/has'() { + this.assertReactivity( + class extends Component { + obj = {}; + set = trackedWeakSet(); + + get value() { + return this.set.has(this.obj); + } + + update() { + this.set.add(this.obj); + } + } + ); + } + + @test + 'add/has existing value'() { + this.assertReactivity( + class extends Component { + obj = {}; + set = trackedWeakSet([this.obj]); + + get value() { + return this.set.has(this.obj); + } + + update() { + this.set.add(this.obj); + } + }, + false + ); + } + + @test + 'add/has existing value (always invalidates)'() { + this.assertReactivity( + class extends Component { + obj = {}; + set = trackedWeakSet([this.obj], { equals: () => false }); + + get value() { + return this.set.has(this.obj); + } + + update() { + this.set.add(this.obj); + } + } + ); + } + + @test + 'add/has unrelated value'() { + this.assertReactivity( + class extends Component { + obj = {}; + obj2 = {}; + set = trackedWeakSet(); + + get value() { + return this.set.has(this.obj); + } + + update() { + this.set.add(this.obj2); + } + }, + false + ); + } + + @test + delete() { + this.assertReactivity( + class extends Component { + obj = {}; + obj2 = {}; + set = trackedWeakSet([this.obj, this.obj2]); + + get value() { + return this.set.has(this.obj); + } + + update() { + this.set.delete(this.obj); + } + } + ); + } + + @test + 'delete unrelated value'() { + this.assertReactivity( + class extends Component { + obj = {}; + obj2 = {}; + set = trackedWeakSet([this.obj, this.obj2]); + + get value() { + return this.set.has(this.obj); + } + + update() { + this.set.delete(this.obj2); + } + }, + false + ); + } +} + +jitSuite(TrackedWeakSetTest); diff --git a/packages/@glimmer/validator/index.ts b/packages/@glimmer/validator/index.ts index 7c3491c79..a3abcaa50 100644 --- a/packages/@glimmer/validator/index.ts +++ b/packages/@glimmer/validator/index.ts @@ -9,6 +9,11 @@ if (Reflect.has(globalThis, GLIMMER_VALIDATOR_REGISTRATION)) { Reflect.set(globalThis, GLIMMER_VALIDATOR_REGISTRATION, true); export { trackedArray } from './lib/collections/array'; +export { trackedMap } from './lib/collections/map'; +export { trackedObject } from './lib/collections/object'; +export { trackedSet } from './lib/collections/set'; +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'; diff --git a/packages/@glimmer/validator/lib/collections/map.ts b/packages/@glimmer/validator/lib/collections/map.ts new file mode 100644 index 000000000..2b36e868f --- /dev/null +++ b/packages/@glimmer/validator/lib/collections/map.ts @@ -0,0 +1,171 @@ +import type { ReactiveOptions } from './types'; + +import { consumeTag } from '../tracking'; +import { createUpdatableTag, DIRTY_TAG } from '../validators'; + +class TrackedMap implements Map { + #options: ReactiveOptions; + #collection = createUpdatableTag(); + #storages = new Map>(); + #vals: Map; + + #storageFor(key: K): ReturnType { + const storages = this.#storages; + let storage = storages.get(key); + + if (storage === undefined) { + storage = createUpdatableTag(); + storages.set(key, storage); + } + + return storage; + } + #dirtyStorageFor(key: K): void { + const storage = this.#storages.get(key); + + if (storage) { + DIRTY_TAG(storage); + } + } + + constructor( + existing: readonly (readonly [K, V])[] | Iterable | null | Map, + options: ReactiveOptions + ) { + // TypeScript doesn't correctly resolve the overloads for calling the `Map` + // constructor for the no-value constructor. This resolves that. + this.#vals = existing instanceof Map ? new Map(existing.entries()) : new Map(existing); + this.#options = options; + } + + get(key: K): V | undefined { + consumeTag(this.#storageFor(key)); + + return this.#vals.get(key); + } + + has(key: K): boolean { + consumeTag(this.#storageFor(key)); + + return this.#vals.has(key); + } + + // **** ALL GETTERS **** + entries() { + consumeTag(this.#collection); + + return this.#vals.entries(); + } + + keys() { + consumeTag(this.#collection); + + return this.#vals.keys(); + } + + values() { + consumeTag(this.#collection); + + return this.#vals.values(); + } + + forEach(fn: (value: V, key: K, map: Map) => void): void { + consumeTag(this.#collection); + + this.#vals.forEach(fn); + } + + get size(): number { + consumeTag(this.#collection); + + return this.#vals.size; + } + + /** + * When iterating: + * - we entangle with the collection (as we iterate over the whole thing + * - for each individual item, we entangle with the item as well + */ + [Symbol.iterator]() { + let keys = this.keys(); + // eslint-disable-next-line @typescript-eslint/no-this-alias + let self = this; + + return { + next() { + let next = keys.next(); + let currentKey = next.value; + + if (next.done) { + return { value: [undefined, undefined], done: true }; + } + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + return { value: [currentKey, self.get(currentKey!)], done: false }; + }, + } as MapIterator<[K, V]>; + } + + get [Symbol.toStringTag](): string { + return this.#vals[Symbol.toStringTag]; + } + + set(key: K, value: V): this { + let existing = this.#vals.get(key); + + if (existing) { + let isUnchanged = this.#options.equals(existing, value); + + if (isUnchanged) { + return this; + } + } + + this.#dirtyStorageFor(key); + + if (!existing) { + DIRTY_TAG(this.#collection); + } + + this.#vals.set(key, value); + + return this; + } + + delete(key: K): boolean { + if (!this.#vals.has(key)) return true; + + this.#dirtyStorageFor(key); + DIRTY_TAG(this.#collection); + + this.#storages.delete(key); + return this.#vals.delete(key); + } + + clear(): void { + if (this.#vals.size === 0) return; + + this.#storages.forEach((s) => DIRTY_TAG(s)); + this.#storages.clear(); + + DIRTY_TAG(this.#collection); + this.#vals.clear(); + } +} + +// So instanceof works +Object.setPrototypeOf(TrackedMap.prototype, Map.prototype); + +export function trackedMap( + data?: + | Map + | Iterable + | readonly (readonly [Key, Value])[] + | null, + options?: { equals?: (a: Value, b: Value) => boolean; description?: string } +): Map { + return new TrackedMap(data ?? [], { + equals: options?.equals ?? Object.is, + description: options?.description, + }); +} diff --git a/packages/@glimmer/validator/lib/collections/object.ts b/packages/@glimmer/validator/lib/collections/object.ts new file mode 100644 index 000000000..b91421d74 --- /dev/null +++ b/packages/@glimmer/validator/lib/collections/object.ts @@ -0,0 +1,125 @@ +import type { ReactiveOptions } from './types'; + +import { consumeTag } from '../tracking'; +import { createUpdatableTag, DIRTY_TAG } from '../validators'; + +class TrackedObject> { + #options: ReactiveOptions; + #storages = new Map>(); + #collection = createUpdatableTag(); + + #readStorageFor(key: PropertyKey) { + let storage = this.#storages.get(key); + + if (storage === undefined) { + storage = createUpdatableTag(); + this.#storages.set(key, storage); + } + + consumeTag(storage); + } + + #dirtyStorageFor(key: PropertyKey) { + const storage = this.#storages.get(key); + + if (storage) { + DIRTY_TAG(storage); + } + } + + #dirtyCollection() { + DIRTY_TAG(this.#collection); + } + + /** + * This implementation of trackedObject is far too dynamic for TS to be happy with + */ + constructor(obj: ObjectType, options: ReactiveOptions) { + this.#options = options; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const proto = Object.getPrototypeOf(obj); + const descs = Object.getOwnPropertyDescriptors(obj); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + const clone = Object.create(proto) as ObjectType; + + for (const prop in descs) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + Object.defineProperty(clone, prop, descs[prop]!); + } + + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + return new Proxy(clone, { + get(target, prop) { + self.#readStorageFor(prop); + + return target[prop as keyof ObjectType]; + }, + + has(target, prop) { + self.#readStorageFor(prop); + + return prop in target; + }, + + ownKeys(target: ObjectType) { + consumeTag(self.#collection); + + return Reflect.ownKeys(target); + }, + + set(target, prop, value) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + let isUnchanged = self.#options.equals(target[prop as keyof ObjectType], value); + + if (isUnchanged) { + return true; + } + + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + target[prop as keyof ObjectType] = value; + + self.#dirtyStorageFor(prop); + self.#dirtyCollection(); + + return true; + }, + + deleteProperty(target, prop) { + if (prop in target) { + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete target[prop as keyof ObjectType]; + self.#dirtyStorageFor(prop); + self.#storages.delete(prop); + self.#dirtyCollection(); + } + + return true; + }, + + getPrototypeOf() { + return TrackedObject.prototype; + }, + }) as TrackedObject; + } +} + +export function trackedObject( + data?: ObjectType, + options?: { + equals?: (a: ObjectType[keyof ObjectType], b: ObjectType[keyof ObjectType]) => boolean; + description?: string; + } +): ObjectType { + return new TrackedObject(data ?? ({} as ObjectType), { + equals: options?.equals ?? Object.is, + description: options?.description, + /** + * SAFETY: we are trying to mimic the same behavior as a plain object, so if anything about + * the object that is returned behaves differently from a native object in a surprising + * way, we should fix that and make the behavior match native objects. + */ + }) as unknown as ObjectType; +} diff --git a/packages/@glimmer/validator/lib/collections/set.ts b/packages/@glimmer/validator/lib/collections/set.ts new file mode 100644 index 000000000..a1505eeaf --- /dev/null +++ b/packages/@glimmer/validator/lib/collections/set.ts @@ -0,0 +1,175 @@ +import type { ReactiveOptions } from './types'; + +import { consumeTag } from '../tracking'; +import { createUpdatableTag, DIRTY_TAG } from '../validators'; + +class TrackedSet implements Set { + #options: ReactiveOptions; + #collection = createUpdatableTag(); + #storages = new Map>(); + #vals: Set; + + #storageFor(key: T): ReturnType { + const storages = this.#storages; + let storage = storages.get(key); + + if (storage === undefined) { + storage = createUpdatableTag(); + storages.set(key, storage); + } + + return storage; + } + + #dirtyStorageFor(key: T): void { + const storage = this.#storages.get(key); + + if (storage) { + DIRTY_TAG(storage); + } + } + + constructor(existing: Iterable, options: ReactiveOptions) { + this.#vals = new Set(existing); + this.#options = options; + } + + // **** KEY GETTERS **** + has(value: T): boolean { + consumeTag(this.#storageFor(value)); + + return this.#vals.has(value); + } + + // **** ALL GETTERS **** + entries() { + consumeTag(this.#collection); + + return this.#vals.entries(); + } + + keys() { + consumeTag(this.#collection); + + return this.#vals.keys(); + } + + values() { + consumeTag(this.#collection); + + return this.#vals.values(); + } + + union(other: ReadonlySetLike): Set { + consumeTag(this.#collection); + + return this.#vals.union(other); + } + + intersection(other: ReadonlySetLike): Set { + consumeTag(this.#collection); + + return this.#vals.intersection(other); + } + + difference(other: ReadonlySetLike): Set { + consumeTag(this.#collection); + + return this.#vals.difference(other); + } + + symmetricDifference(other: ReadonlySetLike): Set { + consumeTag(this.#collection); + + return this.#vals.symmetricDifference(other); + } + + isSubsetOf(other: ReadonlySetLike): boolean { + consumeTag(this.#collection); + + return this.#vals.isSubsetOf(other); + } + + isSupersetOf(other: ReadonlySetLike): boolean { + consumeTag(this.#collection); + + return this.#vals.isSupersetOf(other); + } + + isDisjointFrom(other: ReadonlySetLike): boolean { + consumeTag(this.#collection); + + return this.#vals.isDisjointFrom(other); + } + + forEach(fn: (value1: T, value2: T, set: Set) => void): void { + consumeTag(this.#collection); + + this.#vals.forEach(fn); + } + + get size(): number { + consumeTag(this.#collection); + + return this.#vals.size; + } + + [Symbol.iterator]() { + consumeTag(this.#collection); + + return this.#vals[Symbol.iterator](); + } + + get [Symbol.toStringTag](): string { + return this.#vals[Symbol.toStringTag]; + } + + add(value: T): this { + if (this.#vals.has(value)) { + let isUnchanged = this.#options.equals(value, value); + if (isUnchanged) return this; + } else { + DIRTY_TAG(this.#collection); + } + + this.#dirtyStorageFor(value); + + this.#vals.add(value); + + return this; + } + + delete(value: T): boolean { + if (!this.#vals.has(value)) return true; + + this.#dirtyStorageFor(value); + DIRTY_TAG(this.#collection); + + this.#storages.delete(value); + return this.#vals.delete(value); + } + + // **** ALL SETTERS **** + clear(): void { + if (this.#vals.size === 0) return; + + this.#storages.forEach((s) => DIRTY_TAG(s)); + DIRTY_TAG(this.#collection); + + this.#storages.clear(); + this.#vals.clear(); + } +} + +// So instanceof works +Object.setPrototypeOf(TrackedSet.prototype, Set.prototype); + +export function trackedSet( + data?: Set | Value[] | Iterable | null, + options?: { equals?: (a: Value, b: Value) => boolean; description?: string } +): Set { + return new TrackedSet(data ?? [], { + equals: options?.equals ?? Object.is, + description: options?.description, + }); +} diff --git a/packages/@glimmer/validator/lib/collections/types.ts b/packages/@glimmer/validator/lib/collections/types.ts new file mode 100644 index 000000000..dc8192677 --- /dev/null +++ b/packages/@glimmer/validator/lib/collections/types.ts @@ -0,0 +1,4 @@ +export interface ReactiveOptions { + equals: (a: Value, b: Value) => boolean; + description: string | undefined; +} diff --git a/packages/@glimmer/validator/lib/collections/weak-map.ts b/packages/@glimmer/validator/lib/collections/weak-map.ts new file mode 100644 index 000000000..1b0dc976c --- /dev/null +++ b/packages/@glimmer/validator/lib/collections/weak-map.ts @@ -0,0 +1,96 @@ +import type { ReactiveOptions } from './types'; + +import { consumeTag } from '../tracking'; +import { createUpdatableTag, DIRTY_TAG } from '../validators'; + +class TrackedWeakMap implements WeakMap { + #options: ReactiveOptions; + #storages = new WeakMap>(); + #vals: WeakMap; + + #storageFor(key: K): ReturnType { + let storage = this.#storages.get(key); + + if (storage === undefined) { + storage = createUpdatableTag(); + this.#storages.set(key, storage); + } + + return storage; + } + #dirtyStorageFor(key: K): void { + const storage = this.#storages.get(key); + + if (storage) { + DIRTY_TAG(storage); + } + } + + constructor( + existing: [K, V][] | Iterable | WeakMap, + options: ReactiveOptions + ) { + /** + * SAFETY: note that wehn passing in an existing weak map, we can't + * clone it as it is not iterable and not a supported type of structuredClone + */ + this.#vals = existing instanceof WeakMap ? existing : new WeakMap(existing); + this.#options = options; + } + + get(key: K): V | undefined { + consumeTag(this.#storageFor(key)); + + return this.#vals.get(key); + } + + has(key: K): boolean { + consumeTag(this.#storageFor(key)); + + return this.#vals.has(key); + } + + set(key: K, value: V): this { + let existing = this.#vals.get(key); + + if (existing) { + let isUnchanged = this.#options.equals(existing, value); + + if (isUnchanged) { + return this; + } + } + + this.#dirtyStorageFor(key); + + this.#vals.set(key, value); + + return this; + } + + delete(key: K): boolean { + if (!this.#vals.has(key)) return true; + + this.#dirtyStorageFor(key); + + this.#storages.delete(key); + return this.#vals.delete(key); + } + + get [Symbol.toStringTag](): string { + return this.#vals[Symbol.toStringTag]; + } +} + +// So instanceof works +Object.setPrototypeOf(TrackedWeakMap.prototype, WeakMap.prototype); + +export function trackedWeakMap( + data?: WeakMap | [Key, Value][] | Iterable | null, + options?: { equals?: (a: Value, b: Value) => boolean; description?: string } +): WeakMap { + return new TrackedWeakMap(data ?? [], { + equals: options?.equals ?? Object.is, + description: options?.description, + }); +} diff --git a/packages/@glimmer/validator/lib/collections/weak-set.ts b/packages/@glimmer/validator/lib/collections/weak-set.ts new file mode 100644 index 000000000..a22975389 --- /dev/null +++ b/packages/@glimmer/validator/lib/collections/weak-set.ts @@ -0,0 +1,105 @@ +import { consumeTag } from '../tracking'; +import { createUpdatableTag, DIRTY_TAG } from '../validators'; + +class TrackedWeakSet implements WeakSet { + #options: { equals: (a: T, b: T) => boolean; description: string | undefined }; + #storages = new WeakMap>(); + #vals: WeakSet; + + #storageFor(key: T): ReturnType { + let storage = this.#storages.get(key); + + if (storage === undefined) { + storage = createUpdatableTag(); + this.#storages.set(key, storage); + } + + return storage; + } + + #dirtyStorageFor(key: T): void { + const storage = this.#storages.get(key); + + if (storage) { + DIRTY_TAG(storage); + } + } + + constructor( + values: readonly T[], + options: { equals: (a: T, b: T) => boolean; description: string | undefined } + ) { + this.#options = options; + this.#vals = new WeakSet(values); + } + + has(value: T): boolean { + consumeTag(this.#storageFor(value)); + + return this.#vals.has(value); + } + + add(value: T): this { + /** + * In a WeakSet, there is no `.get()`, but if there was, + * we could assume it's the same value as what we passed. + * + * So for a WeakSet, if we try to add something that already exists + * we no-op. + * + * WeakSet already does this internally for us, + * but we want the ability for the reactive behavior to reflect the same behavior. + * + * i.e.: doing weakSet.add(value) should never dirty with the defaults + * if the `value` is already in the weakSet + */ + if (this.#vals.has(value)) { + /** + * This looks a little silly, where a always will === b, + * but see the note above. + */ + let isUnchanged = this.#options.equals(value, value); + if (isUnchanged) return this; + } + + // Add to vals first to get better error message + this.#vals.add(value); + + this.#dirtyStorageFor(value); + + return this; + } + + delete(value: T): boolean { + if (!this.#vals.has(value)) return true; + + this.#dirtyStorageFor(value); + + this.#storages.delete(value); + return this.#vals.delete(value); + } + + get [Symbol.toStringTag](): string { + return this.#vals[Symbol.toStringTag]; + } +} + +// So instanceof works +Object.setPrototypeOf(TrackedWeakSet.prototype, WeakSet.prototype); + +/** + * NOTE: we cannot pass a WeakSet because WeakSets are not iterable + */ +/** + * Creates an instanceof WeakSet from an optional list of entries + * + */ +export function trackedWeakSet( + data?: Value[], + options?: { equals?: (a: Value, b: Value) => boolean; description?: string } +): WeakSet { + return new TrackedWeakSet(data ?? [], { + equals: options?.equals ?? Object.is, + description: options?.description, + }); +} diff --git a/packages/@glimmer/validator/test/collections/map-test.ts b/packages/@glimmer/validator/test/collections/map-test.ts new file mode 100644 index 000000000..add102db7 --- /dev/null +++ b/packages/@glimmer/validator/test/collections/map-test.ts @@ -0,0 +1,164 @@ +import { trackedMap } from '@glimmer/validator'; +import { expectTypeOf } from 'expect-type'; + +import { module, test } from '../-utils'; + +expectTypeOf>>().toMatchTypeOf>(); + +module('@glimmer/validator: trackedMap', function () { + test('constructor', (assert) => { + const map = trackedMap([['foo', 123]]); + + assert.strictEqual(map.get('foo'), 123); + assert.strictEqual(map.size, 1); + assert.ok(map instanceof Map); + + const map2 = trackedMap(map); + assert.strictEqual(map2.get('foo'), 123); + assert.strictEqual(map2.size, 1); + assert.ok(map2 instanceof Map); + }); + + test('works with all kinds of keys', (assert) => { + // Spoiler: they are needed, as without them, types are inferred + // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-arguments + const map = trackedMap([ + ['foo', 123], + [{}, {}], + [ + () => { + /* no op! */ + }, + 'bar', + ], + [123, true], + [true, false], + [null, null], + ]); + + assert.strictEqual(map.size, 6); + }); + + test('get/set', (assert) => { + const map = trackedMap(); + + map.set('foo', 123); + assert.strictEqual(map.get('foo'), 123); + + map.set('foo', 456); + assert.strictEqual(map.get('foo'), 456); + }); + + test('has', (assert) => { + const map = trackedMap(); + + assert.false(map.has('foo')); + map.set('foo', 123); + assert.true(map.has('foo')); + }); + + test('entries', (assert) => { + const map = trackedMap(); + map.set(0, 1); + map.set(1, 2); + map.set(2, 3); + + const iter = map.entries(); + + assert.deepEqual(iter.next().value, [0, 1]); + assert.deepEqual(iter.next().value, [1, 2]); + assert.deepEqual(iter.next().value, [2, 3]); + assert.true(iter.next().done); + }); + + test('keys', (assert) => { + const map = trackedMap(); + map.set(0, 1); + map.set(1, 2); + map.set(2, 3); + + const iter = map.keys(); + + assert.strictEqual(iter.next().value, 0); + assert.strictEqual(iter.next().value, 1); + assert.strictEqual(iter.next().value, 2); + assert.true(iter.next().done); + }); + + test('values', (assert) => { + const map = trackedMap(); + map.set(0, 1); + map.set(1, 2); + map.set(2, 3); + + const iter = map.values(); + + assert.strictEqual(iter.next().value, 1); + assert.strictEqual(iter.next().value, 2); + assert.strictEqual(iter.next().value, 3); + assert.true(iter.next().done); + }); + + test('forEach', (assert) => { + const map = trackedMap(); + map.set(0, 1); + map.set(1, 2); + map.set(2, 3); + + let count = 0; + let values = ''; + + map.forEach((v, k) => { + count++; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + values += k; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + values += v; + }); + + assert.strictEqual(count, 3); + assert.strictEqual(values, '011223'); + }); + + test('size', (assert) => { + const map = trackedMap(); + assert.strictEqual(map.size, 0); + + map.set(0, 1); + assert.strictEqual(map.size, 1); + + map.set(1, 2); + assert.strictEqual(map.size, 2); + + map.delete(1); + assert.strictEqual(map.size, 1); + + map.set(0, 3); + assert.strictEqual(map.size, 1); + }); + + test('delete', (assert) => { + const map = trackedMap(); + + assert.false(map.has(0)); + + map.set(0, 123); + assert.true(map.has(0)); + + map.delete(0); + assert.false(map.has(0)); + }); + + test('clear', (assert) => { + const map = trackedMap(); + + map.set(0, 1); + map.set(1, 2); + assert.strictEqual(map.size, 2); + + map.clear(); + assert.strictEqual(map.size, 0); + assert.strictEqual(map.get(0), undefined); + assert.strictEqual(map.get(1), undefined); + }); +}); diff --git a/packages/@glimmer/validator/test/collections/object-test.ts b/packages/@glimmer/validator/test/collections/object-test.ts new file mode 100644 index 000000000..217e0b11b --- /dev/null +++ b/packages/@glimmer/validator/test/collections/object-test.ts @@ -0,0 +1,46 @@ +import { trackedObject } from '@glimmer/validator'; +import { expectTypeOf } from 'expect-type'; + +import { module, test } from '../-utils'; + +// The whole point here is that Object *is* the thing we are matching, ESLint! +expectTypeOf>().toMatchTypeOf(); + +// @ts-expect-error - Required keys should require a value +trackedObject<{ foo: number }>(); +// @ts-expect-error - Required keys should require a value +trackedObject<{ foo: number }>({}); + +// Optional keys should not require a value +trackedObject<{ foo?: number }>(); + +module('@glimmer/validator: trackedObject', function () { + test('basic usage', (assert) => { + let original = { foo: 123 }; + let obj = trackedObject(original); + + assert.ok(obj instanceof Object); + expectTypeOf(obj).toEqualTypeOf<{ foo: number }>(); + assert.deepEqual(Object.keys(obj), ['foo']); + assert.strictEqual(obj.foo, 123); + + obj.foo = 456; + assert.strictEqual(obj.foo, 456, 'object updated correctly'); + assert.strictEqual(original.foo, 123, 'original object was not updated'); + }); + + test('preserves getters', (assert) => { + let obj = trackedObject({ + foo: 123, + get bar(): number { + return this.foo; + }, + }); + + expectTypeOf(obj).toEqualTypeOf<{ foo: number; readonly bar: number }>(); + + obj.foo = 456; + assert.strictEqual(obj.foo, 456, 'object updated correctly'); + assert.strictEqual(obj.bar, 456, 'getter cloned correctly'); + }); +}); diff --git a/packages/@glimmer/validator/test/collections/set-test.ts b/packages/@glimmer/validator/test/collections/set-test.ts new file mode 100644 index 000000000..9e8a69549 --- /dev/null +++ b/packages/@glimmer/validator/test/collections/set-test.ts @@ -0,0 +1,358 @@ +import { trackedSet } from '@glimmer/validator'; +import { expectTypeOf } from 'expect-type'; + +import { module, test } from '../-utils'; + +expectTypeOf>>().toMatchTypeOf>(); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type AnyFn = (...args: any[]) => any; + +module('@glimmer/validator: trackedSet', function () { + test('constructor', (assert) => { + const set = trackedSet(['foo', 123]); + + assert.true(set.has('foo')); + assert.strictEqual(set.size, 2); + assert.ok(set instanceof Set); + + const setFromSet = trackedSet(set); + assert.true(setFromSet.has('foo')); + assert.strictEqual(setFromSet.size, 2); + assert.ok(setFromSet instanceof Set); + + const setFromEmpty = trackedSet(); + assert.false(setFromEmpty.has('anything')); + assert.strictEqual(setFromEmpty.size, 0); + assert.ok(setFromEmpty instanceof Set); + }); + + test('works with all kinds of values', (assert) => { + const set = trackedSet | AnyFn | number | boolean | null>( + [ + 'foo', + {}, + () => { + /* no op */ + }, + 123, + true, + null, + ] + ); + + assert.strictEqual(set.size, 6); + }); + + test('add/has', (assert) => { + const set = trackedSet(); + + set.add('foo'); + assert.true(set.has('foo')); + }); + + test('entries', (assert) => { + const set = trackedSet(); + set.add(0); + set.add(2); + set.add(1); + + const iter = set.entries(); + + assert.deepEqual(iter.next().value, [0, 0]); + assert.deepEqual(iter.next().value, [2, 2]); + assert.deepEqual(iter.next().value, [1, 1]); + assert.true(iter.next().done); + }); + + test('keys', (assert) => { + const set = trackedSet(); + set.add(0); + set.add(2); + set.add(1); + + const iter = set.keys(); + + assert.strictEqual(iter.next().value, 0); + assert.strictEqual(iter.next().value, 2); + assert.strictEqual(iter.next().value, 1); + assert.true(iter.next().done); + }); + + test('values', (assert) => { + const set = trackedSet(); + set.add(0); + set.add(2); + set.add(1); + + const iter = set.values(); + + assert.strictEqual(iter.next().value, 0); + assert.strictEqual(iter.next().value, 2); + assert.strictEqual(iter.next().value, 1); + assert.true(iter.next().done); + }); + + test('union', (assert) => { + const set = trackedSet(); + const set2 = trackedSet(); + const nativeSet = new Set(); + + set.add(0); + set.add(2); + set.add(1); + + set2.add(2); + set2.add(3); + + nativeSet.add(0); + nativeSet.add(5); + + let iter = set.union(set2).values(); + + assert.strictEqual(iter.next().value, 0); + assert.strictEqual(iter.next().value, 2); + assert.strictEqual(iter.next().value, 1); + assert.strictEqual(iter.next().value, 3); + assert.true(iter.next().done); + + let iter2 = set.union(nativeSet).values(); + + assert.strictEqual(iter2.next().value, 0); + assert.strictEqual(iter2.next().value, 2); + assert.strictEqual(iter2.next().value, 1); + assert.strictEqual(iter2.next().value, 5); + assert.true(iter2.next().done); + }); + + test('intersection', (assert) => { + const set = trackedSet(); + const set2 = trackedSet(); + const nativeSet = new Set(); + + set.add(0); + set.add(2); + set.add(1); + + set2.add(2); + set2.add(3); + + nativeSet.add(0); + nativeSet.add(5); + + let iter = set.intersection(set2).values(); + + assert.strictEqual(iter.next().value, 2); + assert.true(iter.next().done); + + let iter2 = set.intersection(nativeSet).values(); + + assert.strictEqual(iter2.next().value, 0); + assert.true(iter2.next().done); + }); + + test('difference', (assert) => { + const set = trackedSet(); + const set2 = trackedSet(); + const nativeSet = new Set(); + + set.add(0); + set.add(2); + set.add(1); + + set2.add(2); + set2.add(3); + + nativeSet.add(0); + nativeSet.add(5); + + let iter = set.difference(set2).values(); + + assert.strictEqual(iter.next().value, 0); + assert.strictEqual(iter.next().value, 1); + assert.true(iter.next().done); + + let iter2 = set.difference(nativeSet).values(); + + assert.strictEqual(iter2.next().value, 2); + assert.strictEqual(iter2.next().value, 1); + assert.true(iter2.next().done); + }); + + test('symmetricDifference', (assert) => { + const set = trackedSet(); + const set2 = trackedSet(); + const nativeSet = new Set(); + + set.add(0); + set.add(2); + set.add(1); + + set2.add(2); + set2.add(3); + + nativeSet.add(0); + nativeSet.add(5); + + let iter = set.symmetricDifference(set2).values(); + + assert.strictEqual(iter.next().value, 0); + assert.strictEqual(iter.next().value, 1); + assert.strictEqual(iter.next().value, 3); + assert.true(iter.next().done); + + let iter2 = set.symmetricDifference(nativeSet).values(); + + assert.strictEqual(iter2.next().value, 2); + assert.strictEqual(iter2.next().value, 1); + assert.strictEqual(iter2.next().value, 5); + assert.true(iter2.next().done); + }); + + test('isSubsetOf', (assert) => { + const set = trackedSet(); + const set2 = trackedSet(); + const nativeSet = new Set(); + + set.add(0); + set.add(2); + set.add(1); + + set2.add(2); + set2.add(3); + + nativeSet.add(0); + nativeSet.add(5); + + assert.false(set.isSubsetOf(set2)); + + set2.add(0); + set2.add(1); + + assert.true(set.isSubsetOf(set2)); + + assert.false(set.isSubsetOf(nativeSet)); + + nativeSet.add(1); + nativeSet.add(2); + + assert.true(set.isSubsetOf(nativeSet)); + }); + + test('isSupersetOf', (assert) => { + const set = trackedSet(); + const set2 = trackedSet(); + const nativeSet = new Set(); + + set.add(0); + set.add(2); + set.add(1); + + set2.add(2); + set2.add(3); + + nativeSet.add(0); + nativeSet.add(5); + + assert.false(set.isSupersetOf(set2)); + + set.add(3); + + assert.true(set.isSupersetOf(set2)); + + assert.false(set.isSupersetOf(nativeSet)); + + set.add(5); + + assert.true(set.isSupersetOf(nativeSet)); + }); + + test('isDisjointFrom', (assert) => { + const set = trackedSet(); + const set2 = trackedSet(); + const nativeSet = new Set(); + + set.add(0); + set.add(2); + set.add(1); + + set2.add(3); + + nativeSet.add(5); + + assert.true(set.isDisjointFrom(set2)); + + set2.add(2); + + assert.false(set.isDisjointFrom(set2)); + + assert.true(set.isDisjointFrom(nativeSet)); + + nativeSet.add(0); + + assert.false(set.isDisjointFrom(nativeSet)); + }); + + test('forEach', (assert) => { + const set = trackedSet(); + set.add(0); + set.add(1); + set.add(2); + + let count = 0; + let values = ''; + + set.forEach((v, k) => { + count++; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + values += k; + // eslint-disable-next-line @typescript-eslint/restrict-plus-operands + values += v; + }); + + assert.strictEqual(count, 3); + assert.strictEqual(values, '001122'); + }); + + test('size', (assert) => { + const set = trackedSet(); + assert.strictEqual(set.size, 0); + + set.add(0); + assert.strictEqual(set.size, 1); + + set.add(1); + assert.strictEqual(set.size, 2); + + set.delete(1); + assert.strictEqual(set.size, 1); + + set.add(0); + assert.strictEqual(set.size, 1); + }); + + test('delete', (assert) => { + const set = trackedSet(); + + assert.false(set.has(0)); + + set.add(0); + assert.true(set.has(0)); + + set.delete(0); + assert.false(set.has(0)); + }); + + test('clear', (assert) => { + const set = trackedSet(); + + set.add(0); + set.add(1); + assert.strictEqual(set.size, 2); + + set.clear(); + assert.strictEqual(set.size, 0); + assert.false(set.has(0)); + assert.false(set.has(1)); + }); +}); diff --git a/packages/@glimmer/validator/test/collections/weak-map-test.ts b/packages/@glimmer/validator/test/collections/weak-map-test.ts new file mode 100644 index 000000000..07b587ebf --- /dev/null +++ b/packages/@glimmer/validator/test/collections/weak-map-test.ts @@ -0,0 +1,76 @@ +import { trackedWeakMap } from '@glimmer/validator'; +import { expectTypeOf } from 'expect-type'; + +import { module, test } from '../-utils'; + +expectTypeOf>>().toMatchTypeOf< + WeakMap +>(); + +module('@glimmer/validator: trackedWeakMap()', function () { + test('constructor', (assert) => { + const obj = {}; + const map = trackedWeakMap([[obj, 123]]); + + assert.strictEqual(map.get(obj), 123); + assert.ok(map instanceof WeakMap); + }); + + test('does not work with built-ins', (assert) => { + const map = trackedWeakMap(); + + assert.throws( + // @ts-expect-error -- point is testing constructor error + () => map.set('aoeu', 123), + /Invalid value used as weak map key/u + ); + assert.throws( + // @ts-expect-error -- point is testing constructor error + () => map.set(true, 123), + /Invalid value used as weak map key/u + ); + assert.throws( + // @ts-expect-error -- point is testing constructor error + () => map.set(123, 123), + /Invalid value used as weak map key/u + ); + assert.throws( + // @ts-expect-error -- point is testing constructor error + () => map.set(undefined, 123), + /Invalid value used as weak map key/u + ); + }); + + test('get/set', (assert) => { + const obj = {}; + const map = trackedWeakMap(); + + map.set(obj, 123); + assert.strictEqual(map.get(obj), 123); + + map.set(obj, 456); + assert.strictEqual(map.get(obj), 456); + }); + + test('has', (assert) => { + const obj = {}; + const map = trackedWeakMap(); + + assert.false(map.has(obj)); + map.set(obj, 123); + assert.true(map.has(obj)); + }); + + test('delete', (assert) => { + const obj = {}; + const map = trackedWeakMap(); + + assert.false(map.has(obj)); + + map.set(obj, 123); + assert.true(map.has(obj)); + + map.delete(obj); + assert.false(map.has(obj)); + }); +}); diff --git a/packages/@glimmer/validator/test/collections/weak-set-test.ts b/packages/@glimmer/validator/test/collections/weak-set-test.ts new file mode 100644 index 000000000..2a9f8dc3e --- /dev/null +++ b/packages/@glimmer/validator/test/collections/weak-set-test.ts @@ -0,0 +1,55 @@ +import { trackedWeakSet } from '@glimmer/validator'; +import { expectTypeOf } from 'expect-type'; + +import { module, test } from '../-utils'; + +expectTypeOf>>().toMatchTypeOf>(); + +module('@glimmer/validator: trackedWeakSet()', function () { + test('constructor', (assert) => { + const obj = {}; + const set = trackedWeakSet([obj]); + + assert.true(set.has(obj)); + assert.ok(set instanceof WeakSet); + + const array = [1, 2, 3]; + const iterable = [array]; + const fromIterable = trackedWeakSet(iterable); + assert.true(fromIterable.has(array)); + }); + + test('does not work with built-ins', (assert) => { + const set = trackedWeakSet(); + + // @ts-expect-error -- point is testing constructor error + assert.throws(() => set.add('aoeu'), /Invalid value used in weak set/u); + // @ts-expect-error -- point is testing constructor error + assert.throws(() => set.add(true), /Invalid value used in weak set/u); + // @ts-expect-error -- point is testing constructor error + assert.throws(() => set.add(123), /Invalid value used in weak set/u); + // @ts-expect-error -- point is testing constructor error + assert.throws(() => set.add(undefined), /Invalid value used in weak set/u); + }); + + test('add/has', (assert) => { + const obj = {}; + const set = trackedWeakSet(); + + set.add(obj); + assert.true(set.has(obj)); + }); + + test('delete', (assert) => { + const obj = {}; + const set = trackedWeakSet(); + + assert.false(set.has(obj)); + + set.add(obj); + assert.true(set.has(obj)); + + set.delete(obj); + assert.false(set.has(obj)); + }); +}); diff --git a/packages/@glimmer/validator/test/package.json b/packages/@glimmer/validator/test/package.json index 2aa799b84..f7f097758 100644 --- a/packages/@glimmer/validator/test/package.json +++ b/packages/@glimmer/validator/test/package.json @@ -9,6 +9,7 @@ "dependencies": { "@glimmer/global-context": "workspace:*", "@glimmer/interfaces": "workspace:*", - "@glimmer/validator": "workspace:*" + "@glimmer/validator": "workspace:*", + "expect-type": "^1.1.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d5d6f111f..e7415bd8b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1571,6 +1571,9 @@ importers: '@glimmer/validator': specifier: workspace:* version: link:.. + expect-type: + specifier: ^1.1.0 + version: 1.1.0 packages/@glimmer/vm: dependencies: