@ngrx/component-store
Version:
Reactive store for component state
419 lines (412 loc) • 16.4 kB
JavaScript
import { Observable, Subscription, asapScheduler, ReplaySubject, isObservable, of, queueScheduler, EMPTY, throwError, scheduled, combineLatest, Subject } from 'rxjs';
import { take, takeUntil, observeOn, tap, withLatestFrom, map, catchError, distinctUntilChanged, shareReplay } from 'rxjs/operators';
import * as i0 from '@angular/core';
import { InjectionToken, inject, computed, isDevMode, Injectable, Optional, Inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
/**
* @license MIT License
*
* Copyright (c) 2017-2020 Nicholas Jamieson and contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
function debounceSync() {
return (source) => new Observable((observer) => {
let actionSubscription;
let actionValue;
const rootSubscription = new Subscription();
rootSubscription.add(source.subscribe({
complete: () => {
if (actionSubscription) {
observer.next(actionValue);
}
observer.complete();
},
error: (error) => {
observer.error(error);
},
next: (value) => {
actionValue = value;
if (!actionSubscription) {
actionSubscription = asapScheduler.schedule(() => {
observer.next(actionValue);
actionSubscription = undefined;
});
rootSubscription.add(actionSubscription);
}
},
}));
return rootSubscription;
});
}
/**
* Checks to see if the OnInitStore lifecycle hook
* is defined on the ComponentStore.
*
* @param cs ComponentStore type
* @returns boolean
*/
function isOnStoreInitDefined(cs) {
return typeof cs.ngrxOnStoreInit === 'function';
}
/**
* Checks to see if the OnInitState lifecycle hook
* is defined on the ComponentStore.
*
* @param cs ComponentStore type
* @returns boolean
*/
function isOnStateInitDefined(cs) {
return typeof cs.ngrxOnStateInit === 'function';
}
/**
* @description
*
* Function that returns the ComponentStore
* class registered as a provider,
* and uses a factory provider to instantiate the
* ComponentStore and run the lifecycle hooks
* defined on the ComponentStore.
*
* @param componentStoreClass The ComponentStore with lifecycle hooks
* @returns Provider[]
*
* @usageNotes
*
* ```ts
* @Injectable()
* export class MyStore
* extends ComponentStore<{ init: boolean }>
* implements OnStoreInit, OnStateInit
* {
*
* constructor() {
* super({ init: true });
* }
*
* ngrxOnStoreInit() {
* // runs once after store has been instantiated
* }
*
* ngrxOnStateInit() {
* // runs once after store state has been initialized
* }
* }
*
* @Component({
* providers: [
* provideComponentStore(MyStore)
* ]
* })
* export class MyComponent {
* constructor(private myStore: MyStore) {}
* }
* ```
*/
function provideComponentStore(componentStoreClass) {
const CS_WITH_HOOKS = new InjectionToken('@ngrx/component-store ComponentStore with Hooks');
return [
{ provide: CS_WITH_HOOKS, useClass: componentStoreClass },
{
provide: componentStoreClass,
useFactory: () => {
const componentStore = inject(CS_WITH_HOOKS);
// Set private property that CS has been provided with lifecycle hooks
componentStore['ɵhasProvider'] = true;
if (isOnStoreInitDefined(componentStore)) {
componentStore.ngrxOnStoreInit();
}
if (isOnStateInitDefined(componentStore)) {
componentStore.state$
.pipe(take(1))
.subscribe(() => componentStore.ngrxOnStateInit());
}
return componentStore;
},
},
];
}
const INITIAL_STATE_TOKEN = new InjectionToken('@ngrx/component-store Initial State');
class ComponentStore {
constructor(defaultState) {
// Should be used only in ngOnDestroy.
this.destroySubject$ = new ReplaySubject(1);
// Exposed to any extending Store to be used for the teardown.
this.destroy$ = this.destroySubject$.asObservable();
this.stateSubject$ = new ReplaySubject(1);
this.isInitialized = false;
// Needs to be after destroy$ is declared because it's used in select.
this.state$ = this.select((s) => s);
this.state = toSignal(this.stateSubject$.pipe(takeUntil(this.destroy$)), { requireSync: false, manualCleanup: true });
this.ɵhasProvider = false;
// State can be initialized either through constructor or setState.
if (defaultState) {
this.initState(defaultState);
}
this.checkProviderForHooks();
}
/** Completes all relevant Observable streams. */
ngOnDestroy() {
this.stateSubject$.complete();
this.destroySubject$.next();
}
/**
* Creates an updater.
*
* Throws an error if updater is called with synchronous values (either
* imperative value or Observable that is synchronous) before ComponentStore
* is initialized. If called with async Observable before initialization then
* state will not be updated and subscription would be closed.
*
* @param updaterFn A static updater function that takes 2 parameters (the
* current state and an argument object) and returns a new instance of the
* state.
* @return A function that accepts one argument which is forwarded as the
* second argument to `updaterFn`. Every time this function is called
* subscribers will be notified of the state change.
*/
updater(updaterFn) {
return ((observableOrValue) => {
// We need to explicitly throw an error if a synchronous error occurs.
// This is necessary to make synchronous errors catchable.
let isSyncUpdate = true;
let syncError;
// We can receive either the value or an observable. In case it's a
// simple value, we'll wrap it with `of` operator to turn it into
// Observable.
const observable$ = isObservable(observableOrValue)
? observableOrValue
: of(observableOrValue);
const subscription = observable$
.pipe(
// Push the value into queueScheduler
observeOn(queueScheduler),
// If the state is not initialized yet, we'll throw an error.
tap(() => this.assertStateIsInitialized()), withLatestFrom(this.stateSubject$),
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
map(([value, currentState]) => updaterFn(currentState, value)), tap((newState) => this.stateSubject$.next(newState)), catchError((error) => {
if (isSyncUpdate) {
syncError = error;
return EMPTY;
}
return throwError(error);
}), takeUntil(this.destroy$))
.subscribe();
if (syncError) {
throw syncError;
}
isSyncUpdate = false;
return subscription;
});
}
/**
* Initializes state. If it was already initialized then it resets the
* state.
*/
initState(state) {
scheduled([state], queueScheduler).subscribe((s) => {
this.isInitialized = true;
this.stateSubject$.next(s);
});
}
/**
* Sets the state specific value.
* @param stateOrUpdaterFn object of the same type as the state or an
* updaterFn, returning such object.
*/
setState(stateOrUpdaterFn) {
if (typeof stateOrUpdaterFn !== 'function') {
this.initState(stateOrUpdaterFn);
}
else {
this.updater(stateOrUpdaterFn)();
}
}
/**
* Patches the state with provided partial state.
*
* @param partialStateOrUpdaterFn a partial state or a partial updater
* function that accepts the state and returns the partial state.
* @throws Error if the state is not initialized.
*/
patchState(partialStateOrUpdaterFn) {
const patchedState = typeof partialStateOrUpdaterFn === 'function'
? partialStateOrUpdaterFn(this.get())
: partialStateOrUpdaterFn;
this.updater((state, partialState) => ({
...state,
...partialState,
}))(patchedState);
}
get(projector) {
this.assertStateIsInitialized();
let value;
this.stateSubject$.pipe(take(1)).subscribe((state) => {
value = projector ? projector(state) : state;
});
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return value;
}
select(...args) {
const { observablesOrSelectorsObject, projector, config } = processSelectorArgs(args);
const source$ = hasProjectFnOnly(observablesOrSelectorsObject, projector)
? this.stateSubject$
: combineLatest(observablesOrSelectorsObject);
return source$.pipe(config.debounce ? debounceSync() : noopOperator(), (projector
? map((projectorArgs) =>
// projectorArgs could be an Array in case where the entire state is an Array, so adding this check
observablesOrSelectorsObject.length >
0 && Array.isArray(projectorArgs)
? projector(...projectorArgs)
: projector(projectorArgs))
: noopOperator()), distinctUntilChanged(config.equal), shareReplay({
refCount: true,
bufferSize: 1,
}), takeUntil(this.destroy$));
}
selectSignal(...args) {
const selectSignalArgs = [...args];
const options = typeof selectSignalArgs[args.length - 1] === 'object'
? selectSignalArgs.pop()
: {};
const projector = selectSignalArgs.pop();
const signals = selectSignalArgs;
const computation = signals.length === 0
? () => projector(this.state())
: () => {
const values = signals.map((signal) => signal());
return projector(...values);
};
return computed(computation, options);
}
/**
* Creates an effect.
*
* This effect is subscribed to throughout the lifecycle of the ComponentStore.
* @param generator A function that takes an origin Observable input and
* returns an Observable. The Observable that is returned will be
* subscribed to for the life of the component.
* @return A function that, when called, will trigger the origin Observable.
*/
effect(generator) {
const origin$ = new Subject();
generator(origin$)
// tied to the lifecycle 👇 of ComponentStore
.pipe(takeUntil(this.destroy$))
.subscribe();
return ((observableOrValue) => {
const observable$ = isObservable(observableOrValue)
? observableOrValue
: of(observableOrValue);
return observable$.pipe(takeUntil(this.destroy$)).subscribe((value) => {
// any new 👇 value is pushed into a stream
origin$.next(value);
});
});
}
/**
* Used to check if lifecycle hooks are defined
* but not used with provideComponentStore()
*/
checkProviderForHooks() {
asapScheduler.schedule(() => {
if (isDevMode() &&
(isOnStoreInitDefined(this) || isOnStateInitDefined(this)) &&
!this.ɵhasProvider) {
const warnings = [
isOnStoreInitDefined(this) ? 'OnStoreInit' : '',
isOnStateInitDefined(this) ? 'OnStateInit' : '',
].filter((defined) => defined);
console.warn(`@ngrx/component-store: ${this.constructor.name} has the ${warnings.join(' and ')} ` +
'lifecycle hook(s) implemented without being provided using the ' +
`provideComponentStore(${this.constructor.name}) function. ` +
`To resolve this, provide the component store via provideComponentStore(${this.constructor.name})`);
}
});
}
assertStateIsInitialized() {
if (!this.isInitialized) {
throw new Error(`${this.constructor.name} has not been initialized yet. ` +
`Please make sure it is initialized before updating/getting.`);
}
}
/** @nocollapse */ static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: ComponentStore, deps: [{ token: INITIAL_STATE_TOKEN, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
/** @nocollapse */ static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: ComponentStore }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "20.0.0", ngImport: i0, type: ComponentStore, decorators: [{
type: Injectable
}], ctorParameters: () => [{ type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [INITIAL_STATE_TOKEN]
}] }] });
function processSelectorArgs(args) {
const selectorArgs = Array.from(args);
const defaultEqualityFn = (previous, current) => previous === current;
// Assign default values.
let config = {
debounce: false,
equal: defaultEqualityFn,
};
// Last argument is either config or projector or selectorsObject
if (isSelectConfig(selectorArgs[selectorArgs.length - 1])) {
config = { ...config, ...selectorArgs.pop() };
}
// At this point selectorArgs is either projector, selectors with projector or selectorsObject
if (selectorArgs.length === 1 && typeof selectorArgs[0] !== 'function') {
// this is a selectorsObject
return {
observablesOrSelectorsObject: selectorArgs[0],
projector: undefined,
config,
};
}
const projector = selectorArgs.pop();
// The Observables to combine, if there are any left.
const observables = selectorArgs;
return {
observablesOrSelectorsObject: observables,
projector,
config,
};
}
function isSelectConfig(arg) {
const typedArg = arg;
return (typeof typedArg.debounce !== 'undefined' ||
typeof typedArg.equal !== 'undefined');
}
function hasProjectFnOnly(observablesOrSelectorsObject, projector) {
return (Array.isArray(observablesOrSelectorsObject) &&
observablesOrSelectorsObject.length === 0 &&
projector);
}
function noopOperator() {
return (source$) => source$;
}
/**
* DO NOT EDIT
*
* This file is automatically generated at build
*/
/**
* Generated bundle index. Do not edit.
*/
export { ComponentStore, INITIAL_STATE_TOKEN, provideComponentStore };
//# sourceMappingURL=ngrx-component-store.mjs.map