import * as _ from "lodash"; import * as React from "react"; import { isEqualWithoutFunctions } from "../utilities/collections"; import { promised } from "../utilities/functions"; /** * @module * * ## Rejected updates * * Updates can be rejected. A rejected update indicates to the * ControlledUpdates component that the user component was in * an invalid state to process the update. These rejected updates * are stored and **must be explicitly flushed**. */ type identifiedPromised = [promised, string?]; export interface IUpdateControllable { /** * Block updates until `allowUpdates` is called */ preventUpdates(): void; /** * Allow updates until `blockUpdates` is called */ allowUpdates(): void; /** * Add an update to be run when updates are allowed. If updates * are allowed when the update is pushed, it will be run. */ pushUpdate(update: promised, id?: string): void; /** * Explicitly run an update. */ flushUpdate?(id: string, flushed?: () => void): void; /** * Accept a reject update(s) */ acceptUpdate?(id: string): void; } export interface IControlledUpdatesState

{ /** * This flag is toggled by `preventUpdates` and `allowUpdates`. If this * is false, any updates will be blocked. */ canUpdate: boolean; /** * This flag is used internally to notate whether the component has begun processing updates. */ isUpdating: boolean; /** * This flag is used internally to notate whether the component has begun flushing an update. */ isFlushing: boolean; /** * The list of updates */ updates: (identifiedPromised)[]; /** * The execution of an update can be rejected. When this happens, the update is pushed * onto the `rejectedUpdate` stack. `rejectedUpdates` are only executed when updates would * have normally been processed. Rejected updates are non-replacing. This is distinct from * regular updates. In the future, this will be cleaned up to be some sort of configurable flag * on an update type. */ rejectedUpdates: (identifiedPromised)[]; /** * The props that need to be passed down to the component. */ componentProps?: P; /** * The props that have been passed down to the component. If * this and `componentProps` are not the same then we know we * have new props to pass down to the component */ prevComponentProps?: P; } const defaultControlledUpdatesState = { canUpdate: true, isUpdating: false, isFlushing: false, updates: [], rejectedUpdates: [], }; export const withControlledUpdates =

( Component: React.ComponentType

) => class extends React.Component> { public static getDerivedStateFromProps(props: P, state: IControlledUpdatesState

) { return { componentProps: state.componentProps || props, prevComponentProps: state.prevComponentProps || state.componentProps || props, }; } public state: IControlledUpdatesState

= defaultControlledUpdatesState; public shouldComponentUpdate(nextProps: P, nextState: IControlledUpdatesState

) { if ( ( this.state.canUpdate || // If should update... (!this.state.canUpdate && nextState.canUpdate) // ...or should begin update ) && nextState.canUpdate && // ...and isn't turning off updates ( // ... and (!this.state.isUpdating && !nextState.isUpdating) || // ...isn't still updating (this.state.isUpdating && !nextState.isUpdating) // ...or is ending updates ) && ( (!this.state.isFlushing && !nextState.isFlushing) || // ...isn't still flushing (this.state.isFlushing && !nextState.isFlushing) // ...or is ending flushing ) ) { // Now that we know that we can update, should we? if ( !_.isEmpty(this.state.updates) || // If there are updates to process... !_.isEmpty(nextState.updates) || // ... or if there are new updates to process !isEqualWithoutFunctions(this.props, nextProps) || // ...or we've received new props to queue !isEqualWithoutFunctions(this.state.componentProps, nextState.componentProps) || // ...or we've updated the the props we want to pass !isEqualWithoutFunctions(this.state.componentProps, this.props) || // ...or we have stale props !isEqualWithoutFunctions(this.state.componentProps, nextProps) || // ...or we are about to have stale props !isEqualWithoutFunctions(this.state.prevComponentProps, this.state.componentProps) || // ...or the component has stale props !isEqualWithoutFunctions(this.state.prevComponentProps, nextState.componentProps) // ...or there are new props for the component ) { return true; } } return false; } public componentDidUpdate(prevProps: P, prevState: IControlledUpdatesState

) { // Guard against an invalid update state if (this.state.isUpdating) return; this.setState({ isUpdating: true, }, async () => { // First, check if we just applied the new props to the component and need to update our tracking. if (!isEqualWithoutFunctions(this.state.prevComponentProps, this.state.componentProps)) { await this.enqueueUpdate(((passedProps) => () => { return new Promise((resolve) => { this.setState({ prevComponentProps: passedProps, }, () => { resolve(); }); }); })(this.state.componentProps)); } // Now, process any outstanding updates if (!_.isEmpty(this.state.updates)) { await (async () => { // Loop until while (!_.isEmpty(this.state.updates)) { await new Promise(async (resolve) => { // @ts-ignore const [updateFn, updateId] = this.state.updates[0]; let rejectedUpdate; try { await updateFn(); } catch { rejectedUpdate = [updateFn, updateId]; } this.setState({ updates: this.state.updates.filter((item, index) => index !== 0), ...rejectedUpdate && { rejectedUpdates: this.state.rejectedUpdates.concat([rejectedUpdate]), } }, () => { resolve(); }); }); // Guard against preventing updating early if (!this.state.canUpdate) break; } })(); } // We've received new props that haven't been passed if (!isEqualWithoutFunctions(this.state.componentProps, this.props)) { this.pushUpdate(((props) => () => { return new Promise((resolve) => { this.setState({ componentProps: props, }, () => { resolve(); }); }); })(this.props)); } this.setState({ isUpdating: false, }); }); } public render = () => { return ( ); }; public preventUpdates = () => { this.setState({ canUpdate: false }) }; public allowUpdates = () => { this.setState({ canUpdate: true }) }; public pushUpdate = (update: promised, id?: string) => { const updates: identifiedPromised[] = (() => { if (_.find(this.state.rejectedUpdates, ([__, updateId]) => updateId === id)) { // if the pushed update has the same identifier as an update that has been rejected, we reject it this.rejectUpdate([update, id]); return this.state.updates; } else if (_.find(this.state.updates, ([__, updateId]) => updateId === id)) { // if the pushed update already exists as a valid update, we replace it return this.state.updates.map(([oldUpdate, oldUpdateId]) => { if (oldUpdateId === id) { return [update, id] as identifiedPromised; } return [oldUpdate, oldUpdateId] as identifiedPromised; }); } // otherwise, it is a new or unidentified update return this.state.updates.concat([[update, id] as identifiedPromised]); })(); this.setState({ updates, }); }; public rejectUpdate = (update: identifiedPromised) => { this.setState({ rejectedUpdates: this.state.rejectedUpdates.concat([update]), }); } public canFlush = (id: string): boolean => { const update = _.find(this.state.updates, ([__, updateId]) => updateId === id); const rejectedUpdate = _.find(this.state.rejectedUpdates, ([__, updateId]) => updateId === id); return !!update || !!rejectedUpdate; } // TODO: Clean this method up a bit. Return Promise. Generalize `runUpdate`. Add flushed update to rejected if failed. 'Rejected' naming inconsistent public flushUpdate = (id: string, flushed?: () => void) => { const update = _.find(this.state.updates, ([__, updateId]) => updateId === id); const rejectedUpdate = _.find(this.state.rejectedUpdates, ([__, updateId]) => updateId === id); // This is an invalid state. It is corrected below with the double filter. if (update && rejectedUpdate) console.warn(`There is a duplication of update ${id} in rejectedUpdates!`); if (update || rejectedUpdate) { const [updateFn, updateId] = update || rejectedUpdate; let rejected = []; this.setState({ isFlushing: true, }, async () => { try { if (_.isFunction(flushed)) { flushed(); } await updateFn(); } catch { rejected = [[updateFn, updateId]]; } this.setState({ isFlushing: false, updates: this.state.updates.filter(update => update[1] !== updateId), rejectedUpdates: this.state.rejectedUpdates.filter(update => update[1] !== updateId).concat(rejected), }); }); } } /** * Accept and run rejected update(s) */ public acceptUpdate = (id: string) => { // Guard against no rejectedUpdates if (_.isEmpty(this.state.rejectedUpdates)) return; const rejectedUpdates = _.filter(this.state.rejectedUpdates, ([__, rejectedUpdateId]) => rejectedUpdateId === id); if (!_.isEmpty(rejectedUpdates)) { this.setState({ isFlushing: true, }, async () => { let rejected = []; while (!_.isEmpty(rejectedUpdates)) { await new Promise(async (resolve) => { // @ts-ignore const [updateFn, updateId] = rejectedUpdates.shift(); try { await updateFn(); } catch { rejected.push([updateFn, updateId]); } resolve(); }); } this.setState({ isFlushing: false, rejectedUpdates: _.filter(this.state.rejectedUpdates, ([__, rejectedUpdateId]) => rejectedUpdateId !== id).concat(rejected), }); }); } } public enqueueUpdate = (update: promised, id?: string) => { return new Promise(resolve => { this.setState({ updates: [[update, id] as identifiedPromised].concat(this.state.updates), }, () => { resolve(); }); }); } };