UNPKG

ember-source

Version:

A JavaScript framework for creating ambitious web applications

592 lines (555 loc) 20 kB
import { a as consumeTag, c as createUpdatableTag, D as DIRTY_TAG } from '../../shared-chunks/cache-BIlOoPA7.js'; /* eslint-disable @typescript-eslint/no-explicit-any */ // Unfortunately, TypeScript's ability to do inference *or* type-checking in a // `Proxy`'s body is very limited, so we have to use a number of casts `as any` // to make the internal accesses work. The type safety of these is guaranteed at // the *call site* instead of within the body: you cannot do `Array.blah` in TS, // and it will blow up in JS in exactly the same way, so it is safe to assume // that properties within the getter have the correct type in TS. const ARRAY_GETTER_METHODS = new Set([Symbol.iterator, 'concat', 'entries', 'every', 'filter', 'find', 'findIndex', 'flat', 'flatMap', 'forEach', 'includes', 'indexOf', 'join', 'keys', 'lastIndexOf', 'map', 'reduce', 'reduceRight', 'slice', 'some', 'values']); // For these methods, `Array` itself immediately gets the `.length` to return // after invoking them. const ARRAY_WRITE_THEN_READ_METHODS = new Set(['fill', 'push', 'unshift']); function convertToInt(prop) { if (typeof prop === 'symbol') return null; const num = Number(prop); if (isNaN(num)) return null; return num % 1 === 0 ? num : null; } // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging class TrackedArray { #options; constructor(arr, options) { this.#options = options; const clone = arr.slice(); // eslint-disable-next-line @typescript-eslint/no-this-alias const self = this; const boundFns = new Map(); /** Flag to track whether we have *just* intercepted a call to `.push()` or `.unshift()`, since in those cases (and only those cases!) the `Array` itself checks `.length` to return from the function call. */ let nativelyAccessingLengthFromWriteMethod = false; return new Proxy(clone, { get(target, prop /*, _receiver */) { const index = convertToInt(prop); if (index !== null) { self.#readStorageFor(index); consumeTag(self.#collection); return target[index]; } if (prop === 'length') { // If we are reading `.length`, it may be a normal user-triggered // read, or it may be a read triggered by Array itself. In the latter // case, it is because we have just done `.push()` or `.unshift()`; in // that case it is safe not to mark this as a *read* operation, since // calling `.push()` or `.unshift()` cannot otherwise be part of a // "read" operation safely, and if done during an *existing* read // (e.g. if the user has already checked `.length` *prior* to this), // that will still trigger the mutation-after-consumption assertion. if (nativelyAccessingLengthFromWriteMethod) { nativelyAccessingLengthFromWriteMethod = false; } else { consumeTag(self.#collection); } return target[prop]; } // Here, track that we are doing a `.push()` or `.unshift()` by setting // the flag to `true` so that when the `.length` is read by `Array` (see // immediately above), it knows not to dirty the collection. if (ARRAY_WRITE_THEN_READ_METHODS.has(prop)) { nativelyAccessingLengthFromWriteMethod = true; } if (ARRAY_GETTER_METHODS.has(prop)) { let fn = boundFns.get(prop); if (fn === undefined) { fn = (...args) => { consumeTag(self.#collection); // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access return target[prop](...args); }; boundFns.set(prop, fn); } return fn; } // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access return target[prop]; }, set(target, prop, value /*, _receiver */) { // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-argument let isUnchanged = self.#options.equals(target[prop], value); if (isUnchanged) return true; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access target[prop] = value; const index = convertToInt(prop); if (index !== null) { self.#dirtyStorageFor(index); self.#dirtyCollection(); } else if (prop === 'length') { self.#dirtyCollection(); } return true; }, getPrototypeOf() { return TrackedArray.prototype; } }); } #collection = createUpdatableTag(); #storages = new Map(); #readStorageFor(index) { let storage = this.#storages.get(index); if (storage === undefined) { storage = createUpdatableTag(); this.#storages.set(index, storage); } consumeTag(storage); } #dirtyStorageFor(index) { const storage = this.#storages.get(index); if (storage) { DIRTY_TAG(storage); } } #dirtyCollection() { DIRTY_TAG(this.#collection); this.#storages.clear(); } } // This rule is correct in the general case, but it doesn't understand // declaration merging, which is how we're using the interface here. This says // `TrackedArray` acts just like `Array<T>`, but also has the properties // declared via the `class` declaration above -- but without the cost of a // subclass, which is much slower that the proxied array behavior. That is: a // `TrackedArray` *is* an `Array`, just with a proxy in front of accessors and // setters, rather than a subclass of an `Array` which would be de-optimized by // the browsers. // // eslint-disable-next-line @typescript-eslint/no-empty-object-type // Ensure instanceof works correctly Object.setPrototypeOf(TrackedArray.prototype, Array.prototype); function trackedArray(data, options) { return new TrackedArray(data ?? [], { equals: options?.equals ?? Object.is, description: options?.description }); } /* eslint-disable @typescript-eslint/no-explicit-any */ // Using a Proxy-based approach so that any new methods added to the Map // interface (like getOrInsert, getOrInsertComputed, etc.) are automatically // supported without needing to manually re-implement each one. function trackedMap(data, options) { const equals = options?.equals ?? Object.is; // TypeScript doesn't correctly resolve the overloads for calling the `Map` // constructor for the no-value constructor. This resolves that. const target = data instanceof Map ? new Map(data.entries()) : new Map(data ?? []); const collection = createUpdatableTag(); const storages = new Map(); function storageFor(key) { let storage = storages.get(key); if (storage === undefined) { storage = createUpdatableTag(); storages.set(key, storage); } return storage; } function dirtyStorageFor(key) { const storage = storages.get(key); if (storage) { DIRTY_TAG(storage); } } const proxy = new Proxy(target, { get(target, prop, receiver) { if (prop === 'set') { return function (key, value) { const hasExisting = target.has(key); if (hasExisting) { // TS doesn't know about the relationship between has and get // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const isUnchanged = equals(target.get(key), value); if (isUnchanged) return proxy; } dirtyStorageFor(key); DIRTY_TAG(collection); target.set(key, value); return proxy; }; } if (prop === 'delete') { return function (key) { if (!target.has(key)) return false; dirtyStorageFor(key); DIRTY_TAG(collection); storages.delete(key); return target.delete(key); }; } if (prop === 'clear') { return function () { if (target.size === 0) return; storages.forEach(s => DIRTY_TAG(s)); storages.clear(); DIRTY_TAG(collection); target.clear(); }; } if (prop === 'get') { return function (key) { consumeTag(storageFor(key)); return target.get(key); }; } if (prop === 'has') { return function (key) { consumeTag(storageFor(key)); return target.has(key); }; } if (prop === 'size') { consumeTag(collection); return target.size; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { return function (...args) { consumeTag(collection); // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return value.apply(target, args); }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; } }); return proxy; } class TrackedObject { #options; #storages = new Map(); #collection = createUpdatableTag(); #readStorageFor(key) { let storage = this.#storages.get(key); if (storage === undefined) { storage = createUpdatableTag(); this.#storages.set(key, storage); } consumeTag(storage); } #dirtyStorageFor(key) { 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, options) { 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); 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]; }, has(target, prop) { self.#readStorageFor(prop); return prop in target; }, ownKeys(target) { 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], value); if (isUnchanged) { return true; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment target[prop] = value; self.#dirtyStorageFor(prop); self.#dirtyCollection(); return true; }, deleteProperty(target, prop) { if (prop in target) { delete target[prop]; self.#dirtyStorageFor(prop); self.#storages.delete(prop); self.#dirtyCollection(); } return true; }, getPrototypeOf() { return TrackedObject.prototype; } }); } } function trackedObject(data, options) { return new TrackedObject(data ?? {}, { 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. */ }); } /* eslint-disable @typescript-eslint/no-explicit-any */ // Using a Proxy-based approach so that any new methods added to the Set // interface are automatically supported without needing to manually // re-implement each one. function trackedSet(data, options) { const equals = options?.equals ?? Object.is; const target = new Set(data ?? []); const collection = createUpdatableTag(); const storages = new Map(); function storageFor(key) { let storage = storages.get(key); if (storage === undefined) { storage = createUpdatableTag(); storages.set(key, storage); } return storage; } function dirtyStorageFor(key) { const storage = storages.get(key); if (storage) { DIRTY_TAG(storage); } } const proxy = new Proxy(target, { get(target, prop, receiver) { if (prop === 'add') { return function (value) { if (target.has(value)) { const isUnchanged = equals(value, value); if (isUnchanged) return proxy; } else { DIRTY_TAG(collection); } dirtyStorageFor(value); target.add(value); return proxy; }; } if (prop === 'delete') { return function (value) { if (!target.has(value)) return false; dirtyStorageFor(value); DIRTY_TAG(collection); storages.delete(value); return target.delete(value); }; } if (prop === 'clear') { return function () { if (target.size === 0) return; storages.forEach(s => DIRTY_TAG(s)); DIRTY_TAG(collection); storages.clear(); target.clear(); }; } if (prop === 'has') { return function (value) { consumeTag(storageFor(value)); return target.has(value); }; } if (prop === 'size') { consumeTag(collection); return target.size; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { return function (...args) { consumeTag(collection); // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return value.apply(target, args); }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; } }); return proxy; } // Using a Proxy-based approach so that any new methods added to the WeakMap // interface (like getOrInsert, getOrInsertComputed, etc.) are automatically // supported without needing to manually re-implement each one. function trackedWeakMap(data, options) { const equals = options?.equals ?? Object.is; const existing = data ?? []; /** * SAFETY: note that when passing in an existing weak map, we can't * clone it as it is not iterable and not a supported type of structuredClone */ const target = existing instanceof WeakMap ? existing : new WeakMap(existing); const storages = new WeakMap(); function storageFor(key) { let storage = storages.get(key); if (storage === undefined) { storage = createUpdatableTag(); storages.set(key, storage); } return storage; } function dirtyStorageFor(key) { const storage = storages.get(key); if (storage) { DIRTY_TAG(storage); } } const proxy = new Proxy(target, { get(target, prop, receiver) { if (prop === 'set') { return function (key, value) { const hasExisting = target.has(key); if (hasExisting) { const isUnchanged = equals(target.get(key), value); if (isUnchanged) return proxy; } dirtyStorageFor(key); target.set(key, value); return proxy; }; } if (prop === 'delete') { return function (key) { if (!target.has(key)) return false; dirtyStorageFor(key); storages.delete(key); return target.delete(key); }; } if (prop === 'get') { return function (key) { consumeTag(storageFor(key)); return target.get(key); }; } if (prop === 'has') { return function (key) { consumeTag(storageFor(key)); return target.has(key); }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return value.bind(target); } // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; } }); return proxy; } // Using a Proxy-based approach so that any new methods added to the WeakSet // interface are automatically supported without needing to manually // re-implement each one. /** * NOTE: we cannot pass a WeakSet because WeakSets are not iterable */ /** * Creates an instanceof WeakSet from an optional list of entries * */ function trackedWeakSet(data, options) { const equals = options?.equals ?? Object.is; const target = new WeakSet(data ?? []); const storages = new WeakMap(); function storageFor(key) { let storage = storages.get(key); if (storage === undefined) { storage = createUpdatableTag(); storages.set(key, storage); } return storage; } function dirtyStorageFor(key) { const storage = storages.get(key); if (storage) { DIRTY_TAG(storage); } } const proxy = new Proxy(target, { get(target, prop, receiver) { if (prop === 'add') { return function (value) { /** * 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 (target.has(value)) { /** * This looks a little silly, where a always will === b, * but see the note above. */ const isUnchanged = equals(value, value); if (isUnchanged) return proxy; } // Add to vals first to get better error message target.add(value); dirtyStorageFor(value); return proxy; }; } if (prop === 'delete') { return function (value) { if (!target.has(value)) return false; dirtyStorageFor(value); storages.delete(value); return target.delete(value); }; } if (prop === 'has') { return function (value) { consumeTag(storageFor(value)); return target.has(value); }; } // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment const value = Reflect.get(target, prop, receiver); if (typeof value === 'function') { // eslint-disable-next-line @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access, @typescript-eslint/no-unsafe-call return value.bind(target); } // eslint-disable-next-line @typescript-eslint/no-unsafe-return return value; } }); return proxy; } export { trackedArray, trackedMap, trackedObject, trackedSet, trackedWeakMap, trackedWeakSet };