File size: 5,215 Bytes
c2b7eb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
import type { StoreEnhancer } from 'redux'

export const SHOULD_AUTOBATCH = 'RTK_autoBatch'

export const prepareAutoBatched =
  <T>() =>
  (payload: T): { payload: T; meta: unknown } => ({
    payload,
    meta: { [SHOULD_AUTOBATCH]: true },
  })

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

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

/**
 * A Redux store enhancer that watches for "low-priority" actions, and delays
 * 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
 * a single subscriber notification to the UI after the sequence of actions
 * is finished, thus improving UI re-render performance.
 *
 * Watches for actions with the `action.meta[SHOULD_AUTOBATCH]` attribute.
 * 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`
 * - `{type: 'timer', timeout: number}`: queues using `setTimeout`
 * - `{type: 'raf'}`: queues using `requestAnimationFrame` (default)
 * - `{type: 'callback', queueNotification: (notify: () => void) => void}`: lets you provide your own callback
 *
 *
 */
export const autoBatchEnhancer =
  (options: AutoBatchOptions = { type: 'raf' }): StoreEnhancer =>
  (next) =>
  (...args) => {
    const store = next(...args)

    let notifying = true
    let shouldNotifyAtEndOfTick = false
    let notificationQueued = false

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

    const queueCallback =
      options.type === 'tick'
        ? queueMicrotask
        : options.type === 'raf'
          ? // requestAnimationFrame won't exist in SSR environments. Fall back to a vague approximation just to keep from erroring.
            typeof window !== 'undefined' && window.requestAnimationFrame
            ? window.requestAnimationFrame
            : createQueueWithTimer(10)
          : 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.
      notificationQueued = false
      if (shouldNotifyAtEndOfTick) {
        shouldNotifyAtEndOfTick = false
        listeners.forEach((l) => l())
      }
    }

    return Object.assign({}, store, {
      // Override the base `store.subscribe` method to keep original listeners
      // from running if we're delaying notifications
      subscribe(listener: () => void) {
        // Each wrapped listener will only call the real listener if
        // the `notifying` flag is currently active when it's called.
        // This lets the base store work as normal, while the actual UI
        // update becomes controlled by this enhancer.
        const wrappedListener: typeof listener = () => notifying && listener()
        const unsubscribe = store.subscribe(wrappedListener)
        listeners.add(listener)
        return () => {
          unsubscribe()
          listeners.delete(listener)
        }
      },
      // Override the base `store.dispatch` method so that we can check actions
      // for the `shouldAutoBatch` flag and determine if batching is active
      dispatch(action: any) {
        try {
          // If the action does _not_ have the `shouldAutoBatch` flag,
          // we resume/continue normal notify-after-each-dispatch behavior
          notifying = !action?.meta?.[SHOULD_AUTOBATCH]
          // If a `notifyListeners` microtask was queued, you can't cancel it.
          // Instead, we set a flag so that it's a no-op when it does run
          shouldNotifyAtEndOfTick = !notifying
          if (shouldNotifyAtEndOfTick) {
            // We've seen at least 1 action with `SHOULD_AUTOBATCH`. Try to queue
            // a microtask to notify listeners at the end of the event loop tick.
            // Make sure we only enqueue this _once_ per tick.
            if (!notificationQueued) {
              notificationQueued = true
              queueCallback(notifyListeners)
            }
          }
          // Go ahead and process the action as usual, including reducers.
          // If normal notification behavior is enabled, the store will notify
          // all of its own listeners, and the wrapper callbacks above will
          // see `notifying` is true and pass on to the real listener callbacks.
          // If we're "batching" behavior, then the wrapped callbacks will
          // bail out, causing the base store notification behavior to be no-ops.
          return store.dispatch(action)
        } finally {
          // Assume we're back to normal behavior after each action
          notifying = true
        }
      },
    })
  }