Skip to content

Commit fdc1c50

Browse files
authored
Merge pull request #2857 from reduxjs/feature/autobatch-timing
2 parents b12b22c + f178b94 commit fdc1c50

File tree

4 files changed

+84
-23
lines changed

4 files changed

+84
-23
lines changed

docs/api/autoBatchEnhancer.mdx

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -65,17 +65,30 @@ const store = configureStore({
6565

6666
```ts title="autoBatchEnhancer signature" no-transpile
6767
export type SHOULD_AUTOBATCH = string
68-
export type autoBatchEnhancer = () => StoreEnhancer
68+
type AutoBatchOptions =
69+
| { type: 'tick' }
70+
| { type: 'timer'; timeout: number }
71+
| { type: 'raf' }
72+
| { type: 'callback'; queueNotification: (notify: () => void) => void }
73+
74+
export type autoBatchEnhancer = (options?: AutoBatchOptions) => StoreEnhancer
6975
```
7076
7177
Creates a new instance of the autobatch store enhancer.
7278
73-
Any action that is tagged with `action.meta[SHOULD_AUTOBATCH] = true` will be treated as "low-priority", and the enhancer will delay notifying subscribers until either:
79+
Any action that is tagged with `action.meta[SHOULD_AUTOBATCH] = true` will be treated as "low-priority", and a notification callback will be queued. The enhancer will delay notifying subscribers until either:
7480
75-
- The end of the current event loop tick happens, and a queued microtask runs the notifications
81+
- The queued callback runs and triggers the notifications
7682
- A "normal-priority" action (any action _without_ `action.meta[SHOULD_AUTOBATCH] = true`) is dispatched in the same tick
7783
78-
This method currently does not accept any options. We may consider allowing customization of the delay behavior in the future.
84+
`autoBatchEnhancer` accepts options to configure how the notification callback is queued:
85+
86+
- `{type: 'tick'}: queues using `queueMicrotask` (default)
87+
- `{type: 'timer, timeout: number}`: queues using `setTimeout`
88+
- `{type: 'raf'}`: queues using `requestAnimationFrame`
89+
- `{type: 'callback', queueNotification: (notify: () => void) => void}: lets you provide your own callback
90+
91+
The default behavior is to queue the notifications at the end of the current event loop using `queueMicrotask`.
7992
8093
The `SHOULD_AUTOBATCH` value is meant to be opaque - it's currently a string for simplicity, but could be a `Symbol` in the future.
8194
@@ -117,7 +130,7 @@ This enhancer is a variation of the "debounce" approach, but with a twist.
117130
118131
Instead of _just_ debouncing _all_ subscriber notifications, it watches for any actions with a specific `action.meta[SHOULD_AUTOBATCH]: true` field attached.
119132
120-
When it sees an action with that field, it queues a microtask. The reducer is updated immediately, but the enhancer does _not_ notify subscribers right way. If other actions with the same field are dispatched in succession, the enhancer will continue to _not_ notify subscribers. Then, when the queued microtask runs at the end of the event loop tick, it finally notifies all subscribers, similar to how React batches re-renders.
133+
When it sees an action with that field, it queues a callback. The reducer is updated immediately, but the enhancer does _not_ notify subscribers right way. If other actions with the same field are dispatched in succession, the enhancer will continue to _not_ notify subscribers. Then, when the queued callback runs, it finally notifies all subscribers, similar to how React batches re-renders.
121134
122135
The additional twist is also inspired by React's separation of updates into "low-priority" and "immediate" behavior (such as a render queued by an AJAX request vs a render queued by a user input that should be handled synchronously).
123136

packages/toolkit/src/autoBatchEnhancer.ts

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,21 @@ const queueMicrotaskShim =
2323
}, 0)
2424
)
2525

26+
export type AutoBatchOptions =
27+
| { type: 'tick' }
28+
| { type: 'timer'; timeout: number }
29+
| { type: 'raf' }
30+
| { type: 'callback'; queueNotification: (notify: () => void) => void }
31+
32+
const createQueueWithTimer = (timeout: number) => {
33+
return (notify: () => void) => {
34+
setTimeout(notify, timeout)
35+
}
36+
}
37+
2638
/**
2739
* 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
40+
* notifying subscribers until either the queued callback executes or the
2941
* next "standard-priority" action is dispatched.
3042
*
3143
* This allows dispatching multiple "low-priority" actions in a row with only
@@ -36,9 +48,17 @@ const queueMicrotaskShim =
3648
* This can be added to `action.meta` manually, or by using the
3749
* `prepareAutoBatched` helper.
3850
*
51+
* By default, it will queue a notification for the end of the event loop tick.
52+
* However, you can pass several other options to configure the behavior:
53+
* - `{type: 'tick'}: queues using `queueMicrotask` (default)
54+
* - `{type: 'timer, timeout: number}`: queues using `setTimeout`
55+
* - `{type: 'raf'}`: queues using `requestAnimationFrame`
56+
* - `{type: 'callback', queueNotification: (notify: () => void) => void}: lets you provide your own callback
57+
*
58+
*
3959
*/
4060
export const autoBatchEnhancer =
41-
(): StoreEnhancer =>
61+
(options: AutoBatchOptions = { type: 'tick' }): StoreEnhancer =>
4262
(next) =>
4363
(...args) => {
4464
const store = next(...args)
@@ -49,6 +69,15 @@ export const autoBatchEnhancer =
4969

5070
const listeners = new Set<() => void>()
5171

72+
const queueCallback =
73+
options.type === 'tick'
74+
? queueMicrotaskShim
75+
: options.type === 'raf'
76+
? requestAnimationFrame
77+
: options.type === 'callback'
78+
? options.queueNotification
79+
: createQueueWithTimer(options.timeout)
80+
5281
const notifyListeners = () => {
5382
// We're running at the end of the event loop tick.
5483
// Run the real listener callbacks to actually update the UI.
@@ -91,7 +120,7 @@ export const autoBatchEnhancer =
91120
// Make sure we only enqueue this _once_ per tick.
92121
if (!notificationQueued) {
93122
notificationQueued = true
94-
queueMicrotaskShim(notifyListeners)
123+
queueCallback(notifyListeners)
95124
}
96125
}
97126
// Go ahead and process the action as usual, including reducers.

packages/toolkit/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,4 +189,5 @@ export {
189189
SHOULD_AUTOBATCH,
190190
prepareAutoBatched,
191191
autoBatchEnhancer,
192+
AutoBatchOptions,
192193
} from './autoBatchEnhancer'

packages/toolkit/src/tests/autoBatchEnhancer.test.ts

Lines changed: 33 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import { configureStore } from '../configureStore'
22
import { createSlice } from '../createSlice'
3-
import { autoBatchEnhancer, prepareAutoBatched } from '../autoBatchEnhancer'
3+
import {
4+
autoBatchEnhancer,
5+
prepareAutoBatched,
6+
AutoBatchOptions,
7+
} from '../autoBatchEnhancer'
48
import { delay } from '../utils'
9+
import { debounce } from 'lodash'
510

611
interface CounterState {
712
value: number
@@ -26,11 +31,11 @@ const counterSlice = createSlice({
2631
})
2732
const { incrementBatched, decrementUnbatched } = counterSlice.actions
2833

29-
const makeStore = () => {
34+
const makeStore = (autoBatchOptions?: AutoBatchOptions) => {
3035
return configureStore({
3136
reducer: counterSlice.reducer,
3237
enhancers: (existingEnhancers) => {
33-
return existingEnhancers.concat(autoBatchEnhancer())
38+
return existingEnhancers.concat(autoBatchEnhancer(autoBatchOptions))
3439
},
3540
})
3641
}
@@ -39,16 +44,29 @@ let store: ReturnType<typeof makeStore>
3944

4045
let subscriptionNotifications = 0
4146

42-
beforeEach(() => {
43-
subscriptionNotifications = 0
44-
store = makeStore()
47+
const cases: AutoBatchOptions[] = [
48+
{ type: 'tick' },
49+
{ type: 'raf' },
50+
{ type: 'timer', timeout: 0 },
51+
{ type: 'timer', timeout: 10 },
52+
{ type: 'timer', timeout: 20 },
53+
{
54+
type: 'callback',
55+
queueNotification: debounce((notify: () => void) => {
56+
notify()
57+
}, 5),
58+
},
59+
]
4560

46-
store.subscribe(() => {
47-
subscriptionNotifications++
48-
})
49-
})
61+
describe.each(cases)('autoBatchEnhancer: %j', (autoBatchOptions) => {
62+
beforeEach(() => {
63+
subscriptionNotifications = 0
64+
store = makeStore(autoBatchOptions)
5065

51-
describe('autoBatchEnhancer', () => {
66+
store.subscribe(() => {
67+
subscriptionNotifications++
68+
})
69+
})
5270
test('Does not alter normal subscription notification behavior', async () => {
5371
store.dispatch(decrementUnbatched())
5472
expect(subscriptionNotifications).toBe(1)
@@ -58,7 +76,7 @@ describe('autoBatchEnhancer', () => {
5876
expect(subscriptionNotifications).toBe(3)
5977
store.dispatch(decrementUnbatched())
6078

61-
await delay(5)
79+
await delay(25)
6280

6381
expect(subscriptionNotifications).toBe(4)
6482
})
@@ -72,7 +90,7 @@ describe('autoBatchEnhancer', () => {
7290
expect(subscriptionNotifications).toBe(0)
7391
store.dispatch(incrementBatched())
7492

75-
await delay(5)
93+
await delay(25)
7694

7795
expect(subscriptionNotifications).toBe(1)
7896
})
@@ -86,7 +104,7 @@ describe('autoBatchEnhancer', () => {
86104
expect(subscriptionNotifications).toBe(1)
87105
store.dispatch(incrementBatched())
88106

89-
await delay(5)
107+
await delay(25)
90108

91109
expect(subscriptionNotifications).toBe(2)
92110
})
@@ -104,7 +122,7 @@ describe('autoBatchEnhancer', () => {
104122
store.dispatch(decrementUnbatched())
105123
expect(subscriptionNotifications).toBe(3)
106124

107-
await delay(5)
125+
await delay(25)
108126

109127
expect(subscriptionNotifications).toBe(3)
110128
})

0 commit comments

Comments
 (0)