blob: f3733314ff531636c05a4b22880bc83c27374f63 [file] [log] [blame]
// Copyright (C) 2023 The Android Open Source Project
//
// 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 produce, {Draft} from 'immer';
import {Disposable} from './disposable';
import {getPath, Path, setPath} from './object_utils';
export type Migrate<T> = (init: unknown) => T;
export type Edit<T> = (draft: Draft<T>) => void;
export type Callback<T> = (store: Store<T>, previous: T) => void;
/**
* Create a new root-level store.
*
* @template T The type of this store's state.
* @param {T} initialState Initial state of the store.
* @return {Store<T>} The newly created store.
*/
export function createStore<T>(initialState: T): Store<T> {
return new RootStore<T>(initialState);
}
export interface Store<T> extends Disposable {
/**
* Access the immutable state of this store.
*/
get state(): T;
/**
* Mutate the store's state.
*
* @param {Edit<T> | Edit<T>[]} edits The edit (or edits) to the store.
*/
edit(edits: Edit<T> | Edit<T>[]): void;
/**
* Create a sub-store from a subtree of the state from this store.
*
* The returned store looks and feels like a regular store but acts only on a
* specific subtree of its parent store. Reads are writes are channelled
* through to the parent store via the |migrate| function.
*
* |migrate| is called the first time we access our sub-store's state and
* whenever the subtree changes in the root store.
* This migrate function takes the state of the subtree from the sub-store's
* parent store which has unknown type and is responsible for returning a
* value whose type matches that of the sub-store's state.
*
* Sub-stores may be created over the top of subtrees which are not yet fully
* defined. The state is written to the parent store on first edit. The
* sub-store can also deal with the underlying subtree becoming undefined
* again at some point in the future, and so is robust to unpredictable
* changes to the root store.
*
* @template U The type of the sub-store's state.
* @param path The path to the subtree this sub-store is based on.
* @example
* // Given a store whose state takes the form:
* {
* foo: {
* bar: [ {baz: 123}, {baz: 42} ],
* },
* }
*
* // A sub-store crated on path: ['foo','bar', 1] would only see the state:
* {
* baz: 42,
* }
* @param migrate A function used to migrate from the parent store's subtree
* to the sub-store's state.
* @example
* interface RootState {dict: {[key: string]: unknown}};
* interface SubState {foo: string};
*
* const store = createStore({dict: {}});
* const migrate = (init: unknown) => (init ?? {foo: 'bar'}) as SubState;
* const subStore = store.createSubStore(store, ['dict', 'foo'], migrate);
* // |dict['foo']| will be created the first time we edit our sub-store.
* @warning Migration functions should properly validate the incoming state.
* Blindly using type assertions can lead to instability.
* @returns {Store<U>} The newly created sub-store.
*/
createSubStore<U>(path: Path, migrate: Migrate<U>): Store<U>;
/**
* Subscribe for notifications when any edits are made to this store.
*
* @param callback The function to be called.
* @returns {Disposable} When this is disposed, the subscription is removed.
*/
subscribe(callback: Callback<T>): Disposable;
}
/**
* This class implements a standalone store (i.e. one that does not depend on a
* subtree of another store).
* @template T The type of the store's state.
*/
class RootStore<T> implements Store<T> {
private internalState: T;
private subscriptions = new Set<Callback<T>>();
constructor(initialState: T) {
// Run initial state through immer to take advantage of auto-freezing
this.internalState = produce(initialState, () => {});
}
get state() {
return this.internalState;
}
edit(edit: Edit<T> | Edit<T>[]): void {
if (Array.isArray(edit)) {
this.applyEdits(edit);
} else {
this.applyEdits([edit]);
}
}
private applyEdits(edits: Edit<T>[]): void {
const originalState = this.internalState;
const newState = edits.reduce((state, edit) => {
return produce(state, edit);
}, originalState);
if (originalState !== newState) {
this.internalState = newState;
// Notify subscribers
this.subscriptions.forEach((sub) => {
sub(this, originalState);
});
}
}
createSubStore<U>(path: Path, migrate: Migrate<U>): Store<U> {
return new SubStore(this, path, migrate);
}
subscribe(callback: Callback<T>): Disposable {
this.subscriptions.add(callback);
return {
dispose: () => {
this.subscriptions.delete(callback);
},
};
}
dispose(): void {
// No-op
}
}
/**
* This class implements a sub-store, one that is based on a subtree of another
* store. The parent store can be a root level store or another sub-store.
*
* This particular implementation of a sub-tree implements a write-through cache
* style implementation. The sub-store's state is cached internally and all
* edits are written through to the parent store as with a best-effort approach.
* If the subtree does not exist in the parent store, an error is printed to
* the console but the operation is still treated as a success.
*
* @template T The type of the sub-store's state.
* @template ParentT The type of the parent store's state.
*/
class SubStore<T, ParentT> implements Store<T> {
private parentState: unknown;
private cachedState: T;
private parentStoreSubscription: Disposable;
private subscriptions = new Set<Callback<T>>();
constructor(
private readonly parentStore: Store<ParentT>,
private readonly path: Path,
private readonly migrate: (init: unknown) => T,
) {
this.parentState = getPath<unknown>(this.parentStore.state, this.path);
// Run initial state through immer to take advantage of auto-freezing
this.cachedState = produce(migrate(this.parentState), () => {});
// Subscribe to parent store changes.
this.parentStoreSubscription = this.parentStore.subscribe(() => {
const newRootState = getPath<unknown>(this.parentStore.state, this.path);
if (newRootState !== this.parentState) {
this.subscriptions.forEach((callback) => {
callback(this, this.cachedState);
});
}
});
}
get state(): T {
const parentState = getPath<unknown>(this.parentStore.state, this.path);
if (this.parentState === parentState) {
return this.cachedState;
} else {
this.parentState = parentState;
return (this.cachedState = produce(this.cachedState, () => {
return this.migrate(parentState);
}));
}
}
edit(edit: Edit<T> | Edit<T>[]): void {
if (Array.isArray(edit)) {
this.applyEdits(edit);
} else {
this.applyEdits([edit]);
}
}
private applyEdits(edits: Edit<T>[]): void {
const originalState = this.cachedState;
const newState = edits.reduce((state, edit) => {
return produce(state, edit);
}, originalState);
if (originalState !== newState) {
this.parentState = newState;
try {
this.parentStore.edit((draft) => {
setPath(draft, this.path, newState);
});
} catch (error) {
if (error instanceof TypeError) {
console.warn('Failed to update parent store at ', this.path);
} else {
throw error;
}
}
this.cachedState = newState;
this.subscriptions.forEach((sub) => {
sub(this, originalState);
});
}
}
createSubStore<SubtreeState>(
path: Path,
migrate: Migrate<SubtreeState>,
): Store<SubtreeState> {
return new SubStore(this, path, migrate);
}
subscribe(callback: Callback<T>): Disposable {
this.subscriptions.add(callback);
return {
dispose: () => {
this.subscriptions.delete(callback);
},
};
}
dispose(): void {
this.parentStoreSubscription.dispose();
}
}