From 49684755527d45229af5173ecdbd80d7c71473a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Jur=C4=8Da?= Date: Mon, 6 Dec 2021 15:00:10 +0100 Subject: [PATCH 1/6] Add a saga monitor for server-side initialization This saga monitor factory can be used to use complex redux sagas dependant on redux actions dispatched by each other for redux store state initialization at the server. The initialization is deemed completed once every saga is either terminated or waiting on a redux action. --- .../wrapper/src/initSagaMonitorFactory.ts | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 packages/wrapper/src/initSagaMonitorFactory.ts diff --git a/packages/wrapper/src/initSagaMonitorFactory.ts b/packages/wrapper/src/initSagaMonitorFactory.ts new file mode 100644 index 0000000..85084b1 --- /dev/null +++ b/packages/wrapper/src/initSagaMonitorFactory.ts @@ -0,0 +1,180 @@ +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 default 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 { + 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)) { + 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; + } +} From 7990f0ac7bf36bacdba92f6e71895cc9a96e713b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Jur=C4=8Da?= Date: Tue, 7 Dec 2021 08:20:08 +0100 Subject: [PATCH 2/6] Export init saga monitor from the main module --- packages/wrapper/src/index.tsx | 2 ++ packages/wrapper/src/initSagaMonitorFactory.ts | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) 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 index 85084b1..5a42262 100644 --- a/packages/wrapper/src/initSagaMonitorFactory.ts +++ b/packages/wrapper/src/initSagaMonitorFactory.ts @@ -11,7 +11,7 @@ export interface InitSagaMonitor { readonly start: () => void; } -export default function createInitSagaMonitor(timeout = DEFAULT_TIMEOUT): InitSagaMonitor { +export function createInitSagaMonitor(timeout = DEFAULT_TIMEOUT): InitSagaMonitor { const rootEffects: MonitoredEffect[] = []; const effectIdToEffectIndex = new Map(); From ed4e92fa11332e8de69920b6395e7467ef14364e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Jur=C4=8Da?= Date: Tue, 7 Dec 2021 09:43:19 +0100 Subject: [PATCH 3/6] Integrate the init saga monitor with examples --- .../demo-saga-page/src/components/store.tsx | 19 +++++++++++++------ packages/demo-saga-page/src/pages/index.tsx | 9 ++++++--- packages/demo-saga/src/components/store.tsx | 17 ++++++++++++----- packages/demo-saga/src/pages/_app.tsx | 9 ++++++--- 4 files changed, 37 insertions(+), 17 deletions(-) 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 From 0d492c8d10fbc3ee6cce3d18256fb00ba7abb64c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Jur=C4=8Da?= Date: Tue, 7 Dec 2021 12:58:19 +0100 Subject: [PATCH 4/6] Document reason for init monitor's manual start --- packages/wrapper/src/initSagaMonitorFactory.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/wrapper/src/initSagaMonitorFactory.ts b/packages/wrapper/src/initSagaMonitorFactory.ts index 5a42262..3b96028 100644 --- a/packages/wrapper/src/initSagaMonitorFactory.ts +++ b/packages/wrapper/src/initSagaMonitorFactory.ts @@ -66,6 +66,14 @@ export function createInitSagaMonitor(timeout = DEFAULT_TIMEOUT): InitSagaMonito 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.'); } From 0ae3a7a1c965613485fc91130d9f83eccd19e6c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Jur=C4=8Da?= Date: Tue, 7 Dec 2021 14:13:13 +0100 Subject: [PATCH 5/6] Use saga init monitor in redux-saga examples --- README.md | 52 ++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 36 insertions(+), 16 deletions(-) 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(); From 8b394c1f50ec8d7f5df3734018437bf2895463e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Martin=20Jur=C4=8Da?= Date: Fri, 6 May 2022 13:13:05 +0200 Subject: [PATCH 6/6] Improve inter-dependant init saga compatibility This fixes compatibility with init saga that wait for another init saga's outcome being provided through a redux action - this is useful if there are multiple consumers of the outcome of a single saga. --- packages/wrapper/src/initSagaMonitorFactory.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/wrapper/src/initSagaMonitorFactory.ts b/packages/wrapper/src/initSagaMonitorFactory.ts index 3b96028..eda2659 100644 --- a/packages/wrapper/src/initSagaMonitorFactory.ts +++ b/packages/wrapper/src/initSagaMonitorFactory.ts @@ -124,8 +124,14 @@ export function createInitSagaMonitor(timeout = DEFAULT_TIMEOUT): InitSagaMonito function checkInitCompletion(): void { if (isStarted && !isResolved && isInitCompleted(rootEffects)) { - isResolved = true; - initCompletionResolver(); + // 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(); + } + }); } } }