简体   繁体   中英

Angular 7 with NGRX - subscription to a store property through selector getting called multiple times

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.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM