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