export type StateUpdateCallback = ( path: Path, newState: DeepState, oldState: DeepState ) => void; function arraysEqual(a: any, b: any) { if (a === b) { return true; } if (a == null || b == null) { return false; } if (a.length !== b.length) { return false; } // If you don't care about the order of the elements inside // the array, you should sort both arrays here. for (let i = 0; i < a.length; ++i) { if (a[i] !== b[i]) { return false; } } return true; } function removePath(pathToRemove: Path, paths: Path[]): Path[] { let found = false; const result: Path[] = []; for (const path of paths) { if (arraysEqual(path, pathToRemove) && !found) { found = true; } else { result.push(path); } } return result; } export interface DeepStorage { /** * sets a value in deep storage and notifies subscribers. shortcut for * update where the old value is ignored */ set: (newValue: State) => Promise>; /** * Updates the whole state and notifies subscribers */ update: ( callback: (s: State) => State ) => Promise>; /** * Returns the state that this deep storage is managing */ state: State; /** * Creates a new DeepStorage at this point in the object path */ deep: ( path: Key ) => DeepStorage; /** * Gets the root deep storage */ root: () => DeepStorage; /** * The path from the root to this storage */ path: Path; /** * Returns an object with keys from State and values of * DeepStorage for that key */ props: { [P in keyof State]: DeepStorage }; /** * Get the value of a property */ prop: (name: Key) => State[Key]; addSubscriber: (subscriber: Subscriber) => void; removeSubscriber: (subscriber: Subscriber) => void; } /** * Is one array a prefix on another e.g. * * [] is a prefix of any array * ['asdf'] is a prefix of ['asdf', ...] * * etc. * * @param stateChangePath the full array to check, must not be null * @param subscriptionPath the partial array to check */ export function isPathMatch(stateChangePath: T[], subscriptionPath: T[]) { for ( let i = 0; i < Math.min(subscriptionPath.length, stateChangePath.length); i++ ) { if (stateChangePath[i] !== subscriptionPath[i]) { return false; } } return true; } export type stringNumberOrSymbol = string | number | symbol; export type Path = stringNumberOrSymbol[]; export class Subscriber { private static idGenerator: number = 0; public id: number; private callback: StateUpdateCallback | undefined; constructor() { this.id = Subscriber.idGenerator++; } public onChange(callback: StateUpdateCallback) { this.callback = callback; } public change( path: Path, newState: DeepState, oldState: DeepState ) { if (this.callback) { this.callback(path, newState, oldState); } } } // tslint:disable-next-line:max-classes-per-file export class DefaultDeepStorage implements DeepStorage { public path: Path = []; private subscriptions: { [key: number]: { paths: Path[]; subscriber: Subscriber }; } = {}; constructor(public state: State) {} public update = ( callback: (s: State) => State ): Promise> => { return this.updateIn()(callback); }; public set = (newValue: State) => { return this.updateIn()(() => newValue); }; public setIn = (...path: Path) => (newValue: DeepState) => { return this.updateIn(...path)(() => newValue); }; public merge = (partial: { [P in keyof State]: State[P] }) => { this.update(oldState => { for (const key in partial) { if (partial.hasOwnProperty(key)) { oldState[key] = partial[key]; } } return oldState; }); }; public updateIn = (...path: Path) => async ( callback: (s: DeepState) => DeepState ): Promise> => { const oldState = this.stateIn(...path); const newState = callback(oldState); if (path.length === 0) { this.state = newState as any; } else { // todo: this will no doubt cause some bugs... better to replace all the // parent objects too so that reference equality checks work in react this.stateIn(...path.slice(0, path.length - 1))[ path[path.length - 1] ] = newState; } const stateChangePath = path; for (const subscriberId in this.subscriptions) { if (this.subscriptions.hasOwnProperty(subscriberId)) { const subscriber = this.subscriptions[subscriberId]; // check to see if we have any matches if ( subscriber.paths.some(subscriberPath => isPathMatch(stateChangePath, subscriberPath) ) ) { subscriber.subscriber.change(stateChangePath, newState, oldState); } } } return this.deepIn(...path); }; public stateIn = (...path: Path) => { let currentState: any = this.state; const pathSoFar = []; for (const p of path) { pathSoFar.push(p); if (!(p in currentState)) { currentState[p] = {}; } currentState = currentState[p]; } return currentState; }; public deepIn = (...path: Path): DeepStorage => { return new NestedDeepStorage(path, this); }; public deep = ( name: stringNumberOrSymbol ): DeepStorage => { return this.deepIn(name); }; public addSubscriber = (subscriber: Subscriber) => { this.addSubscriberIn(...this.path)(subscriber); }; public addSubscriberIn = (...path: Path) => (subscriber: Subscriber) => { const subscription = this.subscriptions[subscriber.id]; if (subscription) { subscription.paths.push(path); } else { this.subscriptions[subscriber.id] = { paths: [path], subscriber }; } }; public removeSubscriber = (subscriber: Subscriber) => { this.removeSubscriberIn(...this.path)(subscriber); }; public removeSubscriberIn = (...path: Path) => (subscriber: Subscriber) => { const subscription = this.subscriptions[subscriber.id]; if (subscription) { subscription.paths = removePath(path, subscription.paths); } }; public root = () => this; get props() { const result: any = {}; for (const key of Object.keys(this.state)) { result[key] = this.deep(key); } return result as { [P in keyof State]: DeepStorage }; } public prop = (name: Key) => { return this.deep(name).state; }; } // tslint:disable-next-line:max-classes-per-file export class NestedDeepStorage implements DeepStorage { constructor( public path: Path, public rootStorage: DefaultDeepStorage ) {} public setIn = (...path: stringNumberOrSymbol[]) => ( newValue: DeepState ): Promise> => { return this.rootStorage.setIn(...this.path.concat(path))(newValue); }; public set = (newValue: State): Promise> => { return this.rootStorage.setIn(...this.path)(newValue); }; public update = ( callback: (s: State) => State ): Promise> => { return this.rootStorage.updateIn(...this.path)(callback); }; public updateIn = (...path: stringNumberOrSymbol[]) => ( callback: (s: DeepState) => DeepState ): Promise> => { return this.rootStorage.updateIn(...this.path.concat(path))(callback); }; get state() { return this.rootStorage.stateIn(...this.path); } public stateIn = (...path: stringNumberOrSymbol[]): DeepState => { return this.rootStorage.stateIn(...this.path.concat(path)); }; public deep = ( ...path: stringNumberOrSymbol[] ): DeepStorage => { return this.rootStorage.deepIn(...this.path.concat(path)); }; public root = () => this.rootStorage; get props() { const result: any = {}; for (const key of Object.keys(this.state)) { result[key] = this.deep(key); } return result as { [P in keyof State]: DeepStorage }; } public prop = (name: Key) => { return this.deep(name).state; }; public addSubscriber = (subscriber: Subscriber) => { this.rootStorage.addSubscriberIn(...this.path)(subscriber); }; public removeSubscriber = (subscriber: Subscriber) => { this.rootStorage.removeSubscriberIn(...this.path)(subscriber); }; } function numberOrString(value: string): stringNumberOrSymbol { const parsed = parseInt(value, 10); return parsed.toString() === value ? parsed : value; } export function parsePath(path: Path | stringNumberOrSymbol): Path { if (path instanceof Array) { return path; } else if (typeof path === "string") { return path.split("/").map(numberOrString); } else { return [path]; } } export function parsePaths(paths: { [key: string]: Path | stringNumberOrSymbol; }): { [key: string]: Path } { const result: { [key: string]: Path } = {}; for (const key in paths) { if (paths.hasOwnProperty(key)) { result[key] = parsePath(paths[key]); } } return result; } export const deepStorage = (s: State): DeepStorage => new DefaultDeepStorage(s);