Skip to content

Commit 0de2441

Browse files
committed
Add an auto-batching enhancer that delays low-pri notifications
1 parent d934fad commit 0de2441

File tree

3 files changed

+227
-0
lines changed

3 files changed

+227
-0
lines changed
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import type { StoreEnhancer } from 'redux'
2+
3+
export const SHOULD_AUTOBATCH = 'RTK_autoBatch'
4+
5+
export const prepareAutoBatched =
6+
<T>() =>
7+
(payload: T): { payload: T; meta: unknown } => ({
8+
payload,
9+
meta: { [SHOULD_AUTOBATCH]: true },
10+
})
11+
12+
// TODO Remove this in 2.0
13+
// Copied from https://github.com/feross/queue-microtask
14+
let promise: Promise<any>
15+
const queueMicrotaskShim =
16+
typeof queueMicrotask === 'function'
17+
? queueMicrotask.bind(typeof window !== 'undefined' ? window : global)
18+
: // reuse resolved promise, and allocate it lazily
19+
(cb: () => void) =>
20+
(promise || (promise = Promise.resolve())).then(cb).catch((err: any) =>
21+
setTimeout(() => {
22+
throw err
23+
}, 0)
24+
)
25+
26+
/**
27+
* A Redux store enhancer that watches for "low-priority" actions, and delays
28+
* notifying subscribers until either the end of the event loop tick or the
29+
* next "standard-priority" action is dispatched.
30+
*
31+
* This allows dispatching multiple "low-priority" actions in a row with only
32+
* a single subscriber notification to the UI after the sequence of actions
33+
* is finished, thus improving UI re-render performance.
34+
*
35+
* Watches for actions with the `action.meta[SHOULD_AUTOBATCH]` attribute.
36+
* This can be added to `action.meta` manually, or by using the
37+
* `prepareAutoBatched` helper.
38+
*
39+
*/
40+
export const autoBatchEnhancer =
41+
(): StoreEnhancer =>
42+
(next) =>
43+
(...args) => {
44+
const store = next(...args)
45+
46+
let notifying = true
47+
let shouldNotifyAtEndOfTick = false
48+
let notificationQueued = false
49+
50+
const listeners = new Set<() => void>()
51+
52+
const notifyListeners = () => {
53+
// We're running at the end of the event loop tick.
54+
// Run the real listener callbacks to actually update the UI.
55+
notificationQueued = false
56+
if (shouldNotifyAtEndOfTick) {
57+
shouldNotifyAtEndOfTick = false
58+
listeners.forEach((l) => l())
59+
}
60+
}
61+
62+
return Object.assign({}, store, {
63+
// Override the base `store.subscribe` method to keep original listeners
64+
// from running if we're delaying notifications
65+
subscribe(listener: () => void) {
66+
// Each wrapped listener will only call the real listener if
67+
// the `notifying` flag is currently active when it's called.
68+
// This lets the base store work as normal, while the actual UI
69+
// update becomes controlled by this enhancer.
70+
const wrappedListener: typeof listener = () => notifying && listener()
71+
const unsubscribe = store.subscribe(wrappedListener)
72+
listeners.add(listener)
73+
return () => {
74+
unsubscribe()
75+
listeners.delete(listener)
76+
}
77+
},
78+
// Override the base `store.dispatch` method so that we can check actions
79+
// for the `shouldAutoBatch` flag and determine if batching is active
80+
dispatch(action: any) {
81+
try {
82+
// If the action does _not_ have the `shouldAutoBatch` flag,
83+
// we resume/continue normal notify-after-each-dispatch behavior
84+
notifying = !action?.meta?.[SHOULD_AUTOBATCH]
85+
// If a `notifyListeners` microtask was queued, you can't cancel it.
86+
// Instead, we set a flag so that it's a no-op when it does run
87+
shouldNotifyAtEndOfTick = !notifying
88+
if (shouldNotifyAtEndOfTick) {
89+
// We've seen at least 1 action with `SHOULD_AUTOBATCH`. Try to queue
90+
// a microtask to notify listeners at the end of the event loop tick.
91+
// Make sure we only enqueue this _once_ per tick.
92+
if (!notificationQueued) {
93+
notificationQueued = true
94+
queueMicrotaskShim(notifyListeners)
95+
}
96+
}
97+
// Go ahead and process the action as usual, including reducers.
98+
// If normal notification behavior is enabled, the store will notify
99+
// all of its own listeners, and the wrapper callbacks above will
100+
// see `notifying` is true and pass on to the real listener callbacks.
101+
// If we're "batching" behavior, then the wrapped callbacks will
102+
// bail out, causing the base store notification behavior to be no-ops.
103+
return store.dispatch(action)
104+
} finally {
105+
// Assume we're back to normal behavior after each action
106+
notifying = true
107+
}
108+
},
109+
})
110+
}

packages/toolkit/src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,3 +184,9 @@ export {
184184
clearAllListeners,
185185
TaskAbortError,
186186
} from './listenerMiddleware/index'
187+
188+
export {
189+
SHOULD_AUTOBATCH,
190+
prepareAutoBatched,
191+
autoBatchEnhancer,
192+
} from './autoBatchEnhancer'
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { configureStore } from '../configureStore'
2+
import { createSlice } from '../createSlice'
3+
import { autoBatchEnhancer, prepareAutoBatched } from '../autoBatchEnhancer'
4+
import { delay } from '../utils'
5+
6+
interface CounterState {
7+
value: number
8+
}
9+
10+
const counterSlice = createSlice({
11+
name: 'counter',
12+
initialState: { value: 0 } as CounterState,
13+
reducers: {
14+
incrementBatched: {
15+
// Batched, low-priority
16+
reducer(state) {
17+
state.value += 1
18+
},
19+
prepare: prepareAutoBatched<void>(),
20+
},
21+
// Not batched, normal priority
22+
decrementUnbatched(state) {
23+
state.value -= 1
24+
},
25+
},
26+
})
27+
const { incrementBatched, decrementUnbatched } = counterSlice.actions
28+
29+
const makeStore = () => {
30+
return configureStore({
31+
reducer: counterSlice.reducer,
32+
enhancers: (existingEnhancers) => {
33+
return existingEnhancers.concat(autoBatchEnhancer())
34+
},
35+
})
36+
}
37+
38+
let store: ReturnType<typeof makeStore>
39+
40+
let subscriptionNotifications = 0
41+
42+
beforeEach(() => {
43+
subscriptionNotifications = 0
44+
store = makeStore()
45+
46+
store.subscribe(() => {
47+
subscriptionNotifications++
48+
})
49+
})
50+
51+
describe('autoBatchEnhancer', () => {
52+
test('Does not alter normal subscription notification behavior', async () => {
53+
store.dispatch(decrementUnbatched())
54+
expect(subscriptionNotifications).toBe(1)
55+
store.dispatch(decrementUnbatched())
56+
expect(subscriptionNotifications).toBe(2)
57+
store.dispatch(decrementUnbatched())
58+
expect(subscriptionNotifications).toBe(3)
59+
store.dispatch(decrementUnbatched())
60+
61+
await delay(5)
62+
63+
expect(subscriptionNotifications).toBe(4)
64+
})
65+
66+
test('Only notifies once if several batched actions are dispatched in a row', async () => {
67+
store.dispatch(incrementBatched())
68+
expect(subscriptionNotifications).toBe(0)
69+
store.dispatch(incrementBatched())
70+
expect(subscriptionNotifications).toBe(0)
71+
store.dispatch(incrementBatched())
72+
expect(subscriptionNotifications).toBe(0)
73+
store.dispatch(incrementBatched())
74+
75+
await delay(5)
76+
77+
expect(subscriptionNotifications).toBe(1)
78+
})
79+
80+
test('Notifies immediately if a non-batched action is dispatched', async () => {
81+
store.dispatch(incrementBatched())
82+
expect(subscriptionNotifications).toBe(0)
83+
store.dispatch(incrementBatched())
84+
expect(subscriptionNotifications).toBe(0)
85+
store.dispatch(decrementUnbatched())
86+
expect(subscriptionNotifications).toBe(1)
87+
store.dispatch(incrementBatched())
88+
89+
await delay(5)
90+
91+
expect(subscriptionNotifications).toBe(2)
92+
})
93+
94+
test('Does not notify at end of tick if last action was normal priority', async () => {
95+
store.dispatch(incrementBatched())
96+
expect(subscriptionNotifications).toBe(0)
97+
store.dispatch(incrementBatched())
98+
expect(subscriptionNotifications).toBe(0)
99+
store.dispatch(decrementUnbatched())
100+
expect(subscriptionNotifications).toBe(1)
101+
store.dispatch(incrementBatched())
102+
store.dispatch(decrementUnbatched())
103+
expect(subscriptionNotifications).toBe(2)
104+
store.dispatch(decrementUnbatched())
105+
expect(subscriptionNotifications).toBe(3)
106+
107+
await delay(5)
108+
109+
expect(subscriptionNotifications).toBe(3)
110+
})
111+
})

0 commit comments

Comments
 (0)