Skip to content

Commit 855fa9d

Browse files
authored
Merge pull request #21234 from emberjs/copilot/improve-tracked-collections-implementation
2 parents 013f8ba + b5aa221 commit 855fa9d

4 files changed

Lines changed: 322 additions & 437 deletions

File tree

Lines changed: 87 additions & 156 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
1-
import type { ReactiveOptions } from './types';
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
// Using a Proxy-based approach so that any new methods added to the Map
3+
// interface (like getOrInsert, getOrInsertComputed, etc.) are automatically
4+
// supported without needing to manually re-implement each one.
25

36
import { consumeTag } from '../tracking';
47
import { createUpdatableTag, DIRTY_TAG } from '../validators';
58

6-
class TrackedMap<K = unknown, V = unknown> implements Map<K, V> {
7-
#options: ReactiveOptions<V>;
8-
#collection = createUpdatableTag();
9-
#storages = new Map<K, ReturnType<typeof createUpdatableTag>>();
10-
#vals: Map<K, V>;
9+
type Tag = ReturnType<typeof createUpdatableTag>;
1110

12-
#storageFor(key: K): ReturnType<typeof createUpdatableTag> {
13-
const storages = this.#storages;
11+
export function trackedMap<Key = any, Value = any>(
12+
data?:
13+
| Map<Key, Value>
14+
| Iterable<readonly [Key, Value]>
15+
| readonly (readonly [Key, Value])[]
16+
| null,
17+
options?: { equals?: (a: Value, b: Value) => boolean; description?: string }
18+
): Map<Key, Value> {
19+
const equals = options?.equals ?? Object.is;
20+
// TypeScript doesn't correctly resolve the overloads for calling the `Map`
21+
// constructor for the no-value constructor. This resolves that.
22+
const target: Map<Key, Value> =
23+
data instanceof Map ? new Map(data.entries()) : new Map(data ?? []);
24+
const collection = createUpdatableTag();
25+
const storages = new Map<Key, Tag>();
26+
27+
function storageFor(key: Key): Tag {
1428
let storage = storages.get(key);
1529

1630
if (storage === undefined) {
@@ -20,181 +34,98 @@ class TrackedMap<K = unknown, V = unknown> implements Map<K, V> {
2034

2135
return storage;
2236
}
23-
#dirtyStorageFor(key: K): void {
24-
const storage = this.#storages.get(key);
37+
38+
function dirtyStorageFor(key: Key): void {
39+
const storage = storages.get(key);
2540

2641
if (storage) {
2742
DIRTY_TAG(storage);
2843
}
2944
}
3045

31-
constructor(
32-
existing: readonly (readonly [K, V])[] | Iterable<readonly [K, V]> | null | Map<K, V>,
33-
options: ReactiveOptions<V>
34-
) {
35-
// TypeScript doesn't correctly resolve the overloads for calling the `Map`
36-
// constructor for the no-value constructor. This resolves that.
37-
this.#vals = existing instanceof Map ? new Map(existing.entries()) : new Map(existing);
38-
this.#options = options;
39-
}
40-
41-
get(key: K): V | undefined {
42-
consumeTag(this.#storageFor(key));
43-
44-
return this.#vals.get(key);
45-
}
46-
47-
has(key: K): boolean {
48-
consumeTag(this.#storageFor(key));
46+
const proxy: Map<Key, Value> = new Proxy(target, {
47+
get(target, prop, receiver) {
48+
if (prop === 'set') {
49+
return function (key: Key, value: Value): Map<Key, Value> {
50+
const hasExisting = target.has(key);
4951

50-
return this.#vals.has(key);
51-
}
52-
53-
// **** ALL GETTERS ****
54-
entries(): MapIterator<[K, V]> {
55-
return this[Symbol.iterator]();
56-
}
52+
if (hasExisting) {
53+
const isUnchanged = equals(target.get(key) as Value, value);
5754

58-
getOrInsert(key: K, defaultValue: V): V {
59-
consumeTag(this.#storageFor(key));
55+
if (isUnchanged) return proxy;
56+
}
6057

61-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
62-
// @ts-ignore -- older versions of TS don't yet have this method
63-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
64-
return this.#vals.getOrInsert(key, defaultValue);
65-
}
66-
67-
getOrInsertComputed(key: K, creator: (key: K) => V): V {
68-
consumeTag(this.#storageFor(key));
69-
70-
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
71-
// @ts-ignore -- older versions of TS don't yet have this method
72-
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call
73-
return this.#vals.getOrInsertComputed(key, creator);
74-
}
58+
dirtyStorageFor(key);
59+
DIRTY_TAG(collection);
7560

76-
keys() {
77-
consumeTag(this.#collection);
78-
79-
return this.#vals.keys();
80-
}
81-
82-
values() {
83-
let iterator = this[Symbol.iterator]();
84-
85-
return {
86-
next() {
87-
let { value, done } = iterator.next();
88-
return { value: done ? (undefined as V) : value?.[1], done };
89-
},
90-
[Symbol.iterator]() {
91-
return this;
92-
},
93-
} as MapIterator<V>;
94-
}
95-
96-
forEach(fn: (value: V, key: K, map: Map<K, V>) => void): void {
97-
for (let [key, value] of this) {
98-
fn(value, key, this);
99-
}
100-
}
61+
target.set(key, value);
10162

102-
get size(): number {
103-
consumeTag(this.#collection);
63+
return proxy;
64+
};
65+
}
10466

105-
return this.#vals.size;
106-
}
67+
if (prop === 'delete') {
68+
return function (key: Key): boolean {
69+
if (!target.has(key)) return false;
10770

108-
/**
109-
* When iterating:
110-
* - we entangle with the collection (as we iterate over the whole thing)
111-
* via keys() → consumeTag(#collection)
112-
* - for each individual item, we entangle with the item as well
113-
* via get() → consumeTag(#storageFor(key))
114-
*/
115-
[Symbol.iterator]() {
116-
let keys = this.keys();
117-
// eslint-disable-next-line @typescript-eslint/no-this-alias
118-
let self = this;
119-
120-
return {
121-
next() {
122-
let next = keys.next();
123-
let currentKey = next.value;
124-
125-
if (next.done) {
126-
return { value: [undefined, undefined], done: true };
127-
}
128-
129-
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
130-
return { value: [currentKey, self.get(currentKey!)], done: false };
131-
},
132-
[Symbol.iterator]() {
133-
return this;
134-
},
135-
} as MapIterator<[K, V]>;
136-
}
71+
dirtyStorageFor(key);
72+
DIRTY_TAG(collection);
13773

138-
get [Symbol.toStringTag](): string {
139-
return this.#vals[Symbol.toStringTag];
140-
}
74+
storages.delete(key);
75+
return target.delete(key);
76+
};
77+
}
14178

142-
set(key: K, value: V): this {
143-
let hasExisting = this.#vals.has(key);
79+
if (prop === 'clear') {
80+
return function (): void {
81+
if (target.size === 0) return;
14482

145-
if (hasExisting) {
146-
let isUnchanged = this.#options.equals(this.#vals.get(key) as V, value);
83+
storages.forEach((s) => DIRTY_TAG(s));
84+
storages.clear();
14785

148-
if (isUnchanged) {
149-
return this;
86+
DIRTY_TAG(collection);
87+
target.clear();
88+
};
15089
}
151-
}
15290

153-
this.#dirtyStorageFor(key);
91+
if (prop === 'get') {
92+
return function (key: Key): Value | undefined {
93+
consumeTag(storageFor(key));
15494

155-
if (!hasExisting) {
156-
DIRTY_TAG(this.#collection);
157-
}
158-
159-
this.#vals.set(key, value);
160-
161-
return this;
162-
}
95+
return target.get(key);
96+
};
97+
}
16398

164-
delete(key: K): boolean {
165-
if (!this.#vals.has(key)) return false;
99+
if (prop === 'has') {
100+
return function (key: Key): boolean {
101+
consumeTag(storageFor(key));
166102

167-
this.#dirtyStorageFor(key);
168-
DIRTY_TAG(this.#collection);
103+
return target.has(key);
104+
};
105+
}
169106

170-
this.#storages.delete(key);
171-
return this.#vals.delete(key);
172-
}
107+
if (prop === 'size') {
108+
consumeTag(collection);
173109

174-
clear(): void {
175-
if (this.#vals.size === 0) return;
110+
return target.size;
111+
}
176112

177-
this.#storages.forEach((s) => DIRTY_TAG(s));
178-
this.#storages.clear();
113+
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
114+
const value = Reflect.get(target, prop, receiver);
179115

180-
DIRTY_TAG(this.#collection);
181-
this.#vals.clear();
182-
}
183-
}
116+
if (typeof value === 'function') {
117+
return function (this: any, ...args: any[]) {
118+
consumeTag(collection);
184119

185-
// So instanceof works
186-
Object.setPrototypeOf(TrackedMap.prototype, Map.prototype);
120+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call
121+
return value.apply(target, args);
122+
};
123+
}
187124

188-
export function trackedMap<Key = unknown, Value = unknown>(
189-
data?:
190-
| Map<Key, Value>
191-
| Iterable<readonly [Key, Value]>
192-
| readonly (readonly [Key, Value])[]
193-
| null,
194-
options?: { equals?: (a: Value, b: Value) => boolean; description?: string }
195-
): Map<Key, Value> {
196-
return new TrackedMap(data ?? [], {
197-
equals: options?.equals ?? Object.is,
198-
description: options?.description,
125+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
126+
return value;
127+
},
199128
});
129+
130+
return proxy;
200131
}

0 commit comments

Comments
 (0)