diff --git a/README.md b/README.md index f72e29b..4a6b4dd 100644 --- a/README.md +++ b/README.md @@ -916,27 +916,34 @@ Create your root saga as usual, then implement the store creator: ```typescript import {createStore, applyMiddleware, Store} from 'redux'; -import {createWrapper, Context} from 'next-redux-wrapper'; +import {createInitSagaMonitor, createWrapper, InitSagaMonitor, Context} from 'next-redux-wrapper'; import createSagaMiddleware, {Task} from 'redux-saga'; import reducer, {State} from './reducer'; import rootSaga from './saga'; -export interface SagaStore extends Store { - sagaTask?: Task; +export interface SagaStore extends Store { + initMonitor: InitSagaMonitor; + sagaTask: Task; } -export const makeStore = (context: Context) => { +const INITIALIZATION_TIMEOUT = 30_000; // 30 seconds + +export const makeStore = (context: Context): SagaStore => { // 1: Create the middleware - const sagaMiddleware = createSagaMiddleware(); + const initMonitor = createInitSagaMonitor(INITIALIZATION_TIMEOUT); + const sagaMiddleware = createSagaMiddleware({sagaMonitor: initMonitor.monitor}); // 2: Add an extra parameter for applying middleware: const store = createStore(reducer, applyMiddleware(sagaMiddleware)); // 3: Run your sagas on server - (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga); + const sagaTask = sagaMiddleware.run(rootSaga); - // 4: now return the store: - return store; + // 4: Now return the store with access to init monitor and root saga task + return Object.assign(store, { + initMonitor, + sagaTask, + }); }; export const wrapper = createWrapper>(makeStore, {debug: true}); @@ -947,23 +954,29 @@ export const wrapper = createWrapper>(makeStore, {debug: true}); ```js import {createStore, applyMiddleware} from 'redux'; -import {createWrapper} from 'next-redux-wrapper'; +import {createInitSagaMonitor, createWrapper} from 'next-redux-wrapper'; import createSagaMiddleware from 'redux-saga'; import reducer from './reducer'; import rootSaga from './saga'; +const INITIALIZATION_TIMEOUT = 30_000; // 30 seconds + export const makeStore = context => { // 1: Create the middleware - const sagaMiddleware = createSagaMiddleware(); + const initMonitor = createInitSagaMonitor(INITIALIZATION_TIMEOUT); + const sagaMiddleware = createSagaMiddleware({sagaMonitor: initMonitor.monitor}); // 2: Add an extra parameter for applying middleware: const store = createStore(reducer, applyMiddleware(sagaMiddleware)); // 3: Run your sagas on server - store.sagaTask = sagaMiddleware.run(rootSaga); + const sagaTask = sagaMiddleware.run(rootSaga); - // 4: now return the store: - return store; + // 4: Now return the store with access to init monitor and root saga task + return Object.assign(store, { + initMonitor, + sagaTask, + }); }; export const wrapper = createWrapper(makeStore, {debug: true}); @@ -989,10 +1002,13 @@ class WrappedApp extends App { ...(await App.getInitialProps(context)).pageProps, }; - // 2. Stop the saga if on server + // 2. Stop the saga if on server once the initialization is done if (context.ctx.req) { + const sagaStore = store as SagaStore; + sagaStore.initMonitor.start(); // Start the init monitor after sagas started to do their work + await sagaStore.initMonitor.initCompletion; store.dispatch(END); - await (store as SagaStore).sagaTask.toPromise(); + await sagaStore.sagaTask.toPromise(); } // 3. Return props @@ -1025,8 +1041,10 @@ class WrappedApp extends App { ...(await App.getInitialProps(context)).pageProps, }; - // 2. Stop the saga if on server + // 2. Stop the saga if on server once the initialization is done if (context.ctx.req) { + store.initMonitor.start(); // Start the init monitor after sagas started to do their work + await store.initMonitor.initCompletion; store.dispatch(END); await store.sagaTask.toPromise(); } @@ -1054,6 +1072,8 @@ In order to use it with `getServerSideProps` or `getStaticProps` you need to `aw export const getServerSideProps = ReduxWrapper.getServerSideProps(async ({store, req, res, ...etc}) => { // regular stuff store.dispatch(ApplicationSlice.actions.updateConfiguration()); + store.initMonitor.start(); // Start the init monitor after sagas started to do their work + await store.initMonitor.initCompletion; // end the saga store.dispatch(END); await store.sagaTask.toPromise(); diff --git a/packages/demo-saga-page/src/components/store.tsx b/packages/demo-saga-page/src/components/store.tsx index 04b849c..d050284 100644 --- a/packages/demo-saga-page/src/components/store.tsx +++ b/packages/demo-saga-page/src/components/store.tsx @@ -1,26 +1,33 @@ import {createStore, applyMiddleware, Store} from 'redux'; import logger from 'redux-logger'; import createSagaMiddleware, {Task} from 'redux-saga'; -import {Context, createWrapper} from 'next-redux-wrapper'; +import {Context, createInitSagaMonitor, createWrapper, InitSagaMonitor} from 'next-redux-wrapper'; import reducer, {State} from './reducer'; import rootSaga from './saga'; export interface SagaStore extends Store { + initMonitor: InitSagaMonitor; sagaTask: Task; } -const makeStore = (context: Context) => { +const INITIALIZATION_TIMEOUT = 30_000; // 30 seconds + +const makeStore = (context: Context): SagaStore => { // 1: Create the middleware - const sagaMiddleware = createSagaMiddleware(); + const initMonitor = createInitSagaMonitor(INITIALIZATION_TIMEOUT); + const sagaMiddleware = createSagaMiddleware({sagaMonitor: initMonitor.monitor}); // 2: Add an extra parameter for applying middleware: const store = createStore(reducer, applyMiddleware(sagaMiddleware, logger)); // 3: Run your sagas on server - (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga); + const sagaTask = sagaMiddleware.run(rootSaga); - // 4: now return the store: - return store; + // 4: Now return the store with access to init monitor and root saga task + return Object.assign(store, { + initMonitor, + sagaTask, + }); }; export const wrapper = createWrapper(makeStore as any); diff --git a/packages/demo-saga-page/src/pages/index.tsx b/packages/demo-saga-page/src/pages/index.tsx index 3ade96f..966959d 100644 --- a/packages/demo-saga-page/src/pages/index.tsx +++ b/packages/demo-saga-page/src/pages/index.tsx @@ -21,9 +21,12 @@ const Page: NextPage = ({custom}: ConnectedPageProps) => { }; export const getServerSideProps = wrapper.getServerSideProps(store => async () => { - store.dispatch({type: SAGA_ACTION}); - store.dispatch(END); - await (store as SagaStore).sagaTask.toPromise(); + const sagaStore = store as SagaStore; + sagaStore.dispatch({type: SAGA_ACTION}); + sagaStore.initMonitor.start(); // Start the init monitor after sagas started to do their work + await sagaStore.initMonitor.initCompletion; + sagaStore.dispatch(END); + await sagaStore.sagaTask.toPromise(); return {props: {custom: 'custom'}}; }); diff --git a/packages/demo-saga/src/components/store.tsx b/packages/demo-saga/src/components/store.tsx index 41d4070..52c37b1 100644 --- a/packages/demo-saga/src/components/store.tsx +++ b/packages/demo-saga/src/components/store.tsx @@ -1,26 +1,33 @@ import {createStore, applyMiddleware, Store} from 'redux'; import logger from 'redux-logger'; import createSagaMiddleware, {Task} from 'redux-saga'; -import {Context, createWrapper} from 'next-redux-wrapper'; +import {Context, createInitSagaMonitor, createWrapper, InitSagaMonitor} from 'next-redux-wrapper'; import reducer from './reducer'; import rootSaga from './saga'; export interface SagaStore extends Store { + initMonitor: InitSagaMonitor; sagaTask: Task; } +const INITIALIZATION_TIMEOUT = 30_000; // 30 seconds + export const makeStore = (context: Context) => { // 1: Create the middleware - const sagaMiddleware = createSagaMiddleware(); + const initMonitor = createInitSagaMonitor(INITIALIZATION_TIMEOUT); + const sagaMiddleware = createSagaMiddleware({sagaMonitor: initMonitor.monitor}); // 2: Add an extra parameter for applying middleware: const store = createStore(reducer, applyMiddleware(sagaMiddleware, logger)); // 3: Run your sagas on server - (store as SagaStore).sagaTask = sagaMiddleware.run(rootSaga); + const sagaTask = sagaMiddleware.run(rootSaga); - // 4: now return the store: - return store; + // 4: Now return the store with access to init monitor and root saga task + return Object.assign(store, { + initMonitor, + sagaTask, + }); }; export const wrapper = createWrapper(makeStore as any); diff --git a/packages/demo-saga/src/pages/_app.tsx b/packages/demo-saga/src/pages/_app.tsx index 983ce0b..3bd89fe 100644 --- a/packages/demo-saga/src/pages/_app.tsx +++ b/packages/demo-saga/src/pages/_app.tsx @@ -11,10 +11,13 @@ class MyApp extends React.Component { ...(await App.getInitialProps(context)).pageProps, }; - // 2. Stop the saga if on server + // 2. Stop the saga if on server once the initialization is done if (context.ctx.req) { - store.dispatch(END); - await (store as SagaStore).sagaTask.toPromise(); + const sagaStore = store as SagaStore; + sagaStore.initMonitor.start(); // Start the init monitor after sagas started to do their work + await sagaStore.initMonitor.initCompletion; + sagaStore.dispatch(END); + await sagaStore.sagaTask.toPromise(); } // 3. Return props diff --git a/packages/wrapper/src/index.tsx b/packages/wrapper/src/index.tsx index 206038c..d8639e3 100644 --- a/packages/wrapper/src/index.tsx +++ b/packages/wrapper/src/index.tsx @@ -11,6 +11,8 @@ import { NextPageContext, } from 'next'; +export * from './initSagaMonitorFactory'; + /** * Quick note on Next.js return types: * diff --git a/packages/wrapper/src/initSagaMonitorFactory.ts b/packages/wrapper/src/initSagaMonitorFactory.ts new file mode 100644 index 0000000..eda2659 --- /dev/null +++ b/packages/wrapper/src/initSagaMonitorFactory.ts @@ -0,0 +1,194 @@ +import {SagaMonitor} from '@redux-saga/core'; +import {effect as isEffect, task as isTask} from '@redux-saga/is'; +import {Effect} from '@redux-saga/types'; +import {spawn} from 'redux-saga/effects'; + +export const DEFAULT_TIMEOUT = 120_000; // 2 minutes. No timeouts at all are generally a very bad idea. + +export interface InitSagaMonitor { + readonly monitor: SagaMonitor; + readonly initCompletion: Promise; + readonly start: () => void; +} + +export function createInitSagaMonitor(timeout = DEFAULT_TIMEOUT): InitSagaMonitor { + const rootEffects: MonitoredEffect[] = []; + const effectIdToEffectIndex = new Map(); + + let initCompletionResolver!: () => void; + let initCompletionRejector!: (error: Error) => void; + let isStarted = false; + let isResolved = false; + let monitor!: SagaMonitor; + + const initCompletion = new Promise((resolve, reject) => { + initCompletionResolver = resolve; + initCompletionRejector = reject; + monitor = { + rootSagaStarted({effectId, saga, args}): void { + registerEffect(effectId, null, spawn(saga, ...args)); + }, + effectTriggered({effectId, parentEffectId, effect}): void { + if (isEffect(effect)) { + registerEffect(effectId, parentEffectId, effect); + if (effect.type === 'TAKE') { + checkInitCompletion(); + } + } + }, + effectResolved(effectId, result): void { + const effect = effectIdToEffectIndex.get(effectId); + if (effect) { + if (isTask(result)) { + result.toPromise().then(() => { + effect.isPending = false; + checkInitCompletion(); + }, (error) => { + isResolved = true; + reject(error); + }); + } else { + effect.isPending = false; + checkInitCompletion(); + } + } + }, + effectRejected(effectId): void { + resolveFailedOrCancelledEffect(effectId); + }, + effectCancelled(effectId): void { + resolveFailedOrCancelledEffect(effectId); + }, + }; + }); + + return { + initCompletion, + monitor, + start(): void { + // Implementation note: While it is possible, in certain situations, to auto-start the monitor at the right moment (a saga triggers an effect that + // is a CPS or a CALL to a function that is not a generator and does not resolve in the next tick, i.e. is asynchronous; or all sagas have + // terminated), this would not be compatible with all use cases, e.g. a page dispatching an action, that makes sagas do anything meaningful at + // all, only in certain conditions. + // A tempting alternative is to start the monitor asynchronously in the next tick after it has been created, however, this would not be compatible + // with the use case of a page component's getXXXProps asynchronously dispatching a redux action that would start the sagas to do their work. + // Ergo, to mitigate any possible confusion caused by this behavior, this is not even an opt-in feature. At least, not yet. + + if (isStarted) { + throw new Error('This init monitor has already been started. Please create a new one for every redux store.'); + } + + isStarted = true; + + if (timeout > 0) { + setTimeout(() => { + if (isResolved) { + return; + } + + isResolved = true; + const pendingEffects = trimResolvedEffects(rootEffects); + initCompletionRejector(Object.assign(new Error( + `The following redux saga effects did not finish or block on a take effect within the configured timeout of ${timeout} ` + + `milliseconds:\n${formatEffectForest(pendingEffects)}`, + ), {name: 'TimeoutError', timeout})); + }, timeout); + } + + checkInitCompletion(); + }, + }; + + function registerEffect(effectId: number, parentId: number | null, descriptor: Effect): void { + const effect: MonitoredEffect = { + id: effectId, + parent: (parentId !== null && effectIdToEffectIndex.get(parentId)) || null, + descriptor, + children: [], + isPending: true, + }; + if (effect.parent === null) { + rootEffects.push(effect); + } else { + effect.parent.children.push(effect); + } + effectIdToEffectIndex.set(effectId, effect); + } + + function resolveFailedOrCancelledEffect(effectId: number): void { + const effect = effectIdToEffectIndex.get(effectId); + if (effect) { + effect.isPending = false; + } + } + + function checkInitCompletion(): void { + if (isStarted && !isResolved && isInitCompleted(rootEffects)) { + // We wait a micro tick to ensure that an ongoing init saga waiting for a redux action currently being delivered is not prematurely marked as + // completed. + Promise.resolve().then(() => { + if (!isResolved && isInitCompleted(rootEffects)) { + isResolved = true; + initCompletionResolver(); + } + }); + } + } +} + +interface MonitoredEffect { + readonly id: number; + readonly parent: MonitoredEffect | null; + readonly descriptor: Effect; + readonly children: MonitoredEffect[]; + isPending: boolean; +} + +function isInitCompleted(effects: readonly MonitoredEffect[]): boolean { + return effects.every(isTreeResolvedOrTake); +} + +function isTreeResolvedOrTake(effect: MonitoredEffect): boolean { + return (effect.children.length && effect.children.every(isTreeResolvedOrTake)) || isResolvedOrTake(effect); +} + +function isResolvedOrTake(effect: MonitoredEffect): boolean { + return !effect.isPending || effect.descriptor.type === 'TAKE'; +} + +function trimResolvedEffects(effects: readonly MonitoredEffect[]): MonitoredEffect[] { + const pendingEffects: MonitoredEffect[] = []; + for (const effect of effects) { + const pendingChildren = trimResolvedEffects(effect.children); + if ((!effect.children.length || pendingChildren.length) && !isResolvedOrTake(effect)) { + pendingEffects.push({ + ...effect, + children: pendingChildren, + }); + } + } + return pendingEffects; +} + +function formatEffectForest(rootEffects: readonly MonitoredEffect[]): string { + return formatLevel(rootEffects, '').join('\n'); + + function formatLevel(effects: readonly MonitoredEffect[], indentation: string): string[] { + const lines: string[] = []; + for (const effect of effects) { + const formattedEffect = `#${effect.id} ${effect.descriptor.type}`; + const state = effect.isPending ? 'pending' : 'resolved'; + switch (effect.descriptor.type) { + case 'FORK': + case 'CALL': + lines.push(`${indentation}${formattedEffect}: ${effect.descriptor.payload.fn.name} ${state}`); + break; + default: + lines.push(`${indentation}${formattedEffect} ${state}`); + break; + } + lines.push(...formatLevel(effect.children, ` ${indentation}`)); + } + return lines; + } +}