signalstory
Version:
Signal-based state management for Angular that grows with your project. Explore a versatile toolbox with enriching plugins for developers at all levels.
1,826 lines • 66.3 kB
JavaScript
import { inject, Injector, signal, runInInjectionContext, assertInInjectionContext, computed } from '@angular/core';
import { isObservable, finalize, BehaviorSubject, filter, first } from 'rxjs';
/**
* Naively creates a deep clone of a given state object using JSON.parse and JSON.stringify.
* This approach is simple but not optimal for performance.
*
* @template TState - The type of the state object.
* @param state - The state object to be cloned.
* @returns A deep clone of the provided state object.
*/
function naiveDeepClone(state) {
return JSON.parse(JSON.stringify(state));
}
/**
* Creates a deep clone of a given value object using either the `structuredClone` method if available,
* or a naive approach using JSON.parse and JSON.stringify if `structuredClone` is not supported.
* The naive approach is simple but may have shortcomings and is not optimal for performance.
*
* @template T - The type of the value object.
* @param {T} value - The value object to be cloned.
* @returns {T} A deep clone of the provided value object.
*/
function deepClone(state) {
return window && 'structuredClone' in window
? structuredClone(state)
: naiveDeepClone(state);
}
/**
* Creates a shallow clone of a given value object
* @param state - The state to be cloned.
* @returns Shallow cloned state.
*/
function shallowClone(state) {
if (!state || typeof state !== 'object') {
return state;
}
if (Array.isArray(state))
return [...state];
if (state instanceof Date)
return new Date(state);
if (state instanceof RegExp)
return new RegExp(state);
if (state instanceof Set)
return new Set(state);
if (state instanceof Map)
return new Map(state);
return Object.assign({}, state);
}
/**
* Naive implementation of an immutable update function using a clone-and-mutate approach.
* This function serves as a placeholder that you can swap with a more optimized
* solution like the one provided by the 'immer.js' library (https://immerjs.github.io/immer/).
* Using 'immer.js' will result in more efficient and concise code for managing
* immutable updates to your state.
*
* @template TState - The type of the state object.
* @param currentState - The current state object to be updated.
* @param mutation - A function that modifies a draft copy of the state.
* @returns The new state object after applying the mutation.
*/
function naiveCloneAndMutateFunc(currentState, mutation) {
const clone = deepClone(currentState);
mutation(clone);
return clone;
}
/**
* Creates an empty Mediator registry.
*/
function createRegistry() {
return new WeakMap();
}
/**
* Root mediator registry instance.
*/
const rootRegistry = /*@__PURE__*/ createRegistry();
/**
* Register an event handler for a specific event.
*
* @param {MediatorRegistry} registry - The mediator registry to register the handler in.
* @param {TStore} store - The store instance associated with the event handler.
* @param {StoreEvent<TPayload>} event - The event to register.
* @param {(store: TStore, event: StoreEvent<TPayload>) => void} handler - The handler function to be executed when the event occurs.
* @throws {Error} if the event name is invalid.
*/
function register(registry, store, event, handler) {
const existingHandlers = registry.get(event) || new Set();
existingHandlers.add({
store: new WeakRef(store),
handler,
});
registry.set(event, existingHandlers);
}
/**
* Unregister event handlers for a specific store and events.
*
* @param {MediatorRegistry} registry - The mediator registry to unregister the handlers from.
* @param {TStore} store - The store instance to remove event handlers from.
* @param {...StoreEvent<any>[]} events - The events to remove handlers for.
*/
function unregister(registry, store, ...events) {
for (const event of events) {
const handlers = registry.get(event);
if (handlers) {
handlers.forEach(handler => {
const handlingStore = handler.store.deref();
if (!handlingStore || handlingStore === store) {
handlers.delete(handler);
}
});
if (handlers.size === 0) {
registry.delete(event);
}
}
}
}
function publish(registry, event, payload) {
const handlers = registry.get(event);
const eventWithPayload = {
name: event.name,
payload: payload,
};
if (handlers) {
const errors = [];
for (const handler of handlers) {
const store = handler.store.deref();
if (store) {
try {
handler.handler(store, eventWithPayload);
}
catch (error) {
errors.push(error);
}
}
else {
handlers.delete(handler);
}
}
if (errors.length > 0) {
throw new AggregateError(errors, `Errors in Handler for event ${eventWithPayload.name}`);
}
}
}
function publishStoreEvent(event, payload) {
publish(rootRegistry, event, payload);
}
/**
* Store registry of stores currently in scope
*/
const storeRegistry = new Set();
/**
* Iterates over each store in the registry and executes the specified callback function.
*
* @param callbackFn - The callback function to be executed for each store in the registry.
*/
function forEachStoreInScope(callbackFn) {
storeRegistry.forEach(registration => {
const store = registration.deref();
if (store) {
callbackFn(store);
}
else {
storeRegistry.delete(registration);
}
});
}
/**
* Adds a store to the registry.
*
* @param store - The store to be added to the registry.
*/
function addToRegistry(store) {
storeRegistry.add(new WeakRef(store));
}
/**
* Cleare Store registry.
* Only used for tests
*
*/
function clearRegistry() {
storeRegistry.clear();
}
/**
* Attempts to retrieve the Angular injector or returns null if not available.
*
* This function utilizes the `inject` function from Angular's dependency injection system
* to obtain an instance of the Angular `Injector`. If the injector is not available, it
* gracefully returns null.
*
* @returns The Angular injector if available, otherwise null.
*/
function getInjectorOrNull() {
try {
return inject(Injector);
}
catch (_) {
return null;
}
}
/**
* Checks if the provided object is a promise
* @param obj - The object to be checked.
* @returns True if the object is a promise, false otherwise.
* @template T - The type of the resolved value of the promise.
*/
function isPromise(obj) {
return (obj &&
typeof obj.then === 'function' &&
typeof obj.finally === 'function');
}
/**
* Executes a side effect based on the nature of the source object
* @param source - The source object
* @param sideEffect - The side effect function to be executed.
* @returns The source object with the side effect applied.
* @template T - The type of the source object.
*/
function withSideEffect(source, sideEffect) {
if (isObservable(source)) {
return source.pipe(finalize(sideEffect));
}
else if (isPromise(source)) {
return source.finally(sideEffect);
}
sideEffect();
return source;
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Represents a signal store that manages a state and provides methods for state mutation, event handling, and more.
* @typeparam TState The type of the store's state.
*/
class Store {
/**
* Creates a new instance of the store class.
* @param config The configuration options for the store.
*/
constructor(config) {
this.config = {
name: config.name ?? this.constructor.name,
initialState: config.initialState,
injector: config.injector ?? getInjectorOrNull(),
stateEqualityFn: config.stateEqualityFn ?? null,
plugins: config.plugins ?? [],
};
this._state = signal(this.config.initialState, ...(ngDevMode ? [{ debugName: "_state", equal: this.config.stateEqualityFn ?? undefined }] : [{
equal: this.config.stateEqualityFn ?? undefined,
}]));
addToRegistry(this);
this.config.plugins
.sort((a, b) => (b.precedence ?? 0) - (a.precedence ?? 0))
.forEach(plugin => {
if (plugin.init) {
this.initPostprocessor ??= [];
this.initPostprocessor.push(plugin.init);
}
if (plugin.preprocessCommand) {
this.commandPreprocessor ??= [];
this.commandPreprocessor.push(plugin.preprocessCommand);
}
if (plugin.postprocessCommand) {
this.commandPostprocessor ??= [];
this.commandPostprocessor.unshift(plugin.postprocessCommand);
}
if (plugin.preprocessEffect) {
this.effectPreprocessor ??= [];
this.effectPreprocessor.push(plugin.preprocessEffect);
}
if (plugin.postprocessEffect) {
this.effectPostprocessor ??= [];
this.effectPostprocessor.unshift(plugin.postprocessEffect);
}
});
this.initPostprocessor?.forEach(p => p(this));
}
/**
* Gets the name of the store
*/
get name() {
return this.config.name;
}
/**
* Gets the signal representing the store's current state.
*/
get state() {
return this._state.asReadonly();
}
/**
* Sets the store's state to the provided state, with an optional command name.
* @param newState The new state of the store.
* @param commandName The name of the command associated with the state change.
*/
set(newState, commandName) {
this.commandPreprocessor?.forEach(p => p(this, commandName));
this._state.set(newState);
this.commandPostprocessor?.forEach(p => p(this, commandName));
}
/**
* Updates the store's state based on the current state, with an optional command name.
* @param updateFn A function that updates the current state.
* @param commandName The name of the command associated with the state change.
*/
update(updateFn, commandName) {
this.commandPreprocessor?.forEach(p => p(this, commandName));
this._state.update(state => updateFn(state));
this.commandPostprocessor?.forEach(p => p(this, commandName));
}
/**
* Mutates the store's state using the provided mutator function, with an optional command name.
* @param mutator A function that mutates the current state.
* @param commandName The name of the command associated with the state mutation.
*/
mutate(mutator, commandName) {
this.commandPreprocessor?.forEach(p => p(this, commandName));
this._state.update(state => {
const cloned = shallowClone(state);
mutator(cloned);
return cloned;
});
this.commandPostprocessor?.forEach(p => p(this, commandName));
}
/**
* Registers a handler for the specified event in the store's mediator.
* @param event The event to register the handler for.
* @param handler The handler function to be executed when the event is published.
*/
registerHandler(event, handler) {
register(rootRegistry, this, event, handler);
}
unregisterHandler(...events) {
unregister(rootRegistry, this, ...events);
}
/**
* Runs an effect with the provided arguments and returns the result.
* The effect may be associated with the store itself but it may also be unrelated
* @typeparam TStore The types of the effect's target store.
* @typeparam TArgs The types of the effect's arguments.
* @typeparam TResult The type of the effect's result.
* @param effect The store effect to run.
* @param args The arguments to pass to the effect.
* @returns The result of the effect.
*/
runEffect(effect, ...args) {
const invocationId = performance.now() + Math.random();
this.effectPreprocessor?.forEach(p => p(this, effect, invocationId));
const effectResult = effect.config.withInjectionContext && this.config.injector
? runInInjectionContext(this.config.injector, () => effect.func(this, ...args))
: effect.func(this, ...args);
return !this.effectPostprocessor
? effectResult
: withSideEffect(effectResult, () => {
this.effectPostprocessor?.forEach(action => action(this, effect, effectResult, invocationId));
});
}
/**
* Runs a store query potentially targeting many differnt stores with the provided arguments and returns the result.
* @typeparam TResult The type of the query's result.
* @typeparam TStores The types of the stores used in the query.
* @typeparam TArgs The type of the query's arguments.
* @param storeQuery The store query to run.
* @param args The arguments to pass to the query.
* @returns The result of the query as computed signal.
*/
runQuery(storeQuery, ...args) {
if (!this.config.injector) {
assertInInjectionContext(this.runQuery);
}
return runInInjectionContext(this.config.injector ?? inject(Injector), () => {
const queryArgs = [
...storeQuery.stores.map(x => x === this.constructor ? this : inject(x)),
...args,
];
return computed(() => storeQuery.query(...queryArgs));
});
}
}
/**
* Implementation of the createEffect function.
*/
function createEffect(name, func, arg) {
return {
name,
func,
config: {
withInjectionContext: !arg || arg === true || (arg.withInjectionContext ?? true),
setLoadingStatus: arg?.setLoadingStatus ?? false,
setInitializedStatus: arg?.setInitializedStatus ?? false,
},
};
}
/**
* Creates a store event blueprint with the provided name.
* @param name The name of the event.
* @returns A store event blueprint object.
*/
function createEvent(name) {
return { name };
}
/**
* Represents a store that holds an immutable state, allowing mutation through controlled operations.
*
* @typeparam TState The type of the immutable state held by the store.
*/
class ImmutableStore extends Store {
constructor(config) {
super(config);
this.cloneAndMutateFunc =
config.mutationProducerFn ?? naiveCloneAndMutateFunc;
}
mutate(mutator, commandName) {
this.update(state => this.cloneAndMutateFunc(state, mutator), commandName);
}
}
/**
* Memoizes a function by caching its result and returning the cached result on subsequent calls.
* @param fn - The function to be memoized.
* @returns A memoized version of the input function.
*/
function memoize(fn) {
let cachedResult;
return () => {
if (cachedResult === undefined) {
cachedResult = fn();
}
return cachedResult;
};
}
/**
* Creates a memoized function for feature detection.
*
* @param detectionFn - Function that performs the feature detection.
* @returns Memoized function for feature detection.
*/
function makeFeatureDetector(detectionFn) {
return memoize(() => {
try {
return detectionFn();
}
catch {
return false;
}
});
}
/**
* Feature detection for IndexedDB availability.
*/
const isIndexedDbAvailable = /*@__PURE__*/ makeFeatureDetector(() => !!indexedDB);
/**
* Feature detection for Local Storage availability.
*/
const isLocalStorageAvailable = /*@__PURE__*/ makeFeatureDetector(() => !!localStorage);
/**
* Feature detection for Session Storage availability.
*/
const isSessionStorageAvailable = /*@__PURE__*/ makeFeatureDetector(() => !!sessionStorage);
/**
* Feature detection for Redux DevTools availability.
*/
const isDevtoolsAvailable = /*@__PURE__*/ makeFeatureDetector(() => window && '__REDUX_DEVTOOLS_EXTENSION__' in window);
/**
* Feature detection for setTimeout availability.
*/
const isSetTimeoutAvailable = /*@__PURE__*/ makeFeatureDetector(() => !!setTimeout);
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Type guard to check if an item is a undo item.
* @param item The item to be checked.
*/
function isHistoryUndoItem(item) {
return !!item && 'undoneCommandIndex' in item;
}
/**
* Type guard to check if an item is a redo item.
* @param item The item to be checked.
*/
function isHistoryRedoItem(item) {
return !!item && 'redoneCommandIndex' in item;
}
/**
* Creates a tracker which immediately tracks the history of the specifiedd stores.
* @param maxLength Maximum number of commands to retain in the history.
* @param store Initial store to be tracked in the history.
* @param stores Additional stores to be tracked in the history.
* @returns An instance of `HistoryTracker`.
*
* @remark At least one store has to be specifieid
*/
function trackHistory(maxLength, store, ...stores) {
stores ??= [];
stores.push(store);
return new HistoryTrackerBase(new Set(stores.map(x => new WeakRef(x))), maxLength);
}
const PRUNE_FRACTION = 0.25;
const UNDO_COMMAND = '_UNDO_';
const REDO_COMMAND = '_REDO_';
const UNSPECIFIED_COMMAND = 'Unspecified';
class HistoryTrackerBase {
constructor(stores, maxLength) {
this.addToHistoryRef = this.addToHistory.bind(this);
this.stores = stores;
this.maxLength = Math.floor(maxLength * (1 + PRUNE_FRACTION));
this.pool = new WeakMap();
this._history = [];
this.activeTransactions = 0;
this.lastCommand = signal(undefined, ...(ngDevMode ? [{ debugName: "lastCommand" }] : []));
this.foreachStore(store => {
if (store instanceof ImmutableStore) {
store['commandPreprocessor'] ??= [];
store['commandPreprocessor'].push(this.addToHistoryRef);
this.pool.set(store, new WeakRef(store));
}
else {
throw new Error(`${store.name} is not immutable: HistoryTracker does only support ImmutableStores`);
}
});
}
foreachStore(callbackFn) {
this.stores.forEach(storeRef => {
const store = storeRef.deref();
if (store) {
callbackFn(store);
}
else {
this.stores.delete(storeRef);
}
});
}
pushToHistory(historyItem) {
this._history.push(historyItem);
this.lastCommand.set(historyItem);
if (this._history.length > this.maxLength) {
if (isSetTimeoutAvailable()) {
setTimeout(this.prune.bind(this), 0);
}
else {
this.prune();
}
}
}
popFromHistory() {
const poped = this._history.pop();
this.lastCommand.set(this._history[this._history.length - 1]);
return poped;
}
addToHistory(store, command) {
if (this.activeTransactions === 0 &&
command !== UNDO_COMMAND &&
command !== REDO_COMMAND) {
this.pushToHistory({
command: command ?? UNSPECIFIED_COMMAND,
before: store.state(),
store: this.pool.get(store) ?? new WeakRef(store),
});
}
}
collectCurrentStates() {
const values = new WeakMap();
this.foreachStore(store => values.set(store, store.state()));
return values;
}
getCommandToUndo() {
let toBeUndoneCommandIndex = this._history.length - 1;
let toBeUndoneCommand = this.lastCommand();
while (toBeUndoneCommandIndex >= 0 &&
isHistoryUndoItem(toBeUndoneCommand)) {
toBeUndoneCommandIndex = toBeUndoneCommand.undoneCommandIndex - 1;
toBeUndoneCommand =
toBeUndoneCommandIndex >= 0
? this._history[toBeUndoneCommandIndex]
: undefined;
}
return toBeUndoneCommandIndex >= 0
? [toBeUndoneCommandIndex, toBeUndoneCommand]
: [-1, undefined];
}
getCommandToRedo() {
let toBeRedoneCommandIndex = this._history.length - 1;
let toBeRedoneCommand = this.lastCommand();
while (toBeRedoneCommandIndex >= 0 &&
isHistoryRedoItem(toBeRedoneCommand)) {
toBeRedoneCommandIndex = toBeRedoneCommand.redoneCommandIndex - 1;
toBeRedoneCommand =
toBeRedoneCommandIndex >= 0
? this._history[toBeRedoneCommandIndex]
: undefined;
}
return toBeRedoneCommandIndex >= 0 && isHistoryUndoItem(toBeRedoneCommand)
? [toBeRedoneCommandIndex, toBeRedoneCommand]
: [-1, undefined];
}
prune() {
const deleteCount = Math.floor(this._history.length * PRUNE_FRACTION);
if (deleteCount > 0) {
this._history.splice(0, deleteCount);
this._history.forEach(command => {
if (isHistoryRedoItem(command)) {
command.redoneCommandIndex -= deleteCount;
}
else if (isHistoryUndoItem(command)) {
command.undoneCommandIndex -= deleteCount;
}
});
}
}
get canUndo() {
return computed(() => !!this.lastCommand() && this.getCommandToUndo()[0] >= 0);
}
get canRedo() {
return computed(() => !!this.lastCommand() &&
this.activeTransactions === 0 &&
this.getCommandToRedo()[0] >= 0);
}
getHistory() {
const scopedStores = Array.from(this.stores);
return this._history.map(x => {
const stores = 'store' in x ? [x.store] : scopedStores;
const before = stores.map(storeRef => {
const store = storeRef.deref();
return [
store,
x.before instanceof WeakMap
? store
? x.before.get(store)
: undefined
: x.before,
];
});
return {
command: x.command,
before,
};
});
}
destroy() {
this.foreachStore(store => {
if (store instanceof ImmutableStore) {
const trackerRefIndex = store['commandPreprocessor']?.indexOf(this.addToHistoryRef);
if (trackerRefIndex !== undefined && trackerRefIndex > -1) {
store['commandPreprocessor'].splice(trackerRefIndex, 1);
}
}
});
}
beginTransaction(tag) {
if (this.activeTransactions === 0) {
this.pushToHistory({
command: tag ?? UNSPECIFIED_COMMAND,
before: this.collectCurrentStates(),
});
}
this.activeTransactions++;
}
endTransaction() {
if (this.activeTransactions > 0) {
this.activeTransactions--;
}
}
undo() {
if (this.activeTransactions > 0) {
const toBeUndone = this.popFromHistory();
if (toBeUndone && toBeUndone.before instanceof WeakMap) {
this.foreachStore(store => store.set(toBeUndone.before.get(store), UNDO_COMMAND));
}
this.activeTransactions = 0;
return true;
}
const [toBeUndoneCommandIndex, toBeUndoneCommand] = this.getCommandToUndo();
if (toBeUndoneCommand) {
const newState = toBeUndoneCommand.before;
if (newState instanceof WeakMap) {
this.pushToHistory({
command: UNDO_COMMAND,
before: this.collectCurrentStates(),
undoneCommandIndex: toBeUndoneCommandIndex,
});
this.foreachStore(store => store.set(newState.get(store), UNDO_COMMAND));
}
else if ('store' in toBeUndoneCommand) {
const store = toBeUndoneCommand.store.deref();
if (store) {
this.pushToHistory({
command: UNDO_COMMAND,
store: toBeUndoneCommand.store,
before: store.state(),
undoneCommandIndex: toBeUndoneCommandIndex,
});
store.set(newState, UNDO_COMMAND);
}
}
return true;
}
return false;
}
redo() {
if (this.activeTransactions > 0) {
return false;
}
const [toBeRedoneCommandIndex, toBeRedoneCommand] = this.getCommandToRedo();
if (toBeRedoneCommand) {
const newState = toBeRedoneCommand.before;
if (newState instanceof WeakMap) {
this.pushToHistory({
command: REDO_COMMAND,
before: this.collectCurrentStates(),
redoneCommandIndex: toBeRedoneCommandIndex,
});
this.foreachStore(store => store.set(newState.get(store), REDO_COMMAND));
}
else if ('store' in toBeRedoneCommand) {
const store = toBeRedoneCommand.store.deref();
if (store) {
this.pushToHistory({
command: REDO_COMMAND,
store: toBeRedoneCommand.store,
before: store.state(),
redoneCommandIndex: toBeRedoneCommandIndex,
});
store.set(newState, REDO_COMMAND);
}
}
return true;
}
return false;
}
}
/**
* Deeply freezes an object and its properties, making it immutable at runtime.
* @template T - The type of the object.
* @param {T} obj - The object to be deeply frozen.
* @returns {T} The deeply frozen object.
*/
function deepFreeze(obj) {
if (obj) {
Object.freeze(obj);
const oIsFunction = typeof obj === 'function';
const hasOwnProp = Object.prototype.hasOwnProperty;
Object.getOwnPropertyNames(obj).forEach(function (prop) {
if (hasOwnProp.call(obj, prop) &&
(oIsFunction
? prop !== 'caller' && prop !== 'callee' && prop !== 'arguments'
: true)) {
const propValue = obj[prop];
if (propValue !== null &&
(typeof propValue === 'object' || typeof propValue === 'function') &&
!Object.isFrozen(propValue)) {
deepFreeze(propValue);
}
}
});
}
return obj;
}
/**
* Enables Storeplugin that deep freezes the state after each command
* This middleware introduces some overhead
*
* @returns DeepFreeze Storeplugin.
*/
function useDeepFreeze() {
return {
postprocessCommand(store) {
deepFreeze(store.state());
},
};
}
/**
* Registry for store with attached redux devtools monitoring
* It maps the store name to a Weak reference of the store
*/
const registry = new Map();
/**
* Retrieves a store from the registry by name.
* @param name Name of the store.
* @returns The store, if found.
*/
function getStore(name) {
return registry.get(name)?.deref();
}
/**
* The DevTools extension instance.
*/
let devtools;
/**
* Initializes the Redux DevTools extension.
* @param options DevTools options.
*/
function initDevtools(options = {}) {
devtools = window.__REDUX_DEVTOOLS_EXTENSION__.connect(options);
devtools.subscribe(handleDevtoolsMessage);
}
/**
* Scavenges and retrieves a snapshot of registered stores.
* @returns Snapshot of registered stores.
* @modifies registry - Deletes references to garbage collected stores
*/
function scavengeAndGetStoresSnapshot() {
const stores = {};
registry.forEach((storeRef, name) => {
const store = storeRef.deref();
if (store) {
stores[store.name] = store.state();
}
else {
registry.delete(name);
sendToDevtools({ type: `[${name}] - @Removal` });
}
});
return stores;
}
/**
* Sends an action to the Redux DevTools extension.
* @param action Action to send.
*/
function sendToDevtools(action) {
devtools?.send(action, scavengeAndGetStoresSnapshot());
}
/**
* Handles messages received from the Redux DevTools extension.
* @param message DevTools message.
*/
function handleDevtoolsMessage(message) {
if (devtools) {
if (message.type === 'DISPATCH') {
const payloadType = message.payload.type;
if (payloadType === 'COMMIT') {
devtools.init(scavengeAndGetStoresSnapshot());
return;
}
if (payloadType === 'JUMP_TO_STATE' || payloadType === 'JUMP_TO_ACTION') {
const state = JSON.parse(message.state);
for (const [name, value] of Object.entries(state)) {
const store = getStore(name);
if (store) {
store['_state'].set(value);
}
}
}
}
}
}
/**
* Registers a store for Devtools monitoring.
*
* @param store - Store to be registered.
*/
function registerForDevtools(store) {
if (!devtools) {
initDevtools({});
}
registry.set(store.name, new WeakRef(store));
sendToDevtools({ type: `[${store.name}] - @Init` });
}
/**
* Removes a store from Devtools monitoring.
*
* @param store - Store to be removed.
*/
function removeFromDevtools(store) {
registry.delete(store.name);
sendToDevtools({ type: `[${store.name}] - @Removal` });
}
/**
* Enables Storeplugin that links the store activity with the Redux DevTools extension.
* @returns Devtools Storeplugin
*/
function useDevtools() {
if (!isDevtoolsAvailable()) {
return {};
}
return {
init(store) {
registerForDevtools(store);
},
postprocessCommand(store, command) {
sendToDevtools({
type: `[${store.name}] - ${command ?? 'Command'}`,
});
},
};
}
/**
* Enables StorePlugin that logs command and effect execution
* @returns A StorePlugin instance for logging.
*/
function useLogger(options = {}) {
const plugin = {
name: 'StoreLogger',
log: options.logFunction ?? console.log,
};
plugin.init = store => plugin.log(`[${store.name}->Init]`, store.state());
plugin.postprocessCommand = (store, command) => plugin.log(`[${store.name}->Command] ${command ?? 'Unspecified'}`, store.state());
plugin.preprocessEffect = (store, effect) => plugin.log(`[${store.name}->Effect STARTED] ${effect.name ?? 'Unspecified'}`, store.state());
plugin.postprocessEffect = (store, effect, _, invocationId) => plugin.log(`[${store.name}->Effect FINNISHED in ${Math.floor(performance.now() - invocationId)} ms] ${effect.name ?? 'Unspecified'}`, store.state());
return plugin;
}
/**
* PerformanceCounter class is used to measure and analyze the performance of a process.
*/
class PerformanceCounter {
constructor() {
this.count = 0;
this.totalDurationMs = 0;
this.maxDurationMs = 0;
this.sumSquares = 0;
this.currentTimer = null;
}
/**
* Indicates whether the timer is currently running.
*/
get isRunning() {
return !!this.currentTimer;
}
/**
* Adds the specified duration to the counter, updating relevant statistics.
* @param duration - The duration to be added in milliseconds.
*/
addDuration(duration) {
this.count++;
this.totalDurationMs += duration;
this.maxDurationMs = Math.max(this.maxDurationMs, duration);
this.sumSquares += duration * duration;
}
/**
* Starts the timer, recording the current timestamp.
*/
startTimer() {
this.currentTimer = performance.now();
}
/**
* Stops the timer, calculates the duration, and adds it to the counter.
*/
stopTimer() {
const duration = performance.now() - this.currentTimer;
this.addDuration(duration);
this.currentTimer = null;
}
/**
* Toggles the timer between start and stop states.
*/
toggleTimer() {
if (this.isRunning) {
this.stopTimer();
}
else {
this.startTimer();
}
}
/**
* Calculates and returns the average duration based on the recorded intervals.
* @returns The average duration in milliseconds.
*/
getAverageDuration() {
return this.count === 0 ? 0 : this.totalDurationMs / this.count;
}
/**
* Calculates and returns the standard deviation of durations.
* @returns The standard deviation
*/
getStandardDeviation() {
if (this.count === 0) {
return 0;
}
const count = this.count;
const meanSquared = (this.totalDurationMs / count) ** 2;
const variance = (this.sumSquares - meanSquared * count) / count;
return Math.sqrt(variance);
}
/**
* Generates and returns a performance report with various statistics.
* @returns An object containing performance statistics.
*/
getReport() {
return {
count: this.count,
maxDurationMs: this.maxDurationMs,
averageDurationMs: this.getAverageDuration(),
standardDeviation: this.getStandardDeviation(),
};
}
}
// Counters
const globalCommandCounter =
/*@__PURE__*/ new PerformanceCounter();
const globalEffectCounter =
/*@__PURE__*/ new PerformanceCounter();
const commandCounters = [];
const effectCounters = [];
/**
* Toggles the timer for a specific command, updating the associated counter.
* @param store - The name of the store.
* @param command - The name of the command.
*/
function toggleCommandTimer(store, command) {
const commandName = command ?? 'Unspecified';
const counterRegistration = commandCounters.find(c => c.store === store && c.name === commandName);
const counter = counterRegistration?.counter ?? new PerformanceCounter();
if (!counterRegistration) {
commandCounters.push({
name: commandName,
store,
counter,
});
}
counter.toggleTimer();
}
/**
* Adds the duration of an effect to the associated counter.
* @param store - The name of the store.
* @param effect - The name of the effect.
* @param duration - The duration of the effect execution.
*/
function addEffectDuration(store, effect, duration) {
const effectName = effect ?? 'Unspecified';
const counterRegistration = effectCounters.find(e => e.store === store && e.name === effectName);
const counter = counterRegistration?.counter ?? new PerformanceCounter();
if (!counterRegistration) {
effectCounters.push({
name: effectName,
store,
counter,
});
}
counter.addDuration(duration);
}
/**
* Generates and returns a performance report with various metrics.
* @returns An object containing performance statistics.
*/
function getReport() {
const globalCommandReport = globalCommandCounter.getReport();
const globalEffectReport = globalEffectCounter.getReport();
return {
totalCommandCount: globalCommandReport.count,
averageCommandDurationMs: globalCommandReport.averageDurationMs,
commandDurationStandartDeviation: globalCommandReport.standardDeviation,
totalEffectCount: globalEffectReport.count,
averageEffectDurationMs: globalEffectReport.averageDurationMs,
effectDurationStandartDeviation: globalEffectReport.standardDeviation,
commands: commandCounters
.map(c => ({
name: c.name,
store: c.store,
...c.counter.getReport(),
}))
.sort((a, b) => b.averageDurationMs - a.averageDurationMs),
effects: effectCounters
.map(e => ({
name: e.name,
store: e.store,
...e.counter.getReport(),
}))
.sort((a, b) => b.averageDurationMs - a.averageDurationMs),
};
}
/**
* Store-Like object for registering the performance counters in redux devtools
*/
const counterStore = {
name: '@signalstory/performance-counter',
state() {
return getReport();
},
};
/**
* Returns a StorePlugin that includes initialization and hooks for tracking command and effect performance.
* @returns The StorePlugin for performance tracking.
*/
function usePerformanceCounter() {
return {
precedence: 11, // should come early in initialization
init() {
if (isDevtoolsAvailable() && !registry.has(counterStore.name)) {
registry.set(counterStore.name, new WeakRef(counterStore));
}
},
preprocessCommand(store, command) {
globalCommandCounter.toggleTimer();
toggleCommandTimer(store.name, command);
},
postprocessCommand(store, command) {
globalCommandCounter.toggleTimer();
toggleCommandTimer(store.name, command);
},
postprocessEffect(store, effect, _, invocationId) {
const duration = Math.floor(performance.now() - invocationId);
globalEffectCounter.addDuration(duration);
addEffectDuration(store.name, effect.name, duration);
},
};
}
/**
* Class for configuring indexedb object stores and their corresponding miggrations
*/
class IndexedDbStoreMigrator {
constructor() {
this.migrations = [];
}
/**
* Creates an object store if it does not exist
* @param objectStoreName - The name of the object store
* @returns The IndexedDbStoreRegistrator instance for chaining
*/
createStore(objectStoreName) {
this.migrations.push([objectStoreName, undefined]);
return this;
}
/**
* Creates an object store if it does not exist.
* If it does exists, the current object store value is cleared
* @param objectStoreName - The name of the object store
* @returns The IndexedDbStoreRegistrator instance for chaining
*/
createStoreOrClearState(objectStoreName) {
this.migrations.push([objectStoreName, 'CLEAR']);
return this;
}
/**
* Creates an object store if it does not exist.
* If it does exists, the current object store value can be transformed using the passed transformation function
* @param objectStoreName - The name of the object store
* @param transformation - Custom transformation function for update
* @returns The IndexedDbStoreRegistrator instance for chaining
*/
createStoreOrTransform(objectStoreName, transformation) {
this.migrations.push([objectStoreName, transformation]);
return this;
}
/**
* Creates an object store if it does not exist.
* If it does exists, the current object store value can be transformed using the passed transformation function
* @param objectStoreName - The name of the object store
* @param transformation - Custom transformation function for update
* @returns The IndexedDbStoreRegistrator instance for chaining
*/
createStoreOrMigrateRecords(objectStoreName, migration) {
const recordMigration = migration(new IndexedDbStoreMigrator());
this.migrations.push([objectStoreName, recordMigration]);
return this;
}
/**
* Deletes the object store if it does exist.
* @param objectStoreName - The name of the object store
* @returns The IndexedDbStoreRegistrator instance for chaining
*/
dropStore(objectStoreName) {
this.migrations.push([objectStoreName, 'DROP']);
return this;
}
/**
* Get all registrations
*/
getMigrations() {
return this.migrations;
}
}
/**
* Registered indexedDB migrations per databasename
*/
const idbMigrations = new Map();
/**
* Retrieves the registered migration for a specific IndexedDB database.
* @param dbName - The name of the IndexedDB database.
* @returns The registered migration or undefined if not found.
*/
function getRegisteredMigration(dbName) {
return idbMigrations.get(dbName);
}
/**
* Redeems the migration for a specific IndexedDB database.
* @param dbName - The name of the IndexedDB database.
* @param dbVersion - Optional parameter specifying the database version.
* @returns A function to handle the IDBVersionChangeEvent or undefined if the migration is not found.
* @throws Error if attempting to open a connection with a version different from the registered migration.
*/
function redeemMigration(dbName, dbVersion) {
const migration = idbMigrations.get(dbName);
if (!migration) {
return undefined;
}
if (dbVersion && dbVersion !== migration.dbVersion) {
throw new Error(`getMigrationForDb: Attempted to open a connection to IndexedDb ${dbName} with the version ${dbVersion}, but a migration for the version ${migration.dbVersion} has been specified. Please use only one version for a specific db.`);
}
idbMigrations.delete(dbName);
return (event) => {
const target = event.target;
const db = target.result;
const transaction = target.transaction;
const oldVersion = event.oldVersion;
migration.migrations.forEach(([store, op]) => {
if (!db.objectStoreNames.contains(store)) {
if (op !== 'DROP') {
db.createObjectStore(store);
}
}
else if (op) {
if (op === 'DROP') {
db.deleteObjectStore(store);
}
else {
const objectStore = transaction.objectStore(store);
if (op === 'CLEAR') {
objectStore.clear();
}
else {
if (op instanceof IndexedDbStoreMigrator) {
// Migrate records of objectStore (one-objectstore-for-mulitple-stores approach)
op.getMigrations().forEach(([recordName, recordOp]) => {
objectStore.get(recordName).onsuccess = (event) => {
if (recordOp === 'DROP') {
objectStore.delete(recordName);
}
else if (recordOp === 'CLEAR') {
objectStore.put(undefined, recordName);
}
else if (typeof recordOp === 'function') {
const existingData = event.target.result;
if (existingData) {
const newData = recordOp(oldVersion, existingData);
objectStore.put(newData, recordName);
}
}
};
});
}
else if (typeof op === 'function') {
// Migrate objectStore
objectStore.openCursor().onsuccess = (event) => {
const cursor = event.target.result;
if (cursor) {
const existingData = cursor.value;
const newData = op(oldVersion, existingData);
objectStore.put(newData, cursor.primaryKey);
}
};
}
}
}
}
});
};
}
/**
* Configures the IndexedDB with specified store registrations and database migration.
* @param dbName - The name of the IndexedDB database.
* @param dbVersion - The version of the IndexedDB database.
* @param migration - A function defining store registrations and migration operations.
* @remarks Migrations are registered lazily and applied upon the first use of the database.
* @throws Throws an error if no stores are registered for migration.
* @throws Throws an error if a migration for the specified database already exists.
*/
function migrateIndexedDb(dbName, dbVersion, migration) {
const migrations = migration(new IndexedDbStoreMigrator()).getMigrations();
if (!migrations || migrations.length === 0) {
throw new Error('migrateIndexedDb: Please register at least one Store');
}
if (idbMigrations.has(dbName)) {
throw new Error(`migrateIndexedDb: A migration for ${dbName} has already been specified`);
}
idbMigrations.set(dbName, {
dbVersion,
migrations,
});
}
/**
* Type guard for IDBDatabase based on IndexedDbPoolEntryState.
* @param entry The IndexedDB pool entry to be checked.
* @returns True if the entry is an IDBDatabase, false otherwise.
*/
function isIDBDatabase(entry) {
return typeof entry === 'object' && 'name' in IDBDatabase;
}
/**
* Represents a mapping of database names to their corresponding cached IndexedDB pool entries.
*/
const dbPool = new Map();
/**
* Represents an entry in the IndexedDB pool.
*/
class IndexedDbPoolEntry {
constructor(dbName, dbVersion) {
this.dbName = dbName;
this.dbVersion = dbVersion;
this._db = new BehaviorSubject(undefined);
}
get db() {
return this._db.asObservable();
}
updateEntryState(dbState) {
this._db.next(dbState);
}
}
/**
* Retrieves an existing or opens a new connection to an IndexedDB.
* @param dbName - The name of the database.
* @param dbVersion - The version of the database.
* @param migration - A callback function to perform database migration during upgrade.
* @returns An observable stream representing the state of the IndexedDB entry.
* @throws Throws an error if attempting to open a connection to a database with conflicting versions.
*/
function getOrOpenDb(dbName, dbVersion, migration) {
const cachedDbEntry = dbPool.get(dbName);
if (cachedDbEntry) {
if (dbVersion && cachedDbEntry.dbVersion !== dbVersion) {
throw new Error(`getOrOpenDb: Attempted to open a connection to IndexedDb ${dbName} with the version ${dbVersion}, but another connection to the same db with the version ${cachedDbEntry.dbVersion} is already open. Please use only one version for a specific db.`);
}
return cachedDbEntry.db;
}
else {
dbVersion ??= getRegisteredMigration(dbName)?.dbVersion;
if (!dbVersion) {
throw new Error(`getOrOpenDb: No db version specified. If you want to let the db pool infer the version, you have to setup db migration first using a specific version`);
}
const dbPoolEntry = new IndexedDbPoolEntry(dbName, dbVersion);
dbPool.set(dbName, dbPoolEntry);
const request = indexedDB.open(dbName, dbVersion);
request.onupgradeneeded = event => {
const db = event.target?.result;
if (db) {
const registeredMigration = redeemMigration(db.name, db.version);
registeredMigration?.(event);
migration?.(event);
}
};
request.onsuccess = event => {
dbPoolEntry.updateEntryState(event.target?.result);
};
request.onblocked = () => {
dbPoolEntry.updateEntryState('Blocked');
};
request.onerror = () => {
dbPoolEntry.updateEntryState('InitError');
};
return dbPoolEntry.db;
}
}
/**
* configures connection to an IndexedDb
* @param options - The configuration options for IndexedDB.
* @returns Store persistence plugin options.
*/
function configureIndexedDb(options) {
return {
persistenceStorage: new IndexedDbAdapter(options.dbName, options.dbVersion, options.objectStoreName, options.key, options.handlers),
projection: options.projection,
};
}
/**
* Represents an adapter for interacting with IndexedDB, implementing AsyncStorage.
*/
class IndexedDbAdapter {
constructor(dbName, dbVersion, _objectStoreName, _key, handlers) {
this.dbName = dbName;
this.dbVersion = dbVersion;
this._objectStoreName = _objectStoreName;
this._key = _key;
this.handlers = handlers;
}
get objectStoreName() {
return this._objectStoreName;
}
get key() {
return this._key;
}
initAsync(storeName, callback) {
this._objectStoreName ??= storeName;
this._key ??= storeName;
const db = getOrOpenDb(this.dbName, this.dbVersion, event => {
const db = event.target?.result;
if (db) {
if (!db.objectStoreNames.contains(this._objectStoreName)) {
db.createObjectStore(this._objectStoreName);
}
}
});
db.pipe(filter(entry => !!entry), first()).subscribe(entry => {
if (isIDBDatabase(entry)) {
this.db = entry;
this.dbVersion = entry.version;
this.handlers?.onSuccess?.();
callback?.();
}
else if (entry === 'Blocked') {
this.handlers?.onBlocked?.();
}
else if (entry === 'InitError') {
this.handlers?.onInitializationError?.();
}
});
}
getItemAsync(_, callback) {
const request = this.db
?.transaction([this.objectStoreName])
?.objectStore(this.objectStoreName)
?.get(this.key);
if (request) {
request.onsuccess = (event) => {
callback(event.target.result);
};
}
}
setItemAsync(_, value, callback) {
const request = this.db
?.transaction([this.objectStoreName], 'readwrite')
?.objectStore(this.objectStoreName)
?.put(value, this.key);
if (request && callback) {
request.onsuccess = callback;
}
}
removeItemAsync(_, callback) {
const request = this.db
?.transaction([this.objectStoreName], 'readwrite')
?.objectStore(this.objectStoreName)
?.clear();
if (request && callback) {
request.onsuccess = callback;
}
}
}
/* eslint-disable @typescript-eslint/no-explicit-any */
/**
* Type guard to check if an object implements the `PersistenceStorageAsynchronous` interface.
* @param obj - The object to check.
* @returns True if the object implements the `PersistenceStorageAsynchronous` interface, false otherwise.
*/
function isAsyncStorage(obj) {
return (typeof obj === 'object' &&
typeof obj.initAsync === 'function' &&
typeof obj.getItemAsync === 'function' &&
typeof obj.setItemAsync === 'function' &&
typeof obj.removeItemAsync === 'function');
}
/**
* Type guard to check if an object implements the `PersistenceStorageSynchronous` interface.
* @param obj - The object to check.
* @returns True if the object implements the `PersistenceStorageSynchronous` interface, false otherwise.
*/
function isSyncStorage(obj) {
return (typeof obj === 'object' &&
typeof obj.getItem === 'function' &&
typeof obj.setItem === 'function' &&
typeof obj.removeItem === 'function');
}
/**
* Loads a value from storage.
*
* @template TState - The type of state to load.
* @param store - The store instance.
* @returns The loaded value if available and successfully parsed, otherwise undefined.
*/
function loadFromStorage(persistenceStorage, persistenceKey) {
const value = persistenceStorage.getItem(persistenceKey);
try {
return value ? JSON.parse(value) : undefined;
}
catch {
return undefined;
}
}
/**
* Saves a value to local storage.
*
* @template TState - The type of state to save.
* @param store - The store instance.
* @param value - The store value to store.
*/
function saveToStorage(persistenceStorage, persistenceKey, value) {
persistenceStorage.setItem(persistenceKey, JSON.stringify(value));
}
/**
* typeguard for StorePersistencePlugin.
* @param obj - The object to check.
* @returns True if the object is a StorePersistencePlugin, otherwise false.
*/
function isStorePersistencePlugin(obj) {
return (obj &&
typeof obj === 'object' &&
'name' in obj &&
obj['name'] === 'StorePersistence');
}
/**
* Clears the value associated with the provided key from local storage.
*
* @template TState - The type of state to clear from local storage.
* @param store - The store instance.
*
*/
function clearStoreStorage(store) {
const plugin = store.config.plugins.find(isStorePersistencePlugin);
if (plugin) {
if (isSyncStorage(plugin.storage)) {
plugin.storage.removeItem(plugin.persistenceKey);
}
else if (isAsyncStorage(plugin.storage)) {
plugin.storage.removeItemAsync(plugin.persistenceKey);
}
}
else {
throw new Error(`Store persistence plugin is not enabled for store ${store.config.name}`);
}
}
function configureSyncStorage(plugin, projection) {
plugin.init = store => {
if (!plugin.persistenceKey) {
plugin.persistenceKey = `_persisted_state_of_${store.config.name}`;
}
const persistedState = loadFromStorage(plugin.storage, plugin.persistenceKey);
if (persistedState) {
store.set(projection
? projection.onLoad(persistedState)
: persistedState, 'Load state from storage');
}
};
plugin.postprocessCommand = projection
? store => saveToStorage(plugin.storage, plugin.persistenceKey, projection.onWrite(store.state()))
: store => saveToStorage(plugin.storage, plugin.persistenceKey, store.state());
return plugin;
}
function configureAsyncStorage(plugin, projection) {
plugin.init = store => {
if (!plugin.persistenceKey) {
plugin.persistenceKey = `_persisted_state_of_${store.config.name}`;
}
plugin.storage.initAsync(store.name, () => {
plugin.storage.getItemAsync(plugin.persistenceKey, persistedState => {
if (persistedState) {
store.set(projection
? projection.onLoad(persistedState)
: persistedState, 'Load state from storage');
}
});
});
};
plugin.postprocessCommand = projection
? store => plugin.storage.setItemAsync(plugin.persistenceKey, projection.onWrite(store.state()))
: store => plugin.storage.setItemAsync(plugin.persistenceKey, store.state());
return plugin;
}
/**
* Enables Storeplugin that persists the store state to a storage (e.g. local storage).
* State changes are automatically synced with the storage.
* @param options - Options for configuring the StorePersistencePlugin.
* @returns A StorePersistencePlugin instance.
*/
function useStorePersistence(options = {}) {
const storageProvider = options.persistenceStorage ?? 'LOCAL_STORAGE';
if ((storageProvider === 'LOCAL_STORAGE' && !isLocalStorageAvailable()) ||
(storageProvider === 'SESSION_STORAGE' && !isSessionStorageAvailable()) ||
(storageProvider instanceof IndexedDbAdapter && !isIndexedDbAvailable())) {
return {};
}
const storage = storageProvider === 'LOCAL_STORAGE'
? localStorage
: storageProvider === 'SESSION_STORAGE'
? sessionStorage
: storageProvider;
const plugin = {
name: 'StorePersistence',
storage,
persistenceKey: options.persistenceKey ?? '',
};
return isAsyncStorage(storage)
? configureAsyncStorage(plugin, options.projection)
: configureSyncStorage(plugin, options.projection);
}
/* eslint-disable @typescript-eslint/no-explicit-any */
const storeStatusMap = new WeakMap();
let _runningEffects;
function getRunningEffects() {
if (!_runningEffects) {
_runningEffects = signal([]);
}
return _runningEffects;
}
/**
* Returns a Signal indicating whether the provided store has been modified.
*
* @note A store is initially considered unmodified. Any command (`set`, `update`, `mutate`) applied to the store
* will mark it as modified. Additionally, an effect created with the `setInitializedStatus` flag will reset the store's
* modification status to unmodified.
*
* @param store - The store to check for modification status.
* @returns Signal<boolean> - A signal indicating whether the store has been modified.
*/
function modified(store) {
const status = storeStatusMap.get(store);
if (!status) {
throw new Error(`StatusPlugin has not been activated for store ${store.name}`);
}
return computed(() => status().hasBeenModified);
}
/**
* Returns a Signal indicating whether the provided store has been initialized by an initializing effect.
*
* @note A store is initially considered as deinitialized. An effect created with the `setInitializedStatus` flag will set the store's
* initialization status to `true`.
*
* @param store - The store to check for initialization status.
* @returns Signal<boolean> - A signal indicating whether the store has been initialized.
*/
function initialized(store) {
const status = storeStatusMap.get(store);
if (!status) {
throw new Error(`StatusPlugin has not been activated for store ${store.name}`);
}
return computed(() => status().hasBeenInitialized);
}
/**
* Manually resets the status indicators for the provided store, marking it as deinitialized and unmodified.
* This means that both `unmodified()` and `initialized()` will return false. For manual reset of the loading status,
* use `markAsHavingNoRunningEffects`.
*
* @note This method is intended for exceptional cases.
*
* @param store - The store to manually reset the status.
* @returns void
*/
function resetStoreStatus(store) {
storeStatusMap.get(store)?.set({
hasBeenInitialized: false,
hasBeenModified: false,
});
}
/**
* Returns a Signal indicating whether any of the provided stores is in a loading state.
* If no stores are provided, the returned signal indicates if any store is in a loading state.
*
* @note An effect created with `setLoadingStatus` will mark the associated store as loading while the effect is running.
*
* @param stores - Stores to check for loading status. If no stores are provided, the signal checks all stores.
* @returns Signal<boolean> - A signal indicating whether any store is loading.
*/
function isLoading(...stores) {
if (!stores || stores.length === 0) {
return computed(() => getRunningEffects()().some(effect => effect[1].config.setLoadingStatus));
}
else {
return computed(() => getRunningEffects()().some(runningEffect => {
const affectedStore = runningEffect[0].deref();
return (affectedStore &&
runningEffect[1].config.setLoadingStatus &&
stores.some(store => store === affectedStore));
}));
}
}
/**
* Manually marks the provided store as not having any running effects.
*
* @note This method is intended for exceptional cases, specifically when you observe
* that an effect is not removed from the running state by signalstory automatically.
* If you encounter such a situation, use this method as a temporary workaround and
* be sure to file an issue on GitHub for further investigation and resolution.
*
* @param store - The store to manually mark as not having running effects.
* @returns void
*/
function markAsHavingNoRunningEffects(store) {
getRunningEffects().update(state => state.filter(runningEffect => runningEffect[0].deref() !== store));
}
/**
* Returns a Signal indicating whether any effect is currently running for any of the provided stores.
* If no Store is provided the returned signal indicates if any store has an effect running
* @param stores - Stores to check for running effects.
* @returns Signal<boolean> - A signal indicating whether any effect is running for any store.
*/
function isAnyEffectRunning(...stores) {
if (!stores || stores.length === 0) {
return computed(() => getRunningEffects()().length > 0);
}
else {
return computed(() => getRunningEffects()().some(runningEffect => {
const affectedStore = runningEffect[0].deref();
return affectedStore && stores.some(store => store === affectedStore);
}));
}
}
/**
* Returns a Signal indicating whether the specified effect is currently running for any of the provided stores.
* If no Store is provided the returned signal indicates if any store has the given effect running
* @param effect - The effect to check for.
* @param stores - Stores to check for the specified effect.
* @returns Signal<boolean> - A signal indicating whether the specified effect is running for any store.
*/
function isEffectRunning(effect, ...stores) {
if (!stores || stores.length === 0) {
return computed(() => getRunningEffects()().some(x => x[1] === effect));
}
else {
return computed(() => getRunningEffects()()
.filter(runningEffect => runningEffect[1] === effect)
.some(runningEffect => {
const affectedStore = runningEffect[0].deref();
return affectedStore && stores.some(store => store === affectedStore);
}));
}
}
/**
* Enables StorePlugin that tracks the loading and modification status of a store.
* @returns A StorePlugin instance for loading and modification status tracking.
*/
function useStoreStatus() {
return {
init(store) {
storeStatusMap.set(store, signal({
hasBeenInitialized: false,
hasBeenModified: false,
}));
},
postprocessCommand(store) {
const status = storeStatusMap.get(store);
if (status && !status().hasBeenModified) {
storeStatusMap.get(store)?.update(state => ({
...state,
hasBeenModified: true,
}));
}
},
preprocessEffect(store, effect, invocationId) {
getRunningEffects().update(effects => [
...effects,
[new WeakRef(store), effect, invocationId],
]);
},
postprocessEffect(store, effect, _, invocationId) {
getRunningEffects().update(effects => effects.filter(x => x[2] !== invocationId));
if (effect.config.setInitializedStatus) {
storeStatusMap.get(store)?.set({
hasBeenInitialized: true,
hasBeenModified: false,
});
}
},
};
}
function createQuery(stores, query) {
return {
stores: stores,
query: query,
};
}
/**
* Represents the snapshot restore command.
*/
const RestoreCommand = '_SNAPSHOT_RESTORE_';
/**
* Base implementation of the StateSnapshot interface.
*/
class StateSnapshotBase {
constructor(storesWithState) {
this.storesWithState = storesWithState;
this.timestamp = performance.now();
}
restore() {
forEachStoreInScope(store => {
if (this.storesWithState.has(store)) {
const snapshotValue = this.storesWithState.get(store);
if (snapshotValue !== store.state()) {
store.set(snapshotValue, RestoreCommand);
}
}
});
}
}
/**
* Creates a state snapshot either for specified stores or all stores in scope.
*
* If no stores are provided, the snapshot will include all stores currently in scope.
* Stores can be specified either as instances of Store or as ProviderTokens representing
* Store classes.
*
* @param stores - The stores for which to create a snapshot. Can be either instances
* of Store or ProviderTokens representing Store classes.
* @returns A StateSnapshot instance representing the application state snapshot.
*/
function createSnapshot(...stores) {
const storesWithState = new WeakMap();
stores ??= [];
forEachStoreInScope(store => {
if (stores.length === 0 ||
stores.some(x => x === store || store.constructor === x)) {
const stateSnapshot = store instanceof ImmutableStore
? store.state()
: deepClone(store.state());
storesWithState.set(store, stateSnapshot);
}
});
return new StateSnapshotBase(storesWithState);
}
/*
* Public API Surface of signalstory
*/
/**
* Generated bundle index. Do not edit.
*/
export { ImmutableStore, Store, clearStoreStorage, configureIndexedDb, createEffect, createEvent, createQuery, createSnapshot, getReport, initialized, isAnyEffectRunning, isEffectRunning, isLoading, markAsHavingNoRunningEffects, migrateIndexedDb, modified, publishStoreEvent, resetStoreStatus, trackHistory, useDeepFreeze, useDevtools, useLogger, usePerformanceCounter, useStorePersistence, useStoreStatus };
//# sourceMappingURL=signalstory.mjs.map