// Copyright 2023 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {type ActionsProducerGen, ConcurrentActionInvalidatedError, isActionsProducer} from './actions_producer.js';
import {type Selector, SelectorEmitter, SelectorNode} from './selector.js';
/** The Payload type for Action. */
type PayloadType = Object|void;
/**
* Actions are handled by the store according to their name and payload,
* triggering reducers.
*/
export interface Action<Payload extends PayloadType = any> {
type: string;
payload?: Payload;
}
/**
* A callable object that generates actions of a given type while enforcing the
* payload typing for that type of action.
*
* For convenience and debugging purposes, it also includes the action type.
*
*/
export interface ActionFactory<Payload extends PayloadType> {
(payload: Payload): (Action<Payload>);
type: Action<Payload>['type'];
}
/** Reducers generate a new state from the current state and a payload. */
export type Reducer<State, Payload extends PayloadType> =
(state: State, payload: Payload) => State;
type ReducerMap<State> = Map<Action['type'], Reducer<State, any>>;
type ReducersMap<State> = Map<Action['type'], Array<Reducer<State, any>>>;
/**
* Slices represent a part of the state that is nested directly under the root
* state, aggregating its reducers and selectors.
* @template State The shape of the store's root state.
* @template LocalState The shape of this slice.
*/
// eslint-disable-next-line @typescript-eslint/naming-convention
export class Slice<State, LocalState> {
/**
* Reducers registered with this slice.
* Only one reducer per slice can be associated with a given action type.
*/
reducers: ReducerMap<State> = new Map();
/**
* The slice's default selector - a selector that is created automatically
* when the slice is constructed. It selects the slice's part of the state.
*/
selector: SelectorNode<LocalState> =
SelectorNode.createDisconnectedNode(this.name);
/**
* @param name The prefix to be used when registering action types with
* this slice.
*/
constructor(public name: string) {}
/**
* Returns the full action name given by prepending the slice's name to the
* given action type (the full name is formatted as "[SLICE_NAME] TYPE").
*
* If the given action type is already the full name, it's returned without
* any changes.
*
* Note: the only valid scenario where the given type is the full name is when
* registering a reducer for an action primarily registered in another slice.
*/
private prependSliceName_(type: string) {
const isFullName = type[0] === '[';
return isFullName ? type : `[${this.name}] ${type}`;
}
/**
* Returns an action factory for the added reducer.
* @param localType The name of the action handled by this reducer. It should
* be either a new action, (e.g., 'do-thing') in which case it will get
* prefixed with the slice's name (e.g., '[sliceName] do-thing'), or an
* existing action from another slice (e.g., `someActionFactory.type`).
* @returns A callable action factory that also holds the type and payload
* typing of the actions it produces. Those can be used to register
* reducers in other slices with the same action type.
*/
addReducer<Payload extends PayloadType>(
localType: Action['type'],
reducer: Reducer<State, Payload>): ActionFactory<Payload> {
const type = this.prependSliceName_(localType);
if (this.reducers.get(type)) {
throw new Error(
'Attempting to register multiple reducers ' +
`within slice for the same action type: ${type}`);
}
this.reducers.set(type, reducer);
const actionFactory = (payload: Payload) => ({type, payload});
// Include action type so different slices can register reducers for the
// same action type.
actionFactory.type = type;
return actionFactory;
}
}
/**
* @template State: The shape/interface of the state.
*/
export interface StoreObserver<State> {
onStateChanged(newState: State): void;
}
/**
* A generic datastore for the state of a page, where the state is publicly
* readable but can only be modified by dispatching an Action.
*
* The Store should be extended by specifying `StateType`, the app state type
* associated with the store.
*/
export class BaseStore<State> {
/**
* A map of action names to reducers handled by the store.
*/
private reducers_: ReducersMap<State> = new Map();
/**
* The current state stored in the Store.
*/
private state_: State;
/**
* Whether the Store has been initialized. See init() method to initialize.
*/
private initialized_: boolean = false;
/** Queues actions while the Store un-initialized. */
private queuedActions_: Array<Action|ActionsProducerGen>;
/**
* Observers that are notified when the State is updated by Action/Reducer.
*/
private observers_: Array<StoreObserver<State>>;
/**
* Batch mode groups multiple Action mutations and only notify the observes
* at the end of the batch. See beginBatchUpdate() and endBatchUpdate()
* methods.
*/
private batchMode_: boolean = false;
/**
* The store's default selector - a selector that is created automatically
* when the store is constructed. It selects the root state.
*/
selector: Selector<State>;
/**
* The DAG representation of selectors held by the store. It ensures
* selectors are updated in an efficient manner. For more information,
* please see the `SelectorEmitter` class documentation.
*/
private selectorEmitter_ = new SelectorEmitter();
constructor(state: State, slices: Array<Slice<State, any>>) {
this.state_ = state;
this.queuedActions_ = [];
this.observers_ = [];
this.initialized_ = false;
this.batchMode_ = false;
const sliceNames = new Set(slices.map(slice => slice.name));
if (sliceNames.size !== slices.length) {
throw new Error(
'One or more given slices have the same name. ' +
'Please ensure slices are uniquely named: ' +
[...sliceNames].join(', '));
}
// Connect the default root selector to the Selector Emitter.
const rootSelector =
SelectorNode.createSourceNode(() => this.state_, 'root');
this.selectorEmitter_.addSource(rootSelector);
this.selector = rootSelector;
for (const slice of slices) {
// Connect the slice's default selector to the store's.
slice.selector.select = (state) => state[slice.name];
slice.selector.parents = [rootSelector];
// Populate reducers with slice.
for (const [type, reducer] of slice.reducers.entries()) {
const reducerList = this.reducers_.get(type);
if (!reducerList) {
this.reducers_.set(type, [reducer]);
} else {
reducerList.push(reducer);
}
}
}
}
/**
* Marks the Store as initialized.
* While the Store is not initialized, no action is processed and no observes
* are notified.
*
* It should be called by the app's initialization code.
*/
init(initialState: State) {
this.state_ = initialState;
this.queuedActions_.forEach((action) => {
if (isActionsProducer(action)) {
this.consumeProducedActions_(action);
} else {
this.dispatchInternal_(action);
}
});
this.initialized_ = true;
this.selectorEmitter_.processChange();
this.notifyObservers_(this.state_);
}
isInitialized(): boolean {
return this.initialized_;
}
/**
* Subscribe to Store changes/updates.
* @param observer Callback called whenever the Store is updated.
* @returns callback to unsubscribe the observer.
*/
subscribe(observer: StoreObserver<State>): () => void {
this.observers_.push(observer);
return this.unsubscribe.bind(this, observer);
}
/**
* Removes the observer which will stop receiving Store updates.
* @param observer The instance that was observing the store.
*/
unsubscribe(observer: StoreObserver<State>) {
// Create new copy of `observers_` to ensure elements are not removed
// from the array in the middle of the loop in `notifyObservers_()`.
this.observers_ = this.observers_.filter(o => o !== observer);
}
/**
* Begin a batch update to store data, which will disable updates to the
* observers until `endBatchUpdate()` is called. This is useful when a single
* UI operation is likely to cause many sequential model updates.
*/
beginBatchUpdate() {
this.batchMode_ = true;
}
/**
* End a batch update to the store data, notifying the observers of any
* changes which occurred while batch mode was enabled.
*/
endBatchUpdate() {
this.batchMode_ = false;
this.notifyObservers_(this.state_);
}
/** @returns the current state of the store. */
getState(): State {
return this.state_;
}
/**
* Dispatches an Action to the Store.
*
* For synchronous actions it sends the action to the reducers, which updates
* the Store state, then the Store notifies all subscribers.
* If the Store isn't initialized, the action is queued and dispatched to
* reducers during the initialization.
*/
dispatch(action: Action|ActionsProducerGen) {
if (!this.initialized_) {
this.queuedActions_.push(action);
return;
}
if (isActionsProducer(action)) {
this.consumeProducedActions_(action);
} else {
this.dispatchInternal_(action);
}
}
/**
* Enable/Disable the debug mode for the store. More logs will be displayed in
* the console with debug mode on.
*/
setDebug(isDebug: boolean): void {
if (isDebug) {
localStorage.setItem('DEBUG_STORE', '1');
} else {
localStorage.removeItem('DEBUG_STORE');
}
}
/** Synchronously call apply the `action` by calling the reducer. */
private dispatchInternal_(action: Action) {
this.reduce(action);
}
/**
* Consumes the produced actions from the actions producer.
* It dispatches each generated action.
*/
private async consumeProducedActions_(actionsProducer: ActionsProducerGen) {
while (true) {
try {
const {done, value} = await actionsProducer.next();
// Accept undefined to accept empty `yield;` or `return;`.
// The empty `yield` is useful to allow the generator to be stopped at
// any arbitrary point.
if (value !== undefined) {
this.dispatch(value);
}
if (done) {
return;
}
} catch (error) {
if (isInvalidationError(error)) {
// This error is expected when the actionsProducer has been
// invalidated.
return;
}
console.warn('Failure executing actions producer', error);
}
}
}
/** Apply the `action` to the Store by calling the reducer. */
protected reduce(action: Action) {
const isDebugStore = isDebugStoreEnabled();
if (isDebugStore) {
console.groupCollapsed(`Action: ${action.type}`);
console.dir(action.payload);
}
const reducers = this.reducers_.get(action.type);
if (!reducers || reducers.length === 0) {
console.error(`No registered reducers for action: ${action.type}`);
return;
}
this.state_ = reducers.reduce(
(state, reducer) => reducer(state, action.payload), this.state_);
// Batch notifications until after all initialization queuedActions are
// resolved.
if (this.initialized_ && !this.batchMode_) {
this.notifyObservers_(this.state_);
}
if (this.selector.get() !== this.state_) {
this.selectorEmitter_.processChange();
}
if (isDebugStore) {
console.groupEnd();
}
}
/** Notify observers with the current state. */
private notifyObservers_(state: State) {
this.observers_.forEach(o => {
try {
o.onStateChanged(state);
} catch (error) {
// Subscribers shouldn't fail, here we only log and continue to all
// other subscribers.
console.error(error);
}
});
}
}
/** Returns true when the error is a ConcurrentActionInvalidatedError. */
export function isInvalidationError(error: unknown): boolean {
if (!error) {
return false;
}
if (error instanceof ConcurrentActionInvalidatedError) {
return true;
}
// Rollup sometimes duplicate the definition of error class so the
// `instanceof` above fail in this condition.
if (error.constructor?.name === 'ConcurrentActionInvalidatedError') {
return true;
}
return false;
}
/**
* Check if the store is in debug mode or not. When it's set, action data will
* be logged in the console for debugging purpose.
*
* Run `fileManager.store_.setDebug(true)` in the console to enable it.
*/
export function isDebugStoreEnabled() {
return localStorage.getItem('DEBUG_STORE') === '1';
}