packages/router-plugin/src/router.state.ts
import { NgZone, Injectable, OnDestroy, Injector } from '@angular/core';
import {
NavigationCancel,
NavigationError,
Router,
RouterStateSnapshot,
RoutesRecognized,
ResolveEnd,
NavigationStart,
NavigationEnd,
Event
} from '@angular/router';
import { Action, Selector, State, StateContext, StateToken, Store } from '@ngxs/store';
import {
NavigationActionTiming,
NgxsRouterPluginOptions,
ɵNGXS_ROUTER_PLUGIN_OPTIONS
} from '@ngxs/router-plugin/internals';
import { ReplaySubject } from 'rxjs';
import { takeUntil } from 'rxjs/operators';
import {
Navigate,
RouterAction,
RouterCancel,
RouterError,
RouterNavigation,
RouterDataResolved,
RouterRequest,
RouterNavigated
} from './router.actions';
import { RouterStateSerializer } from './serializer';
export interface RouterStateModel<T = RouterStateSnapshot> {
state?: T;
navigationId?: number;
trigger: RouterTrigger;
}
export type RouterTrigger =
| 'none'
| 'router'
| 'store'
// The `devtools` trigger means that the state change has been triggered by Redux DevTools (e.g. when the time-traveling is used).
| 'devtools';
// NGXS doesn't permit untyped selectors, such as `select(RouterState)`,
// as the `RouterState` class itself lacks type information. Therefore,
// the following state token must replace `RouterState`.
export const ROUTER_STATE_TOKEN = new StateToken<RouterStateModel>('router');
@State<RouterStateModel>({
name: ROUTER_STATE_TOKEN,
defaults: {
state: undefined,
navigationId: undefined,
trigger: 'none'
}
})
@Injectable()
export class RouterState implements OnDestroy {
/**
* Determines how navigation was performed by the `RouterState` itself
* or outside via `new Navigate(...)`
*/
private _trigger: RouterTrigger = 'none';
/**
* That's the serialized state from the `Router` class
*/
private _routerState: RouterStateSnapshot | null = null;
/**
* That's the value of the `RouterState` state
*/
private _storeState: RouterStateModel | null = null;
private _lastEvent: Event | null = null;
private _options: NgxsRouterPluginOptions | null = null;
private _destroy$ = new ReplaySubject<void>(1);
@Selector()
static state<T = RouterStateSnapshot>(state: RouterStateModel<T>) {
// The `state` is optional if the selector is invoked before the router
// state is registered in NGXS.
return state?.state;
}
@Selector()
static url(state: RouterStateModel): string | undefined {
return state?.state?.url;
}
constructor(
private _store: Store,
private _router: Router,
private _serializer: RouterStateSerializer<RouterStateSnapshot>,
private _ngZone: NgZone,
injector: Injector
) {
// Note: do not use `@Inject` since it fails on lower versions of Angular with Jest
// integration, it cannot resolve the token provider.
this._options = injector.get(ɵNGXS_ROUTER_PLUGIN_OPTIONS, null);
this._setUpStoreListener();
this._setUpRouterEventsListener();
}
ngOnDestroy(): void {
this._destroy$.next();
}
@Action(Navigate)
navigate(_: StateContext<RouterStateModel>, action: Navigate) {
return this._ngZone.run(() =>
this._router.navigate(action.path, {
queryParams: action.queryParams,
...action.extras
})
);
}
@Action([
RouterRequest,
RouterNavigation,
RouterError,
RouterCancel,
RouterDataResolved,
RouterNavigated
])
angularRouterAction(
ctx: StateContext<RouterStateModel>,
action: RouterAction<RouterStateModel, RouterStateSnapshot>
): void {
ctx.setState({
trigger: action.trigger,
state: action.routerState,
navigationId: action.event.id
});
}
private _setUpStoreListener(): void {
const routerState$ = this._store
.select(ROUTER_STATE_TOKEN)
.pipe(takeUntil(this._destroy$));
routerState$.subscribe((state: RouterStateModel | undefined) => {
this._navigateIfNeeded(state);
});
}
private _navigateIfNeeded(routerState: RouterStateModel | undefined): void {
if (routerState && routerState.trigger === 'devtools') {
this._storeState = this._store.selectSnapshot(ROUTER_STATE_TOKEN);
}
const canSkipNavigation =
!this._storeState ||
!this._storeState.state ||
!routerState ||
routerState.trigger === 'router' ||
this._router.url === this._storeState.state.url ||
this._lastEvent instanceof NavigationStart;
if (canSkipNavigation) {
return;
}
this._storeState = this._store.selectSnapshot(ROUTER_STATE_TOKEN);
this._trigger = 'store';
this._ngZone.run(() => this._router.navigateByUrl(this._storeState!.state!.url));
}
private _setUpRouterEventsListener(): void {
const dispatchRouterNavigationLate =
this._options != null &&
this._options.navigationActionTiming === NavigationActionTiming.PostActivation;
let lastRoutesRecognized: RoutesRecognized;
const events$ = this._router.events.pipe(takeUntil(this._destroy$));
events$.subscribe(event => {
this._lastEvent = event;
if (event instanceof NavigationStart) {
this._navigationStart(event);
} else if (event instanceof RoutesRecognized) {
lastRoutesRecognized = event;
if (!dispatchRouterNavigationLate && this._trigger !== 'store') {
this._dispatchRouterNavigation(lastRoutesRecognized);
}
} else if (event instanceof ResolveEnd) {
this._dispatchRouterDataResolved(event);
} else if (event instanceof NavigationCancel) {
this._dispatchRouterCancel(event);
this._reset();
} else if (event instanceof NavigationError) {
this._dispatchRouterError(event);
this._reset();
} else if (event instanceof NavigationEnd) {
if (this._trigger !== 'store') {
if (dispatchRouterNavigationLate) {
this._dispatchRouterNavigation(lastRoutesRecognized);
}
this._dispatchRouterNavigated(event);
}
this._reset();
}
});
}
/** Reacts to `NavigationStart`. */
private _navigationStart(event: NavigationStart): void {
this._routerState = this._serializer.serialize(this._router.routerState.snapshot);
if (this._trigger !== 'none') {
this._storeState = this._store.selectSnapshot(ROUTER_STATE_TOKEN);
this._dispatchRouterAction(new RouterRequest(this._routerState, event, this._trigger));
}
}
/** Reacts to `ResolveEnd`. */
private _dispatchRouterDataResolved(event: ResolveEnd): void {
const routerState = this._serializer.serialize(event.state);
this._dispatchRouterAction(new RouterDataResolved(routerState, event, this._trigger));
}
/** Reacts to `RoutesRecognized` or `NavigationEnd`, depends on the `navigationActionTiming`. */
private _dispatchRouterNavigation(lastRoutesRecognized: RoutesRecognized): void {
const nextRouterState = this._serializer.serialize(lastRoutesRecognized.state);
this._dispatchRouterAction(
new RouterNavigation(
nextRouterState,
new RoutesRecognized(
lastRoutesRecognized.id,
lastRoutesRecognized.url,
lastRoutesRecognized.urlAfterRedirects,
nextRouterState
),
this._trigger
)
);
}
/** Reacts to `NavigationCancel`. */
private _dispatchRouterCancel(event: NavigationCancel): void {
this._dispatchRouterAction(
new RouterCancel(this._routerState!, this._storeState, event, this._trigger)
);
}
/** Reacts to `NavigationEnd`. */
private _dispatchRouterError(event: NavigationError): void {
this._dispatchRouterAction(
new RouterError(
this._routerState!,
this._storeState,
new NavigationError(event.id, event.url, `${event}`),
this._trigger
)
);
}
/** Reacts to `NavigationEnd`. */
private _dispatchRouterNavigated(event: NavigationEnd): void {
const routerState = this._serializer.serialize(this._router.routerState.snapshot);
this._dispatchRouterAction(new RouterNavigated(routerState, event, this._trigger));
}
private _dispatchRouterAction<T>(action: RouterAction<T>): void {
this._trigger = 'router';
try {
this._store.dispatch(action);
} finally {
this._trigger = 'none';
}
}
private _reset(): void {
this._trigger = 'none';
this._storeState = null;
this._routerState = null;
}
}