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 (