Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Make autobatching notification queueing configurable #2857

Merged
merged 1 commit into from
Nov 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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