1// Copyright (C) 2023 The Android Open Source Project 2// 3// Licensed under the Apache License, Version 2.0 (the "License"); 4// you may not use this file except in compliance with the License. 5// You may obtain a copy of the License at 6// 7// http://www.apache.org/licenses/LICENSE-2.0 8// 9// Unless required by applicable law or agreed to in writing, software 10// distributed under the License is distributed on an "AS IS" BASIS, 11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12// See the License for the specific language governing permissions and 13// limitations under the License. 14 15import produce, {Draft} from 'immer'; 16 17import {Disposable} from './disposable'; 18import {getPath, Path, setPath} from './object_utils'; 19 20export type Migrate<T> = (init: unknown) => T; 21export type Edit<T> = (draft: Draft<T>) => void; 22export type Callback<T> = (store: Store<T>, previous: T) => void; 23 24/** 25 * Create a new root-level store. 26 * 27 * @template T The type of this store's state. 28 * @param {T} initialState Initial state of the store. 29 * @return {Store<T>} The newly created store. 30 */ 31export function createStore<T>(initialState: T): Store<T> { 32 return new RootStore<T>(initialState); 33} 34 35export interface Store<T> extends Disposable { 36 /** 37 * Access the immutable state of this store. 38 */ 39 get state(): T; 40 41 /** 42 * Mutate the store's state. 43 * 44 * @param {Edit<T> | Edit<T>[]} edits The edit (or edits) to the store. 45 */ 46 edit(edits: Edit<T> | Edit<T>[]): void; 47 48 /** 49 * Create a sub-store from a subtree of the state from this store. 50 * 51 * The returned store looks and feels like a regular store but acts only on a 52 * specific subtree of its parent store. Reads are writes are channelled 53 * through to the parent store via the |migrate| function. 54 * 55 * |migrate| is called the first time we access our sub-store's state and 56 * whenever the subtree changes in the root store. 57 * This migrate function takes the state of the subtree from the sub-store's 58 * parent store which has unknown type and is responsible for returning a 59 * value whose type matches that of the sub-store's state. 60 * 61 * Sub-stores may be created over the top of subtrees which are not yet fully 62 * defined. The state is written to the parent store on first edit. The 63 * sub-store can also deal with the underlying subtree becoming undefined 64 * again at some point in the future, and so is robust to unpredictable 65 * changes to the root store. 66 * 67 * @template U The type of the sub-store's state. 68 * @param path The path to the subtree this sub-store is based on. 69 * @example 70 * // Given a store whose state takes the form: 71 * { 72 * foo: { 73 * bar: [ {baz: 123}, {baz: 42} ], 74 * }, 75 * } 76 * 77 * // A sub-store crated on path: ['foo','bar', 1] would only see the state: 78 * { 79 * baz: 42, 80 * } 81 * @param migrate A function used to migrate from the parent store's subtree 82 * to the sub-store's state. 83 * @example 84 * interface RootState {dict: {[key: string]: unknown}}; 85 * interface SubState {foo: string}; 86 * 87 * const store = createStore({dict: {}}); 88 * const migrate = (init: unknown) => (init ?? {foo: 'bar'}) as SubState; 89 * const subStore = store.createSubStore(store, ['dict', 'foo'], migrate); 90 * // |dict['foo']| will be created the first time we edit our sub-store. 91 * @warning Migration functions should properly validate the incoming state. 92 * Blindly using type assertions can lead to instability. 93 * @returns {Store<U>} The newly created sub-store. 94 */ 95 createSubStore<U>(path: Path, migrate: Migrate<U>): Store<U>; 96 97 /** 98 * Subscribe for notifications when any edits are made to this store. 99 * 100 * @param callback The function to be called. 101 * @returns {Disposable} When this is disposed, the subscription is removed. 102 */ 103 subscribe(callback: Callback<T>): Disposable; 104} 105 106/** 107 * This class implements a standalone store (i.e. one that does not depend on a 108 * subtree of another store). 109 * @template T The type of the store's state. 110 */ 111class RootStore<T> implements Store<T> { 112 private internalState: T; 113 private subscriptions = new Set<Callback<T>>(); 114 115 constructor(initialState: T) { 116 // Run initial state through immer to take advantage of auto-freezing 117 this.internalState = produce(initialState, () => {}); 118 } 119 120 get state() { 121 return this.internalState; 122 } 123 124 edit(edit: Edit<T> | Edit<T>[]): void { 125 if (Array.isArray(edit)) { 126 this.applyEdits(edit); 127 } else { 128 this.applyEdits([edit]); 129 } 130 } 131 132 private applyEdits(edits: Edit<T>[]): void { 133 const originalState = this.internalState; 134 135 const newState = edits.reduce((state, edit) => { 136 return produce(state, edit); 137 }, originalState); 138 139 if (originalState !== newState) { 140 this.internalState = newState; 141 142 // Notify subscribers 143 this.subscriptions.forEach((sub) => { 144 sub(this, originalState); 145 }); 146 } 147 } 148 149 createSubStore<U>(path: Path, migrate: Migrate<U>): Store<U> { 150 return new SubStore(this, path, migrate); 151 } 152 153 subscribe(callback: Callback<T>): Disposable { 154 this.subscriptions.add(callback); 155 return { 156 dispose: () => { 157 this.subscriptions.delete(callback); 158 }, 159 }; 160 } 161 162 dispose(): void { 163 // No-op 164 } 165} 166 167/** 168 * This class implements a sub-store, one that is based on a subtree of another 169 * store. The parent store can be a root level store or another sub-store. 170 * 171 * This particular implementation of a sub-tree implements a write-through cache 172 * style implementation. The sub-store's state is cached internally and all 173 * edits are written through to the parent store as with a best-effort approach. 174 * If the subtree does not exist in the parent store, an error is printed to 175 * the console but the operation is still treated as a success. 176 * 177 * @template T The type of the sub-store's state. 178 * @template ParentT The type of the parent store's state. 179 */ 180class SubStore<T, ParentT> implements Store<T> { 181 private parentState: unknown; 182 private cachedState: T; 183 private parentStoreSubscription: Disposable; 184 private subscriptions = new Set<Callback<T>>(); 185 186 constructor( 187 private readonly parentStore: Store<ParentT>, 188 private readonly path: Path, 189 private readonly migrate: (init: unknown) => T, 190 ) { 191 this.parentState = getPath<unknown>(this.parentStore.state, this.path); 192 193 // Run initial state through immer to take advantage of auto-freezing 194 this.cachedState = produce(migrate(this.parentState), () => {}); 195 196 // Subscribe to parent store changes. 197 this.parentStoreSubscription = this.parentStore.subscribe(() => { 198 const newRootState = getPath<unknown>(this.parentStore.state, this.path); 199 if (newRootState !== this.parentState) { 200 this.subscriptions.forEach((callback) => { 201 callback(this, this.cachedState); 202 }); 203 } 204 }); 205 } 206 207 get state(): T { 208 const parentState = getPath<unknown>(this.parentStore.state, this.path); 209 if (this.parentState === parentState) { 210 return this.cachedState; 211 } else { 212 this.parentState = parentState; 213 return (this.cachedState = produce(this.cachedState, () => { 214 return this.migrate(parentState); 215 })); 216 } 217 } 218 219 edit(edit: Edit<T> | Edit<T>[]): void { 220 if (Array.isArray(edit)) { 221 this.applyEdits(edit); 222 } else { 223 this.applyEdits([edit]); 224 } 225 } 226 227 private applyEdits(edits: Edit<T>[]): void { 228 const originalState = this.cachedState; 229 230 const newState = edits.reduce((state, edit) => { 231 return produce(state, edit); 232 }, originalState); 233 234 if (originalState !== newState) { 235 this.parentState = newState; 236 try { 237 this.parentStore.edit((draft) => { 238 setPath(draft, this.path, newState); 239 }); 240 } catch (error) { 241 if (error instanceof TypeError) { 242 console.warn('Failed to update parent store at ', this.path); 243 } else { 244 throw error; 245 } 246 } 247 248 this.cachedState = newState; 249 250 this.subscriptions.forEach((sub) => { 251 sub(this, originalState); 252 }); 253 } 254 } 255 256 createSubStore<SubtreeState>( 257 path: Path, 258 migrate: Migrate<SubtreeState>, 259 ): Store<SubtreeState> { 260 return new SubStore(this, path, migrate); 261 } 262 263 subscribe(callback: Callback<T>): Disposable { 264 this.subscriptions.add(callback); 265 return { 266 dispose: () => { 267 this.subscriptions.delete(callback); 268 }, 269 }; 270 } 271 272 dispose(): void { 273 this.parentStoreSubscription.dispose(); 274 } 275} 276