/* * Copyright 2017 Palantir Technologies, Inc. All rights reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { requestIdleCallback } from "./requestIdleCallback"; export type SimpleStringifyable = string | number | null | undefined; export type Callback = () => void; /** * This class helps batch updates to large lists. * * For example, if your React component has many children, updating them all at * once may cause jank when reconciling the DOM. This class helps you update * only a few children per frame. * * A typical usage would be: * * ```tsx * public renderChildren = (allChildrenKeys: string[]) => { * * batcher.startNewBatch(); * * allChildrenKeys.forEach((prop1: string, index: number) => { * batcher.addArgsToBatch(prop1, "prop2", index); * }); * * batcher.removeOldAddNew((prop1: string, prop2: string, other: number) => { * return ; * }); * * if (!batcher.isDone()) { * batcher.idleCallback(this.forceUpdate()); * } * * const currentChildren = batcher.getList(); * return currentChildren; * } * * ``` */ export class Batcher { public static DEFAULT_ADD_LIMIT = 20; public static DEFAULT_UPDATE_LIMIT = 20; public static DEFAULT_REMOVE_LIMIT = 20; public static ARG_DELIMITER = "|"; private currentObjects: Record = {}; private oldObjects: Record = {}; private batchArgs: Record = {}; private done = true; private callback: Callback | undefined; /** * Resets the "batch" and "current" sets. This essentially clears the cache * and prevents accidental re-use of "current" objects. */ public reset() { this.batchArgs = {}; this.oldObjects = this.currentObjects; this.currentObjects = {}; } /** * Starts a new "batch" argument set */ public startNewBatch() { this.batchArgs = {}; } /** * Stores the variadic arguments to be later batched together. * * The arguments must be simple stringifyable objects. */ public addArgsToBatch(...args: SimpleStringifyable[]) { this.batchArgs[this.getKey(args)] = args; } /** * Compares the set of "batch" arguments to the "current" set. Creates any * new objects using the callback as a factory. Removes old objects. * * Arguments that are in the "current" set but were not part of the last * "batch" set are considered candidates for removal. Similarly, Arguments * that are part of the "batch" set but not the "current" set are candidates * for addition. * * The number of objects added and removed may be limited with the * `...Limit` parameters. * * Finally, the batcher determines if the batching is complete if the * "current" arguments match the "batch" arguments. */ public removeOldAddNew( callback: (...args: any[]) => T, addNewLimit = Batcher.DEFAULT_ADD_LIMIT, removeOldLimit = Batcher.DEFAULT_REMOVE_LIMIT, updateLimit = Batcher.DEFAULT_UPDATE_LIMIT, ) { // remove old const keysToRemove = this.setKeysDifference(this.currentObjects, this.batchArgs, removeOldLimit); keysToRemove.forEach(key => delete this.currentObjects[key]); // remove ALL old objects not in batch const keysToRemoveOld = this.setKeysDifference(this.oldObjects, this.batchArgs, -1); keysToRemoveOld.forEach(key => delete this.oldObjects[key]); // copy ALL old objects into current objects if not defined const keysToShallowCopy = Object.keys(this.oldObjects); keysToShallowCopy.forEach(key => { if (this.currentObjects[key] == null) { this.currentObjects[key] = this.oldObjects[key]; } }); // update old objects with factory const keysToUpdate = this.setKeysIntersection(this.oldObjects, this.currentObjects, updateLimit); keysToUpdate.forEach(key => { delete this.oldObjects[key]; this.currentObjects[key] = callback.apply(undefined, this.batchArgs[key]); }); // add new objects with factory const keysToAdd = this.setKeysDifference(this.batchArgs, this.currentObjects, addNewLimit); keysToAdd.forEach(key => (this.currentObjects[key] = callback.apply(undefined, this.batchArgs[key]))); // set `done` to true if sets match exactly after add/remove and there // are no "old objects" remaining this.done = this.setHasSameKeys(this.batchArgs, this.currentObjects) && Object.keys(this.oldObjects).length === 0; } /** * Returns true if the "current" set matches the "batch" set. */ public isDone() { return this.done; } /** * Returns all the objects in the "current" set. */ public getList(): T[] { return Object.keys(this.currentObjects).map(this.mapCurrentObjectKey); } /** * Registers a callback to be invoked on the next idle frame. If a callback * has already been registered, we do not register a new one. */ public idleCallback(callback: Callback) { if (!this.callback) { this.callback = callback; requestIdleCallback(this.handleIdleCallback); } } public cancelOutstandingCallback() { delete this.callback; } /** * Forcibly overwrites the current list of batched objects. Not recommended * for normal usage. */ public setList(objectsArgs: SimpleStringifyable[][], objects: T[]) { this.reset(); objectsArgs.forEach((args, i) => { this.addArgsToBatch(...args); this.currentObjects[this.getKey(args)] = objects[i]; }); this.done = true; } private getKey(args: SimpleStringifyable[]) { return args.join(Batcher.ARG_DELIMITER); } private handleIdleCallback = () => { const callback = this.callback; delete this.callback; callback?.(); }; private mapCurrentObjectKey = (key: string) => { return this.currentObjects[key]; }; private setKeysDifference(a: Record, b: Record, limit: number) { return this.setKeysOperation(a, b, "difference", limit); } private setKeysIntersection(a: Record, b: Record, limit: number) { return this.setKeysOperation(a, b, "intersect", limit); } /** * Compares the keys of A from B -- and performs an "intersection" or * "difference" operation on the keys. * * Note that the order of operands A and B matters for the "difference" * operation. * * Returns an array of at most `limit` keys. */ private setKeysOperation( a: Record, b: Record, operation: "intersect" | "difference", limit: number, ) { const result = []; const aKeys = Object.keys(a); for (let i = 0; i < aKeys.length && (limit < 0 || result.length < limit); i++) { const key = aKeys[i]; if ((operation === "difference" && a[key] && !b[key]) || (operation === "intersect" && a[key] && b[key])) { result.push(key); } } return result; } /** * Returns true of objects `a` and `b` have exactly the same keys. */ private setHasSameKeys(a: Record, b: Record) { const aKeys = Object.keys(a); const bKeys = Object.keys(b); if (aKeys.length !== bKeys.length) { return false; } for (const aKey of aKeys) { if (b[aKey] === undefined) { return false; } } return true; } }