Skip to content

Commit

Permalink
Make autobatching notification queueing configurable
Browse files Browse the repository at this point in the history
  • Loading branch information
markerikson committed Nov 2, 2022
1 parent 38d65c0 commit f178b94
Show file tree
Hide file tree
Showing 4 changed files with 84 additions and 23 deletions.
23 changes: 18 additions & 5 deletions docs/api/autoBatchEnhancer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -65,17 +65,30 @@ const store = configureStore({

```ts title="autoBatchEnhancer signature" no-transpile
export type SHOULD_AUTOBATCH = string
export type autoBatchEnhancer = () => StoreEnhancer
type AutoBatchOptions =
| { type: 'tick' }
| { type: 'timer'; timeout: number }
| { type: 'raf' }
| { type: 'callback'; queueNotification: (notify: () => void) => void }

export type autoBatchEnhancer = (options?: AutoBatchOptions) => StoreEnhancer
```
Creates a new instance of the autobatch store enhancer.
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:
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:
- The end of the current event loop tick happens, and a queued microtask runs the notifications
- The queued callback runs and triggers the notifications
- A "normal-priority" action (any action _without_ `action.meta[SHOULD_AUTOBATCH] = true`) is dispatched in the same tick
This method currently does not accept any options. We may consider allowing customization of the delay behavior in the future.
`autoBatchEnhancer` accepts options to configure how the notification callback is queued:
- `{type: 'tick'}: queues using `queueMicrotask` (default)
- `{type: 'timer, timeout: number}`: queues using `setTimeout`
- `{type: 'raf'}`: queues using `requestAnimationFrame`
- `{type: 'callback', queueNotification: (notify: () => void) => void}: lets you provide your own callback
The default behavior is to queue the notifications at the end of the current event loop using `queueMicrotask`.
The `SHOULD_AUTOBATCH` value is meant to be opaque - it's currently a string for simplicity, but could be a `Symbol` in the future.
Expand Down Expand Up @@ -117,7 +130,7 @@ This enhancer is a variation of the "debounce" approach, but with a twist.
Instead of _just_ debouncing _all_ subscriber notifications, it watches for any actions with a specific `action.meta[SHOULD_AUTOBATCH]: true` field attached.
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.
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.
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).
Expand Down
35 changes: 32 additions & 3 deletions packages/toolkit/src/autoBatchEnhancer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,9 +23,21 @@ const queueMicrotaskShim =
}, 0)
)

export type AutoBatchOptions =
| { type: 'tick' }
| { type: 'timer'; timeout: number }
| { type: 'raf' }
| { type: 'callback'; queueNotification: (notify: () => void) => void }

const createQueueWithTimer = (timeout: number) => {
return (notify: () => void) => {
setTimeout(notify, timeout)
}
}

/**
* A Redux store enhancer that watches for "low-priority" actions, and delays
* notifying subscribers until either the end of the event loop tick or the
* notifying subscribers until either the queued callback executes or the
* next "standard-priority" action is dispatched.
*
* This allows dispatching multiple "low-priority" actions in a row with only
Expand All @@ -36,9 +48,17 @@ const queueMicrotaskShim =
* This can be added to `action.meta` manually, or by using the
* `prepareAutoBatched` helper.
*
* By default, it will queue a notification for the end of the event loop tick.
* However, you can pass several other options to configure the behavior:
* - `{type: 'tick'}: queues using `queueMicrotask` (default)
* - `{type: 'timer, timeout: number}`: queues using `setTimeout`
* - `{type: 'raf'}`: queues using `requestAnimationFrame`
* - `{type: 'callback', queueNotification: (notify: () => void) => void}: lets you provide your own callback
*
*
*/
export const autoBatchEnhancer =
(): StoreEnhancer =>
(options: AutoBatchOptions = { type: 'tick' }): StoreEnhancer =>
(next) =>
(...args) => {
const store = next(...args)
Expand All @@ -49,6 +69,15 @@ export const autoBatchEnhancer =

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

const queueCallback =
options.type === 'tick'
? queueMicrotaskShim
: options.type === 'raf'
? requestAnimationFrame
: options.type === 'callback'
? options.queueNotification
: createQueueWithTimer(options.timeout)

const notifyListeners = () => {
// We're running at the end of the event loop tick.
// Run the real listener callbacks to actually update the UI.
Expand Down Expand Up @@ -91,7 +120,7 @@ export const autoBatchEnhancer =
// Make sure we only enqueue this _once_ per tick.
if (!notificationQueued) {
notificationQueued = true
queueMicrotaskShim(notifyListeners)
queueCallback(notifyListeners)
}
}
// Go ahead and process the action as usual, including reducers.
Expand Down
1 change: 1 addition & 0 deletions packages/toolkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,4 +189,5 @@ export {
SHOULD_AUTOBATCH,
prepareAutoBatched,
autoBatchEnhancer,
AutoBatchOptions,
} from './autoBatchEnhancer'
48 changes: 33 additions & 15 deletions packages/toolkit/src/tests/autoBatchEnhancer.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { configureStore } from '../configureStore'
import { createSlice } from '../createSlice'
import { autoBatchEnhancer, prepareAutoBatched } from '../autoBatchEnhancer'
import {
autoBatchEnhancer,
prepareAutoBatched,
AutoBatchOptions,
} from '../autoBatchEnhancer'
import { delay } from '../utils'
import { debounce } from 'lodash'

interface CounterState {
value: number
Expand All @@ -26,11 +31,11 @@ const counterSlice = createSlice({
})
const { incrementBatched, decrementUnbatched } = counterSlice.actions

const makeStore = () => {
const makeStore = (autoBatchOptions?: AutoBatchOptions) => {
return configureStore({
reducer: counterSlice.reducer,
enhancers: (existingEnhancers) => {
return existingEnhancers.concat(autoBatchEnhancer())
return existingEnhancers.concat(autoBatchEnhancer(autoBatchOptions))
},
})
}
Expand All @@ -39,16 +44,29 @@ let store: ReturnType<typeof makeStore>

let subscriptionNotifications = 0

beforeEach(() => {
subscriptionNotifications = 0
store = makeStore()
const cases: AutoBatchOptions[] = [
{ type: 'tick' },
{ type: 'raf' },
{ type: 'timer', timeout: 0 },
{ type: 'timer', timeout: 10 },
{ type: 'timer', timeout: 20 },
{
type: 'callback',
queueNotification: debounce((notify: () => void) => {
notify()
}, 5),
},
]

store.subscribe(() => {
subscriptionNotifications++
})
})
describe.each(cases)('autoBatchEnhancer: %j', (autoBatchOptions) => {
beforeEach(() => {
subscriptionNotifications = 0
store = makeStore(autoBatchOptions)

describe('autoBatchEnhancer', () => {
store.subscribe(() => {
subscriptionNotifications++
})
})
test('Does not alter normal subscription notification behavior', async () => {
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(1)
Expand All @@ -58,7 +76,7 @@ describe('autoBatchEnhancer', () => {
expect(subscriptionNotifications).toBe(3)
store.dispatch(decrementUnbatched())

await delay(5)
await delay(25)

expect(subscriptionNotifications).toBe(4)
})
Expand All @@ -72,7 +90,7 @@ describe('autoBatchEnhancer', () => {
expect(subscriptionNotifications).toBe(0)
store.dispatch(incrementBatched())

await delay(5)
await delay(25)

expect(subscriptionNotifications).toBe(1)
})
Expand All @@ -86,7 +104,7 @@ describe('autoBatchEnhancer', () => {
expect(subscriptionNotifications).toBe(1)
store.dispatch(incrementBatched())

await delay(5)
await delay(25)

expect(subscriptionNotifications).toBe(2)
})
Expand All @@ -104,7 +122,7 @@ describe('autoBatchEnhancer', () => {
store.dispatch(decrementUnbatched())
expect(subscriptionNotifications).toBe(3)

await delay(5)
await delay(25)

expect(subscriptionNotifications).toBe(3)
})
Expand Down

0 comments on commit f178b94

Please sign in to comment.