I am experiencing strange behavior with my app. I have subscribed to a state property using the selector and what I am seeing is, my subscription is getting called no matter what property in the state changes.
Below is a cleaned-up version of my code. My state has all kinds of properties, some objects, and some flat properties. Selectors for all properties work as expected except getImportStatus
and getImportProgress
selectors. Subscription to these selectors is triggered no matter what property in the store changes. I am just about to lose my mind. Can anyone suggest what I am doing wrong? Has anyone faced such issue? I know people run into similar issues when they do not unsubscribe the subscriptions. But, in my case, as you can see I am unsubscribing and the event is being triggered for any property change which has got me puzzled.
Here's my reducer:
import {ImportConfigActions, ImportConfigActionTypes} from '../actions';
import * as _ from 'lodash';
import {ImportProgress} from '../../models/import-progress';
import {ImportStatus} from '../../models/import-status';
import {ActionReducerMap, createFeatureSelector} from '@ngrx/store';
export interface ImportState {
importConfig: fromImportConfig.ImportConfigState;
}
export const reducers: ActionReducerMap<ImportState> = {
importConfig: fromImportConfig.reducer,
};
export const getImportState = createFeatureSelector<ImportState>('import');
export interface ImportConfigState {
spinner: boolean;
importStatus: ImportStatus; // This is my custom model
importProgress: ImportProgress; // This is my custom model
}
export const initialState: ImportConfigState = {
spinner: false,
importStatus: null,
importProgress: null
};
export function reducer(state = initialState, action: ImportConfigActions): ImportConfigState {
let newState;
switch (action.type) {
case ImportConfigActionTypes.ShowImportSpinner:
newState = _.cloneDeep(state);
newState.spinner = false;
return newState;
case ImportConfigActionTypes.HideImportSpinner:
newState = _.cloneDeep(state);
newState.spinner = false;
return newState;
case ImportConfigActionTypes.FetchImportStatusSuccess:
newState = _.cloneDeep(state);
newState.importStatus = action.importStatus;
return newState;
case ImportConfigActionTypes.FetchImportProgressSuccess:
newState = _.cloneDeep(state);
newState.importProgress = action.importProgress;
return newState;
default:
return state;
}
}
Here're my actions:
import {Action} from '@ngrx/store';
import {ImportStatus} from '../../models/import-status';
import {ImportProgress} from '../../models/import-progress';
export enum ImportConfigActionTypes {
ShowImportSpinner = '[Import Config] Show Import Spinner',
HideImportSpinner = '[Import Config] Hide Import Spinner',
FetchImportStatus = '[Import Config] Fetch Import Status',
FetchImportStatusSuccess = '[ImportConfig] Fetch Import Status Success',
FetchImportStatusFailure = '[Import Config] Fetch Import Status Failure',
FetchImportProgress = '[Import Config] Fetch Import Progress',
FetchImportProgressSuccess = '[ImportConfig] Fetch Import Progress Success',
FetchImportProgressFailure = '[Import Config] Fetch Import Progress Failure'
}
export class ShowImportSpinner implements Action {
readonly type = ImportConfigActionTypes.ShowImportSpinner;
}
export class HideImportSpinner implements Action {
readonly type = ImportConfigActionTypes.HideImportSpinner;
}
export class FetchImportStatus implements Action {
readonly type = ImportConfigActionTypes.FetchImportStatus;
constructor(readonly projectId: number, readonly importId: number) {}
}
export class FetchImportStatusSuccess implements Action {
readonly type = ImportConfigActionTypes.FetchImportStatusSuccess;
constructor(readonly importStatus: ImportStatus) {}
}
export class FetchImportStatusFailure implements Action {
readonly type = ImportConfigActionTypes.FetchImportStatusFailure;
}
export class FetchImportProgress implements Action {
readonly type = ImportConfigActionTypes.FetchImportProgress;
constructor(readonly projectId: number, readonly importId: number) {}
}
export class FetchImportProgressSuccess implements Action {
readonly type = ImportConfigActionTypes.FetchImportProgressSuccess;
constructor(readonly importProgress: ImportProgress) {}
}
export class FetchImportProgressFailure implements Action {
readonly type = ImportConfigActionTypes.FetchImportProgressFailure;
}
export type ImportConfigActions =
ShowImportSpinner | HideImportSpinner |
FetchImportStatus | FetchImportStatusSuccess | FetchImportStatusFailure |
FetchImportProgress | FetchImportProgressSuccess | FetchImportProgressFailure;
Here're my effects:
import {Injectable} from '@angular/core';
import {Actions, Effect, ofType} from '@ngrx/effects';
import {ImportConfigService} from '../../services';
import {from, Observable} from 'rxjs';
import {Action} from '@ngrx/store';
import {
FetchImportProgress, FetchImportProgressFailure, FetchImportProgressSuccess,
FetchImportStatus, FetchImportStatusFailure, FetchImportStatusSuccess,
HideImportSpinner,
ImportConfigActionTypes,
StartImport
} from '../actions';
import {catchError, map, mergeMap, switchMap} from 'rxjs/operators';
@Injectable()
export class ImportConfigEffects {
constructor(private actions$: Actions, private service: ImportConfigService, private errorService: ErrorService) {}
@Effect()
startImport: Observable<Action> = this.actions$.pipe(
ofType<StartImport>(ImportConfigActionTypes.StartImport),
switchMap((action) => {
return this.service.startImport(action.payload.projectId, action.payload.importId, action.payload.importConfig)
.pipe(
mergeMap((res: any) => {
if (res.status === 'Success') {
return [
new HideImportSpinner()
];
}
return [];
}),
catchError(err => from([
new HideImportSpinner()
]))
);
})
);
@Effect()
fetchImportStatus: Observable<Action> = this.actions$.pipe(
ofType<FetchImportStatus>(ImportConfigActionTypes.FetchImportStatus),
switchMap((action) => {
return this.service.fetchImportStatus(action.projectId, action.importId)
.pipe(
mergeMap((res: any) => {
if (res.status === 'Success') {
return [
new FetchImportStatusSuccess(res.data)
];
}
}),
catchError(err => from([
new FetchImportStatusFailure()
]))
);
})
);
@Effect()
fetchImportProgress: Observable<Action> = this.actions$.pipe(
ofType<FetchImportProgress>(ImportConfigActionTypes.FetchImportProgress),
switchMap((action) => {
return this.service.fetchImportProgress(action.projectId, action.importId)
.pipe(
mergeMap((res: any) => {
if (res.status === 'Success') {
return [
new FetchImportProgressSuccess(res.data)
];
}
}),
catchError(err => from([
new FetchImportProgressFailure()
]))
);
})
);
}
Here're my selectors:
import {createSelector} from '@ngrx/store';
import {ImportConfig} from '../../models/import-config';
import {ImportConfigState} from '../reducers/import-config.reducer';
import {getImportState, ImportState} from '../reducers';
export const getImportConfigState = createSelector(
getImportState,
(importState: ImportState) => importState.importConfig
);
export const getImportConfig = createSelector(
getImportConfigState,
(importConfigState: ImportConfigState) => importConfigState.importConfig
);
export const isImportSpinnerShowing = createSelector(
getImportConfigState,
(importConfigState: ImportConfigState) => importConfigState.importSpinner
);
export const getImportStatus = createSelector(
getImportConfigState,
(importConfigState: ImportConfigState) => importConfigState.importStatus
);
export const getImportProgress = createSelector(
getImportConfigState,
(importConfigState: ImportConfigState) => importConfigState.importProgress
);
Here's my component:
import {Component, OnDestroy, OnInit, ViewEncapsulation} from '@angular/core';
import {select, Store} from '@ngrx/store';
import {ImportState} from '../../store/reducers';
import {library} from '@fortawesome/fontawesome-svg-core';
import {faAngleLeft, faAngleRight, faExchangeAlt,
faFolder, faFolderOpen, faFileImport, faLink, faEquals, faCogs,
faExclamationCircle, faFilter, faSearch, faHome} from '@fortawesome/free-solid-svg-icons';
import {faFile} from '@fortawesome/free-regular-svg-icons';
import {FetchImportProgress, FetchImportStatus} from '../../store/actions';
import {ActivatedRoute} from '@angular/router';
import {Subject} from 'rxjs';
import {BsModalRef, BsModalService} from 'ngx-bootstrap';
import {ImportProgressComponent} from '../import-progress/import-progress.component';
import {getImportStatus} from '../../store/selectors';
import {filter, map, takeUntil} from 'rxjs/operators';
import {ImportStatus} from '../../models/import-status';
@Component({
selector: 'app-import',
templateUrl: './import.component.html',
styleUrls: ['./import.component.scss'],
encapsulation: ViewEncapsulation.None
})
export class ImportComponent implements OnInit, OnDestroy {
importId: string;
projectId: string;
status: number;
phase: number;
private importProgressModalRef: BsModalRef;
private isProgressModalShowing = false;
private unsubscribe$ = new Subject<void>();
queryParamsSubscription: any;
constructor(
private store: Store<ImportState>,
private route: ActivatedRoute,
private modalService: BsModalService) {
library.add(
faHome,
faFolder, faFolderOpen, faFile, faFileImport,
faAngleRight, faAngleLeft,
faFilter, faSearch,
faExchangeAlt,
faLink,
faEquals,
faCogs,
faExclamationCircle);
this.queryParamsSubscription = this.route.queryParams
.subscribe(params => {
this.importId = params['importId'];
this.projectId = params['projectId'];
});
}
ngOnInit(): void {
this.store.dispatch(new FetchImportStatus(+this.projectId, +this.importId));
this.store.dispatch(new FetchImportProgress(+this.projectId, +this.importId));
this.store.pipe(select(getImportStatus), takeUntil(this.unsubscribe$), map((importStatus: ImportStatus) => importStatus),
filter((importStatus: ImportStatus) => !!importStatus))
.subscribe((importStatus: ImportStatus) => {
this.status = importStatus.status; // This is getting triggered for all property changes
this.phase = importStatus.phase;
this.handleStatusChange();
});
}
ngOnDestroy(): void {
this.unsubscribe$.next();
this.unsubscribe$.complete();
this.queryParamsSubscription.unsubscribe();
}
handleStatusChange() {
if (this.status !== 2 || (this.phase === 5)) {
if (!this.isProgressModalShowing) {
this.openImportProgressModal();
this.isProgressModalShowing = true;
}
}
}
openImportProgressModal() {
this.importProgressModalRef = this.modalService.show(ImportProgressComponent,
Object.assign({}, { class: 'modal-md', ignoreBackdropClick: true }));
this.importProgressModalRef.content.modalRef = this.importProgressModalRef;
this.importProgressModalRef.content.onModalCloseCallBack = this.onImportProgressModalClose;
}
onImportProgressModalClose = () => {
this.isProgressModalShowing = false;
};
}
I couldn't figure out what was happening. Since I was running out of time, I had to go for an alternative hack.
this.store.pipe(select(getImportStatus), takeUntil(this.unsubscribe$), map((importStatus: ImportStatus) => importStatus),
filter((importStatus: ImportStatus) => !!importStatus))
.subscribe((importStatus: ImportStatus) => {
if (_.isEqual(this.importStatus, importStatus)) {
return;
}
this.importStatus = importStatus;
this.status = importStatus.status;
this.phase = importStatus.phase;
this.handleStatusChange();
});
I used the loadash library to compare the new store property with the old one inside my subscribe body. This is unnecessary because the store should have emitted changed values only. For now, at least this will keep me going.
NEW UPDATE
The reason why my subscriptions to store properties were being called multiple times was because the state wasn't being completely cloned. I was using _cloneDeep
function provided by lodash to deep clone my state and update the properties. I guess no library is 100% efficient when it comes to cloning.