import {push} from "redux-first-history"; import {put} from "redux-saga/effects"; import {produce, enablePatches} from "immer"; import {app} from "../app"; import {matchPath} from "react-router"; import {navigationPreventionAction, setStateAction, type State} from "../reducer"; import type {Location} from "history"; import type {TickIntervalDecoratorFlag} from "../module"; import type {SagaGenerator} from "../typed-saga"; import type {Logger} from "../Logger"; if (process.env.NODE_ENV === "development") enablePatches(); export type ModuleLocation = Location | undefined>; export interface ModuleLifecycleListener { onEnter: (entryComponentProps?: any) => SagaGenerator; onDestroy: () => SagaGenerator; onLocationMatched: (routeParameters: RouteParam, location: ModuleLocation) => SagaGenerator; onTick: (() => SagaGenerator) & TickIntervalDecoratorFlag; } export class Module< RootState extends State, ModuleName extends keyof RootState["app"] & string, RouteParam extends object = object, HistoryState extends object = object, > implements ModuleLifecycleListener { constructor( readonly name: ModuleName, readonly initialState: RootState["app"][ModuleName] ) {} *onEnter(entryComponentProps: any): SagaGenerator { /** * Called when the attached component is initially mounted. */ } *onDestroy(): SagaGenerator { /** * Called when the attached component is going to unmount */ } *onLocationMatched(routeParam: RouteParam, location: ModuleLocation): SagaGenerator { /** * Called when the attached component is a React-Route component and its Route location matches * It is called each time the location changes, as long as it still matches */ } *onTick(): SagaGenerator { /** * Called periodically during the lifecycle of attached component * Usually used together with @Interval decorator, to specify the period (in second) * Attention: The next tick will not be triggered, until the current tick has finished */ } get state(): Readonly { return this.rootState.app[this.name]; } get rootState(): Readonly { return app.store.getState() as Readonly; } get logger(): Logger { return app.logger; } get historyState(): HistoryState | undefined { return app.store.getState().router.location?.state as HistoryState | undefined; } setNavigationPrevented(isPrevented: boolean) { app.store.dispatch(navigationPreventionAction(isPrevented)); } setState( stateOrUpdater: ((state: RootState["app"][ModuleName]) => void) | Pick | RootState["app"][ModuleName] ): void { if (typeof stateOrUpdater === "function") { const originalState = this.state; const updater = stateOrUpdater as (state: RootState["app"][ModuleName]) => void; let patchDescriptions: string[] | undefined; const newState = produce, RootState["app"][ModuleName]>( originalState, draftState => { // Wrap into a void function, in case updater() might return anything updater(draftState); }, process.env.NODE_ENV === "development" ? patches => { // No need to read "op", in will only be "replace" patchDescriptions = patches.map(_ => _.path.join(".")); } : undefined ); if (newState !== originalState) { const description = `@@${this.name}/setState${patchDescriptions ? `[${patchDescriptions.join("/")}]` : ``}`; app.store.dispatch(setStateAction(this.name, newState, description)); } } else { const partialState = stateOrUpdater as object; this.setState((state: object) => Object.assign(state, partialState)); } } /** * CAVEAT: * (1) * Calling this.pushHistory to other module should cancel the following logic. * Using store.dispatch here will lead to error while cancelling in lifecycle. * * Because the whole process is in sync mode: * dispatch push action -> location change -> router component will un-mount -> lifecycle saga cancel * * Cancelling the current sync-running saga will throw "TypeError: Generator is already executing". * * (2) * Adding yield cancel() in pushHistory is also incorrect. * If this.pushHistory is only to change state rather than URL, it will lead to the whole lifecycle saga cancelled. * * https://github.com/react-boilerplate/react-boilerplate/issues/1281 */ pushHistory(url: string): SagaGenerator; pushHistory(url: string, stateMode: "keep-state"): SagaGenerator; pushHistory(url: string, state: T): SagaGenerator; // Recommended explicitly pass the generic type pushHistory(state: HistoryState): SagaGenerator; *pushHistory(urlOrState: HistoryState | string, state?: object | "keep-state"): SagaGenerator { if (typeof urlOrState === "string") { const url: string = urlOrState; if (state) { yield put(push(url, state === "keep-state" ? app.history.location.state : state)); } else { yield put(push(url)); } } else { const currentURL = location.pathname + location.search; const state: HistoryState = urlOrState; yield put(push(currentURL, state)); } } }