Skip to content

Commit 0b2ab6f

Browse files
committed
Ember.A no longer modifies the original array
1 parent ee6729f commit 0b2ab6f

File tree

2 files changed

+194
-19
lines changed

2 files changed

+194
-19
lines changed

packages/@ember/-internals/runtime/tests/system/native_array/a_test.js

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
1-
import EmberArray from '@ember/array';
1+
import EmberArray, { NativeArray } from '@ember/array';
22
import { A } from '@ember/array';
3+
import { ENV } from '@ember/-internals/environment';
4+
import { isEmberArray } from '@ember/-internals/utils';
35
import { moduleFor, AbstractTestCase } from 'internal-test-helpers';
46

57
moduleFor(
@@ -24,3 +26,43 @@ moduleFor(
2426
}
2527
}
2628
);
29+
30+
if (!ENV.EXTEND_PROTOTYPES.Array) {
31+
moduleFor(
32+
'Ember.A without Extended Prototypes',
33+
class extends AbstractTestCase {
34+
['@test Ember.A does not modify original'](assert) {
35+
let original = [1, 2];
36+
let proxy = A(original);
37+
38+
assert.notOk(EmberArray.detect(original), 'EmberArray is not detected in the original');
39+
assert.ok(EmberArray.detect(proxy), 'EmberArray is detected in the proxy');
40+
41+
assert.notOk(NativeArray.detect(original), 'NativeArray is not detected in the original');
42+
assert.ok(NativeArray.detect(proxy), 'NativeArray is detected in the proxy');
43+
44+
assert.strictEqual(proxy.objectAt(1), 2, 'proxies to original array');
45+
46+
proxy.pushObject(3);
47+
assert.deepEqual(original, [1, 2, 3], 'original array gets updated');
48+
49+
assert.notOk(isEmberArray(original), 'original is not EmberArray');
50+
assert.ok(isEmberArray(proxy), 'proxy is EmberArray');
51+
52+
assert.ok(Array.isArray(proxy), 'proxy is a native array');
53+
54+
proxy.pushObjects([4, 5]);
55+
assert.deepEqual(original, [1, 2, 3, 4, 5], 'pushObjects works');
56+
}
57+
58+
['@test Ember.A adds warnings about modification to original']() {
59+
let original = [1, 2];
60+
A(original);
61+
62+
expectAssertion(() => {
63+
original.pushObject(1);
64+
});
65+
}
66+
}
67+
);
68+
}

packages/@ember/array/index.ts

Lines changed: 151 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
@module @ember/array
33
*/
44
import { DEBUG } from '@glimmer/env';
5-
import { PROXY_CONTENT } from '@ember/-internals/metal';
5+
import { ComputedProperty, descriptorForDecorator, PROXY_CONTENT } from '@ember/-internals/metal';
66
import { isEmberArray, setEmberArray } from '@ember/-internals/utils';
77
import {
88
objectAt,
@@ -1199,6 +1199,25 @@ interface EmberArray<T> extends Enumerable {
11991199
*/
12001200
without(value: T): NativeArray<T>;
12011201
}
1202+
1203+
const emberArrayBrackets = nonEnumerableComputed({
1204+
get() {
1205+
return this;
1206+
},
1207+
set(_key, value) {
1208+
this.replace(0, this.length, value);
1209+
return this;
1210+
},
1211+
});
1212+
1213+
const emberArrayFirstObject = nonEnumerableComputed(function () {
1214+
return objectAt(this, 0);
1215+
}).readOnly();
1216+
1217+
const emberArrayLastObject = nonEnumerableComputed(function () {
1218+
return objectAt(this, this.length - 1);
1219+
}).readOnly();
1220+
12021221
const EmberArray = Mixin.create(Enumerable, {
12031222
init() {
12041223
this._super(...arguments);
@@ -1209,23 +1228,11 @@ const EmberArray = Mixin.create(Enumerable, {
12091228
return indexes.map((idx) => objectAt(this, idx));
12101229
},
12111230

1212-
'[]': nonEnumerableComputed({
1213-
get() {
1214-
return this;
1215-
},
1216-
set(_key, value) {
1217-
this.replace(0, this.length, value);
1218-
return this;
1219-
},
1220-
}),
1231+
'[]': emberArrayBrackets,
12211232

1222-
firstObject: nonEnumerableComputed(function () {
1223-
return objectAt(this, 0);
1224-
}).readOnly(),
1233+
firstObject: emberArrayFirstObject,
12251234

1226-
lastObject: nonEnumerableComputed(function () {
1227-
return objectAt(this, this.length - 1);
1228-
}).readOnly(),
1235+
lastObject: emberArrayLastObject,
12291236

12301237
// Add any extra methods to EmberArray that are native to the built-in Array.
12311238
slice(beginIndex = 0, endIndex?: number) {
@@ -2099,6 +2106,127 @@ NativeArray.keys().forEach((methodName) => {
20992106

21002107
NativeArray = NativeArray.without(...ignore);
21012108

2109+
const NATIVE_ARRAY_METHODS = Object.freeze(NativeArray.keys());
2110+
2111+
function getCP(decorator: Function) {
2112+
let desc = descriptorForDecorator(decorator);
2113+
assert('[BUG] Expected descriptor with getter', desc instanceof ComputedProperty);
2114+
return desc;
2115+
}
2116+
2117+
// NOTE: This code borrows heavily from https://github.com/tracked-tools/tracked-built-ins/blob/master/addon/src/-private/array.ts
2118+
2119+
// Unfortunately, TypeScript's ability to do inference *or* type-checking in a
2120+
// `Proxy`'s body is very limited, so we have to use a number of casts `as any`
2121+
// to make the internal accesses work. The type safety of these is guaranteed at
2122+
// the *call site* instead of within the body: you cannot do `Array.blah` in TS,
2123+
// and it will blow up in JS in exactly the same way, so it is safe to assume
2124+
// that properties within the getter have the correct type in TS.
2125+
class EmberAProxy<T = unknown> {
2126+
/**
2127+
* Creates an array from an iterable object.
2128+
* @param iterable An iterable object to convert to an array.
2129+
*/
2130+
static from<T>(iterable: Iterable<T> | ArrayLike<T>): EmberAProxy<T> {
2131+
return new EmberAProxy(Array.from(iterable));
2132+
}
2133+
2134+
static of<T>(...arr: T[]): EmberAProxy<T> {
2135+
return new EmberAProxy(arr);
2136+
}
2137+
2138+
constructor(arr: T[] = []) {
2139+
let self = this;
2140+
2141+
let proxy = new Proxy(arr, {
2142+
get(target, prop /*, _receiver */) {
2143+
// These nonEnumerableComputed properties need special handling
2144+
if (prop === '[]') {
2145+
return self.#bracketsCP.get(proxy, prop);
2146+
} else if (prop === 'firstObject') {
2147+
return self.#firstObjectCP.get(proxy, prop);
2148+
} else if (prop === 'lastObject') {
2149+
return self.#lastObjectCP.get(proxy, prop);
2150+
} else if (
2151+
typeof prop === 'string' &&
2152+
(NATIVE_ARRAY_METHODS.has(prop) || prop === '_super')
2153+
) {
2154+
// If it's a NativeArray method, call it with the proxy as the target
2155+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2156+
return (self as any)[prop].bind(proxy);
2157+
}
2158+
2159+
// Pass through every other property
2160+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2161+
return (target as any)[prop];
2162+
},
2163+
2164+
set(target, prop, value /*, _receiver */) {
2165+
// These nonEnumerableComputed properties need special handling
2166+
if (prop === '[]') {
2167+
self.#bracketsCP.set(proxy, prop, value);
2168+
} else if (prop === 'firstObject') {
2169+
self.#firstObjectCP.set(proxy, prop, value);
2170+
} else if (prop === 'lastObject') {
2171+
self.#lastObjectCP.set(proxy, prop, value);
2172+
} else {
2173+
// Pass through every other property
2174+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2175+
(target as any)[prop] = value;
2176+
}
2177+
2178+
return true;
2179+
},
2180+
2181+
getPrototypeOf() {
2182+
return EmberAProxy.prototype;
2183+
},
2184+
}) as EmberAProxy<T>;
2185+
2186+
// Call init so it's properly registered
2187+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
2188+
(self as any).init.call(proxy);
2189+
2190+
return proxy;
2191+
}
2192+
2193+
#bracketsCP = getCP(emberArrayBrackets);
2194+
#firstObjectCP = getCP(emberArrayFirstObject);
2195+
#lastObjectCP = getCP(emberArrayLastObject);
2196+
}
2197+
2198+
// Apply NativeArray to the the EmberAProxy so that it appears as a NativeArray
2199+
// and so that the correct methods are available.
2200+
NativeArray.apply(EmberAProxy.prototype);
2201+
2202+
// This rule is correctly in the general case, but it doesn't understand
2203+
// declaration merging, which is how we're using the interface here. This
2204+
// declaration says that `EmberAProxy` acts just like `Array<T>`, but also has
2205+
// the properties declared via the `class` declaration above -- but without the
2206+
// cost of a subclass, which is much slower that the proxied array behavior.
2207+
// That is: a `EmberAProxy` *is* an `Array`, just with a proxy in front of
2208+
// accessors and setters, rather than a subclass of an `Array` which would be
2209+
// de-optimized by the browsers.
2210+
//
2211+
// eslint-disable-next-line @typescript-eslint/no-empty-interface
2212+
interface EmberAProxy<T = unknown> extends NativeArray<T> {}
2213+
2214+
// Ensure instanceof works correctly
2215+
Object.setPrototypeOf(EmberAProxy.prototype, Array.prototype);
2216+
2217+
const nativeArrayThrowers = Object.fromEntries(
2218+
Array.from(NativeArray.keys()).map((key) => {
2219+
return [
2220+
key,
2221+
() =>
2222+
assert(
2223+
`Ember.A was incorrectly modifying the original array. Do not call the EmberArray function ${key} on the original array. Instead, call it on the value returned by Ember.A.`
2224+
),
2225+
];
2226+
})
2227+
);
2228+
const ModifiedNativeArray = Mixin.create(nativeArrayThrowers);
2229+
21022230
let A: <T>(arr?: Array<T>) => NativeArray<T>;
21032231

21042232
if (ENV.EXTEND_PROTOTYPES.Array) {
@@ -2124,8 +2252,13 @@ if (ENV.EXTEND_PROTOTYPES.Array) {
21242252
// SAFETY: If it's a true native array and it is also an EmberArray then it should be an Ember NativeArray
21252253
return arr as unknown as NativeArray<T>;
21262254
} else {
2127-
// SAFETY: This will return an NativeArray but TS can't infer that.
2128-
return NativeArray.apply(arr ?? []) as NativeArray<T>;
2255+
// Remove this in Ember 5.0.
2256+
if (DEBUG) {
2257+
if (arr) {
2258+
ModifiedNativeArray.apply(arr);
2259+
}
2260+
}
2261+
return new EmberAProxy(arr ?? []);
21292262
}
21302263
};
21312264
}

0 commit comments

Comments
 (0)