• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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