Skip to content

Commit d34c629

Browse files
committed
Implement TrackedArray
1 parent 3197d5f commit d34c629

File tree

2 files changed

+220
-0
lines changed

2 files changed

+220
-0
lines changed

packages/@glimmer/validator/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ if (Reflect.has(globalThis, GLIMMER_VALIDATOR_REGISTRATION)) {
88

99
Reflect.set(globalThis, GLIMMER_VALIDATOR_REGISTRATION, true);
1010

11+
export { TrackedArray } from './lib/collections/array';
1112
export { debug } from './lib/debug';
1213
export { dirtyTagFor, tagFor, type TagMeta, tagMetaFor } from './lib/meta';
1314
export { trackedData } from './lib/tracked-data';
Lines changed: 219 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,219 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
// Unfortunately, TypeScript's ability to do inference *or* type-checking in a
3+
// `Proxy`'s body is very limited, so we have to use a number of casts `as any`
4+
// to make the internal accesses work. The type safety of these is guaranteed at
5+
// the *call site* instead of within the body: you cannot do `Array.blah` in TS,
6+
// and it will blow up in JS in exactly the same way, so it is safe to assume
7+
// that properties within the getter have the correct type in TS.
8+
9+
import { consumeTag } from '../tracking';
10+
import { createUpdatableTag, DIRTY_TAG } from '../validators';
11+
12+
const ARRAY_GETTER_METHODS = new Set<string | symbol | number>([
13+
Symbol.iterator,
14+
'concat',
15+
'entries',
16+
'every',
17+
'filter',
18+
'find',
19+
'findIndex',
20+
'flat',
21+
'flatMap',
22+
'forEach',
23+
'includes',
24+
'indexOf',
25+
'join',
26+
'keys',
27+
'lastIndexOf',
28+
'map',
29+
'reduce',
30+
'reduceRight',
31+
'slice',
32+
'some',
33+
'values',
34+
]);
35+
36+
// For these methods, `Array` itself immediately gets the `.length` to return
37+
// after invoking them.
38+
const ARRAY_WRITE_THEN_READ_METHODS = new Set<string | symbol>(['fill', 'push', 'unshift']);
39+
40+
function convertToInt(prop: number | string | symbol): number | null {
41+
if (typeof prop === 'symbol') return null;
42+
43+
const num = Number(prop);
44+
45+
if (isNaN(num)) return null;
46+
47+
return num % 1 === 0 ? num : null;
48+
}
49+
50+
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
51+
export class TrackedArray<T = unknown> {
52+
/**
53+
* Creates an array from an iterable object.
54+
* @param iterable An iterable object to convert to an array.
55+
*/
56+
static from<T>(iterable: Iterable<T> | ArrayLike<T>): TrackedArray<T>;
57+
58+
/**
59+
* Creates an array from an iterable object.
60+
* @param iterable An iterable object to convert to an array.
61+
* @param mapfn A mapping function to call on every element of the array.
62+
* @param thisArg Value of 'this' used to invoke the mapfn.
63+
*/
64+
static from<T, U>(
65+
iterable: Iterable<T> | ArrayLike<T>,
66+
mapfn: (v: T, k: number) => U,
67+
thisArg?: unknown
68+
): TrackedArray<U>;
69+
70+
static from<T, U>(
71+
iterable: Iterable<T> | ArrayLike<T>,
72+
mapfn?: (v: T, k: number) => U,
73+
thisArg?: unknown
74+
): TrackedArray<T> | TrackedArray<U> {
75+
return mapfn
76+
? new TrackedArray(Array.from(iterable, mapfn, thisArg))
77+
: new TrackedArray(Array.from(iterable));
78+
}
79+
80+
static of<T>(...arr: T[]): TrackedArray<T> {
81+
return new TrackedArray(arr);
82+
}
83+
84+
constructor(arr: T[] = []) {
85+
const clone = arr.slice();
86+
// eslint-disable-next-line @typescript-eslint/no-this-alias
87+
const self = this;
88+
89+
const boundFns = new Map<string | symbol, (...args: any[]) => any>();
90+
91+
/**
92+
Flag to track whether we have *just* intercepted a call to `.push()` or
93+
`.unshift()`, since in those cases (and only those cases!) the `Array`
94+
itself checks `.length` to return from the function call.
95+
*/
96+
let nativelyAccessingLengthFromPushOrUnshift = false;
97+
98+
return new Proxy(clone, {
99+
get(target, prop /*, _receiver */) {
100+
const index = convertToInt(prop);
101+
102+
if (index !== null) {
103+
self.#readStorageFor(index);
104+
consumeTag(self.#collection);
105+
106+
return target[index];
107+
}
108+
109+
if (prop === 'length') {
110+
// If we are reading `.length`, it may be a normal user-triggered
111+
// read, or it may be a read triggered by Array itself. In the latter
112+
// case, it is because we have just done `.push()` or `.unshift()`; in
113+
// that case it is safe not to mark this as a *read* operation, since
114+
// calling `.push()` or `.unshift()` cannot otherwise be part of a
115+
// "read" operation safely, and if done during an *existing* read
116+
// (e.g. if the user has already checked `.length` *prior* to this),
117+
// that will still trigger the mutation-after-consumption assertion.
118+
if (nativelyAccessingLengthFromPushOrUnshift) {
119+
nativelyAccessingLengthFromPushOrUnshift = false;
120+
} else {
121+
consumeTag(self.#collection);
122+
}
123+
124+
return target[prop];
125+
}
126+
127+
// Here, track that we are doing a `.push()` or `.unshift()` by setting
128+
// the flag to `true` so that when the `.length` is read by `Array` (see
129+
// immediately above), it knows not to dirty the collection.
130+
if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) {
131+
nativelyAccessingLengthFromPushOrUnshift = true;
132+
}
133+
134+
if (ARRAY_GETTER_METHODS.has(prop)) {
135+
let fn = boundFns.get(prop);
136+
137+
if (fn === undefined) {
138+
fn = (...args) => {
139+
consumeTag(self.#collection);
140+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
141+
return (target as any)[prop](...args);
142+
};
143+
144+
boundFns.set(prop, fn);
145+
}
146+
147+
return fn;
148+
}
149+
150+
// eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access
151+
return (target as any)[prop];
152+
},
153+
154+
set(target, prop, value /*, _receiver */) {
155+
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-assignment
156+
(target as any)[prop] = value;
157+
158+
const index = convertToInt(prop);
159+
160+
if (index !== null) {
161+
self.#dirtyStorageFor(index);
162+
self.#dirtyCollection();
163+
} else if (prop === 'length') {
164+
self.#dirtyCollection();
165+
}
166+
167+
return true;
168+
},
169+
170+
getPrototypeOf() {
171+
return TrackedArray.prototype;
172+
},
173+
}) as TrackedArray<T>;
174+
}
175+
176+
#collection = createUpdatableTag();
177+
178+
#storages = new Map<number, ReturnType<typeof createUpdatableTag>>();
179+
180+
#readStorageFor(index: number) {
181+
let storage = this.#storages.get(index);
182+
183+
if (storage === undefined) {
184+
storage = createUpdatableTag();
185+
this.#storages.set(index, storage);
186+
}
187+
188+
consumeTag(storage);
189+
}
190+
191+
#dirtyStorageFor(index: number): void {
192+
const storage = this.#storages.get(index);
193+
194+
if (storage) {
195+
DIRTY_TAG(storage);
196+
}
197+
}
198+
199+
#dirtyCollection() {
200+
DIRTY_TAG(this.#collection);
201+
this.#storages.clear();
202+
}
203+
}
204+
205+
// This rule is correct in the general case, but it doesn't understand
206+
// declaration merging, which is how we're using the interface here. This says
207+
// `TrackedArray` acts just like `Array<T>`, but also has the properties
208+
// declared via the `class` declaration above -- but without the cost of a
209+
// subclass, which is much slower that the proxied array behavior. That is: a
210+
// `TrackedArray` *is* an `Array`, just with a proxy in front of accessors and
211+
// setters, rather than a subclass of an `Array` which would be de-optimized by
212+
// the browsers.
213+
//
214+
215+
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
216+
export interface TrackedArray<T = unknown> extends Array<T> {}
217+
218+
// Ensure instanceof works correctly
219+
Object.setPrototypeOf(TrackedArray.prototype, Array.prototype);

0 commit comments

Comments
 (0)