Skip to content

Commit 8da122d

Browse files
authored
Merge pull request #1902 from FaberVitale/fix/alm-getOriginalState-leaky-behavior
2 parents 5cedffd + 6deca2c commit 8da122d

File tree

4 files changed

+118
-21
lines changed

4 files changed

+118
-21
lines changed

packages/action-listener-middleware/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ The `listenerApi` object is the second argument to each listener callback. It co
210210

211211
- `dispatch: Dispatch`: the standard `store.dispatch` method
212212
- `getState: () => State`: the standard `store.getState` method
213-
- `getOriginalState: () => State`: returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran
213+
- `getOriginalState: () => State`: returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran. (**Note**: this method can only be called synchronously, during the initial dispatch call stack, to avoid memory leaks. Calling it asynchronously will throw an error.)
214214

215215
`dispatch` and `getState` are exactly the same as in a thunk. `getOriginalState` can be used to compare the original state before the listener was started.
216216

packages/action-listener-middleware/src/index.ts

Lines changed: 42 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,10 @@ export type {
6464

6565
//Overly-aggressive byte-shaving
6666
const { assign } = Object
67+
/**
68+
* @internal
69+
*/
70+
const INTERNAL_NIL_TOKEN = {} as const
6771

6872
const alm = 'actionListenerMiddleware' as const
6973

@@ -432,33 +436,51 @@ export function createActionListenerMiddleware<
432436
}
433437

434438
// Need to get this state _before_ the reducer processes the action
435-
const originalState = api.getState()
436-
const getOriginalState = () => originalState
439+
let originalState: S | typeof INTERNAL_NIL_TOKEN = api.getState()
440+
441+
// `getOriginalState` can only be called synchronously.
442+
// @see https://github.com/reduxjs/redux-toolkit/discussions/1648#discussioncomment-1932820
443+
const getOriginalState = (): S => {
444+
if (originalState === INTERNAL_NIL_TOKEN) {
445+
throw new Error(
446+
`${alm}: getOriginalState can only be called synchronously`
447+
)
448+
}
437449

438-
// Actually forward the action to the reducer before we handle listeners
439-
const result: unknown = next(action)
450+
return originalState as S
451+
}
440452

441-
if (listenerMap.size > 0) {
442-
let currentState = api.getState()
443-
for (let entry of listenerMap.values()) {
444-
let runListener = false
453+
let result: unknown
445454

446-
try {
447-
runListener = entry.predicate(action, currentState, originalState)
448-
} catch (predicateError) {
449-
runListener = false
455+
try {
456+
// Actually forward the action to the reducer before we handle listeners
457+
result = next(action)
450458

451-
safelyNotifyError(onError, predicateError, {
452-
raisedBy: 'predicate',
453-
})
454-
}
459+
if (listenerMap.size > 0) {
460+
let currentState = api.getState()
461+
for (let entry of listenerMap.values()) {
462+
let runListener = false
455463

456-
if (!runListener) {
457-
continue
458-
}
464+
try {
465+
runListener = entry.predicate(action, currentState, originalState)
466+
} catch (predicateError) {
467+
runListener = false
468+
469+
safelyNotifyError(onError, predicateError, {
470+
raisedBy: 'predicate',
471+
})
472+
}
459473

460-
notifyListener(entry, action, api, getOriginalState)
474+
if (!runListener) {
475+
continue
476+
}
477+
478+
notifyListener(entry, action, api, getOriginalState)
479+
}
461480
}
481+
} finally {
482+
// Remove `originalState` store from this scope.
483+
originalState = INTERNAL_NIL_TOKEN
462484
}
463485

464486
return result

packages/action-listener-middleware/src/tests/listenerMiddleware.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import type {
2121
TypedAddListenerAction,
2222
TypedAddListener,
2323
Unsubscribe,
24+
ActionListenerMiddleware,
2425
} from '../index'
2526

2627
const middlewareApi = {
@@ -531,6 +532,56 @@ describe('createActionListenerMiddleware', () => {
531532
expect(listener2Calls).toBe(1)
532533
})
533534

535+
test('getOriginalState can only be invoked synchronously', async () => {
536+
const onError = jest.fn()
537+
538+
middleware = createActionListenerMiddleware({ onError })
539+
const store = configureStore({
540+
reducer: counterSlice.reducer,
541+
middleware: (gDM) => gDM().prepend(middleware),
542+
})
543+
544+
let appMidleware = middleware as ActionListenerMiddleware<
545+
CounterState,
546+
typeof store.dispatch
547+
>
548+
549+
appMidleware.addListener({
550+
actionCreator: increment,
551+
async listener(_, listenerApi) {
552+
const runIncrementBy = () => {
553+
listenerApi.dispatch(
554+
counterSlice.actions.incrementByAmount(
555+
listenerApi.getOriginalState().value + 2
556+
)
557+
)
558+
}
559+
560+
runIncrementBy()
561+
562+
await Promise.resolve()
563+
564+
runIncrementBy()
565+
},
566+
})
567+
568+
expect(store.getState()).toEqual({ value: 0 })
569+
570+
store.dispatch(increment()) // state.value+=1 && trigger listener
571+
expect(onError).not.toHaveBeenCalled()
572+
expect(store.getState()).toEqual({ value: 3 })
573+
574+
await delay(0)
575+
576+
expect(onError).toBeCalledWith(
577+
new Error(
578+
'actionListenerMiddleware: getOriginalState can only be called synchronously'
579+
),
580+
{ raisedBy: 'listener' }
581+
)
582+
expect(store.getState()).toEqual({ value: 3 })
583+
})
584+
534585
test('by default, actions are forwarded to the store', () => {
535586
reducer.mockClear()
536587

packages/action-listener-middleware/src/types.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,30 @@ export interface ForkedTask<T> {
118118
*/
119119
export interface ActionListenerMiddlewareAPI<S, D extends Dispatch<AnyAction>>
120120
extends MiddlewareAPI<D, S> {
121+
/**
122+
* Returns the store state as it existed when the action was originally dispatched, _before_ the reducers ran.
123+
*
124+
* ### Synchronous invocation
125+
*
126+
* This function can **only** be invoked **synchronously**, it throws error otherwise.
127+
*
128+
* @example
129+
*
130+
* ```ts
131+
* middleware.addListener({
132+
* predicate: () => true,
133+
* async listener(_, { getOriginalState }) {
134+
* getOriginalState(); // sync: OK!
135+
*
136+
* setTimeout(getOriginalState, 0); // async: throws Error
137+
*
138+
* await Promise().resolve();
139+
*
140+
* getOriginalState() // async: throws Error
141+
* }
142+
* })
143+
* ```
144+
*/
121145
getOriginalState: () => S
122146
unsubscribe(): void
123147
subscribe(): void

0 commit comments

Comments
 (0)