-
Notifications
You must be signed in to change notification settings - Fork 27.2k
/
action-queue.ts
193 lines (171 loc) · 5.48 KB
/
action-queue.ts
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
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
import {
type AppRouterState,
type ReducerActions,
type ReducerState,
ACTION_REFRESH,
ACTION_SERVER_ACTION,
ACTION_NAVIGATE,
ACTION_RESTORE,
} from '../../../client/components/router-reducer/router-reducer-types'
import { reducer } from '../../../client/components/router-reducer/router-reducer'
import { startTransition } from 'react'
import { isThenable } from '../is-thenable'
export type DispatchStatePromise = React.Dispatch<ReducerState>
export type AppRouterActionQueue = {
state: AppRouterState
dispatch: (payload: ReducerActions, setState: DispatchStatePromise) => void
action: (state: AppRouterState, action: ReducerActions) => ReducerState
pending: ActionQueueNode | null
needsRefresh?: boolean
last: ActionQueueNode | null
}
export type ActionQueueNode = {
payload: ReducerActions
next: ActionQueueNode | null
resolve: (value: ReducerState) => void
reject: (err: Error) => void
discarded?: boolean
}
function runRemainingActions(
actionQueue: AppRouterActionQueue,
setState: DispatchStatePromise
) {
if (actionQueue.pending !== null) {
actionQueue.pending = actionQueue.pending.next
if (actionQueue.pending !== null) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
runAction({
actionQueue,
action: actionQueue.pending,
setState,
})
} else {
// No more actions are pending, check if a refresh is needed
if (actionQueue.needsRefresh) {
actionQueue.needsRefresh = false
actionQueue.dispatch(
{
type: ACTION_REFRESH,
origin: window.location.origin,
},
setState
)
}
}
}
}
async function runAction({
actionQueue,
action,
setState,
}: {
actionQueue: AppRouterActionQueue
action: ActionQueueNode
setState: DispatchStatePromise
}) {
const prevState = actionQueue.state
actionQueue.pending = action
const payload = action.payload
const actionResult = actionQueue.action(prevState, payload)
function handleResult(nextState: AppRouterState) {
// if we discarded this action, the state should also be discarded
if (action.discarded) {
return
}
actionQueue.state = nextState
runRemainingActions(actionQueue, setState)
action.resolve(nextState)
}
// if the action is a promise, set up a callback to resolve it
if (isThenable(actionResult)) {
actionResult.then(handleResult, (err) => {
runRemainingActions(actionQueue, setState)
action.reject(err)
})
} else {
handleResult(actionResult)
}
}
function dispatchAction(
actionQueue: AppRouterActionQueue,
payload: ReducerActions,
setState: DispatchStatePromise
) {
let resolvers: {
resolve: (value: ReducerState) => void
reject: (reason: any) => void
} = { resolve: setState, reject: () => {} }
// most of the action types are async with the exception of restore
// it's important that restore is handled quickly since it's fired on the popstate event
// and we don't want to add any delay on a back/forward nav
// this only creates a promise for the async actions
if (payload.type !== ACTION_RESTORE) {
// Create the promise and assign the resolvers to the object.
const deferredPromise = new Promise<AppRouterState>((resolve, reject) => {
resolvers = { resolve, reject }
})
startTransition(() => {
// we immediately notify React of the pending promise -- the resolver is attached to the action node
// and will be called when the associated action promise resolves
setState(deferredPromise)
})
}
const newAction: ActionQueueNode = {
payload,
next: null,
resolve: resolvers.resolve,
reject: resolvers.reject,
}
// Check if the queue is empty
if (actionQueue.pending === null) {
// The queue is empty, so add the action and start it immediately
// Mark this action as the last in the queue
actionQueue.last = newAction
runAction({
actionQueue,
action: newAction,
setState,
})
} else if (
payload.type === ACTION_NAVIGATE ||
payload.type === ACTION_RESTORE
) {
// Navigations (including back/forward) take priority over any pending actions.
// Mark the pending action as discarded (so the state is never applied) and start the navigation action immediately.
actionQueue.pending.discarded = true
// Mark this action as the last in the queue
actionQueue.last = newAction
// if the pending action was a server action, mark the queue as needing a refresh once events are processed
if (actionQueue.pending.payload.type === ACTION_SERVER_ACTION) {
actionQueue.needsRefresh = true
}
runAction({
actionQueue,
action: newAction,
setState,
})
} else {
// The queue is not empty, so add the action to the end of the queue
// It will be started by runRemainingActions after the previous action finishes
if (actionQueue.last !== null) {
actionQueue.last.next = newAction
}
actionQueue.last = newAction
}
}
export function createMutableActionQueue(
initialState: AppRouterState
): AppRouterActionQueue {
const actionQueue: AppRouterActionQueue = {
state: initialState,
dispatch: (payload: ReducerActions, setState: DispatchStatePromise) =>
dispatchAction(actionQueue, payload, setState),
action: async (state: AppRouterState, action: ReducerActions) => {
const result = reducer(state, action)
return result
},
pending: null,
last: null,
}
return actionQueue
}