| 1 | import { noop } from '@internal/listenerMiddleware/utils'
|
|---|
| 2 | import { delay, promiseWithResolvers } from '@internal/utils'
|
|---|
| 3 | import type { CreateAsyncThunkFunction, UnknownAction } from '@reduxjs/toolkit'
|
|---|
| 4 | import {
|
|---|
| 5 | configureStore,
|
|---|
| 6 | createAsyncThunk,
|
|---|
| 7 | createReducer,
|
|---|
| 8 | miniSerializeError,
|
|---|
| 9 | unwrapResult,
|
|---|
| 10 | } from '@reduxjs/toolkit'
|
|---|
| 11 |
|
|---|
| 12 | declare global {
|
|---|
| 13 | interface Window {
|
|---|
| 14 | AbortController: AbortController
|
|---|
| 15 | }
|
|---|
| 16 | }
|
|---|
| 17 |
|
|---|
| 18 | describe('createAsyncThunk', () => {
|
|---|
| 19 | it('creates the action types', () => {
|
|---|
| 20 | const thunkActionCreator = createAsyncThunk('testType', async () => 42)
|
|---|
| 21 |
|
|---|
| 22 | expect(thunkActionCreator.fulfilled.type).toBe('testType/fulfilled')
|
|---|
| 23 | expect(thunkActionCreator.pending.type).toBe('testType/pending')
|
|---|
| 24 | expect(thunkActionCreator.rejected.type).toBe('testType/rejected')
|
|---|
| 25 | })
|
|---|
| 26 |
|
|---|
| 27 | it('exposes the typePrefix it was created with', () => {
|
|---|
| 28 | const thunkActionCreator = createAsyncThunk('testType', async () => 42)
|
|---|
| 29 |
|
|---|
| 30 | expect(thunkActionCreator.typePrefix).toBe('testType')
|
|---|
| 31 | })
|
|---|
| 32 |
|
|---|
| 33 | it('includes a settled matcher', () => {
|
|---|
| 34 | const thunkActionCreator = createAsyncThunk('testType', async () => 42)
|
|---|
| 35 | expect(thunkActionCreator.settled).toEqual(expect.any(Function))
|
|---|
| 36 | expect(thunkActionCreator.settled(thunkActionCreator.pending(''))).toBe(
|
|---|
| 37 | false,
|
|---|
| 38 | )
|
|---|
| 39 | expect(
|
|---|
| 40 | thunkActionCreator.settled(thunkActionCreator.rejected(null, '')),
|
|---|
| 41 | ).toBe(true)
|
|---|
| 42 | expect(
|
|---|
| 43 | thunkActionCreator.settled(thunkActionCreator.fulfilled(42, '')),
|
|---|
| 44 | ).toBe(true)
|
|---|
| 45 | })
|
|---|
| 46 |
|
|---|
| 47 | it('works without passing arguments to the payload creator', async () => {
|
|---|
| 48 | const thunkActionCreator = createAsyncThunk('testType', async () => 42)
|
|---|
| 49 |
|
|---|
| 50 | let timesReducerCalled = 0
|
|---|
| 51 |
|
|---|
| 52 | const reducer = () => {
|
|---|
| 53 | timesReducerCalled++
|
|---|
| 54 | }
|
|---|
| 55 |
|
|---|
| 56 | const store = configureStore({
|
|---|
| 57 | reducer,
|
|---|
| 58 | })
|
|---|
| 59 |
|
|---|
| 60 | // reset from however many times the store called it
|
|---|
| 61 | timesReducerCalled = 0
|
|---|
| 62 |
|
|---|
| 63 | await store.dispatch(thunkActionCreator())
|
|---|
| 64 |
|
|---|
| 65 | expect(timesReducerCalled).toBe(2)
|
|---|
| 66 | })
|
|---|
| 67 |
|
|---|
| 68 | it('accepts arguments and dispatches the actions on resolve', async () => {
|
|---|
| 69 | const dispatch = vi.fn()
|
|---|
| 70 |
|
|---|
| 71 | let passedArg: any
|
|---|
| 72 |
|
|---|
| 73 | const result = 42
|
|---|
| 74 | const args = 123
|
|---|
| 75 | let generatedRequestId = ''
|
|---|
| 76 |
|
|---|
| 77 | const thunkActionCreator = createAsyncThunk(
|
|---|
| 78 | 'testType',
|
|---|
| 79 | async (arg: number, { requestId }) => {
|
|---|
| 80 | passedArg = arg
|
|---|
| 81 | generatedRequestId = requestId
|
|---|
| 82 | return result
|
|---|
| 83 | },
|
|---|
| 84 | )
|
|---|
| 85 |
|
|---|
| 86 | const thunkFunction = thunkActionCreator(args)
|
|---|
| 87 |
|
|---|
| 88 | const thunkPromise = thunkFunction(dispatch, () => {}, undefined)
|
|---|
| 89 |
|
|---|
| 90 | expect(thunkPromise.requestId).toBe(generatedRequestId)
|
|---|
| 91 | expect(thunkPromise.arg).toBe(args)
|
|---|
| 92 |
|
|---|
| 93 | await thunkPromise
|
|---|
| 94 |
|
|---|
| 95 | expect(passedArg).toBe(args)
|
|---|
| 96 |
|
|---|
| 97 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 98 | 1,
|
|---|
| 99 | thunkActionCreator.pending(generatedRequestId, args),
|
|---|
| 100 | )
|
|---|
| 101 |
|
|---|
| 102 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 103 | 2,
|
|---|
| 104 | thunkActionCreator.fulfilled(result, generatedRequestId, args),
|
|---|
| 105 | )
|
|---|
| 106 | })
|
|---|
| 107 |
|
|---|
| 108 | it('accepts arguments and dispatches the actions on reject', async () => {
|
|---|
| 109 | const dispatch = vi.fn()
|
|---|
| 110 |
|
|---|
| 111 | const args = 123
|
|---|
| 112 | let generatedRequestId = ''
|
|---|
| 113 |
|
|---|
| 114 | const error = new Error('Panic!')
|
|---|
| 115 |
|
|---|
| 116 | const thunkActionCreator = createAsyncThunk(
|
|---|
| 117 | 'testType',
|
|---|
| 118 | async (args: number, { requestId }) => {
|
|---|
| 119 | generatedRequestId = requestId
|
|---|
| 120 | throw error
|
|---|
| 121 | },
|
|---|
| 122 | )
|
|---|
| 123 |
|
|---|
| 124 | const thunkFunction = thunkActionCreator(args)
|
|---|
| 125 |
|
|---|
| 126 | try {
|
|---|
| 127 | await thunkFunction(dispatch, () => {}, undefined)
|
|---|
| 128 | } catch (e) {}
|
|---|
| 129 |
|
|---|
| 130 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 131 | 1,
|
|---|
| 132 | thunkActionCreator.pending(generatedRequestId, args),
|
|---|
| 133 | )
|
|---|
| 134 |
|
|---|
| 135 | expect(dispatch).toHaveBeenCalledTimes(2)
|
|---|
| 136 |
|
|---|
| 137 | // Have to check the bits of the action separately since the error was processed
|
|---|
| 138 | const errorAction = dispatch.mock.calls[1][0]
|
|---|
| 139 | expect(errorAction.error).toEqual(miniSerializeError(error))
|
|---|
| 140 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
|---|
| 141 | expect(errorAction.meta.arg).toBe(args)
|
|---|
| 142 | })
|
|---|
| 143 |
|
|---|
| 144 | it('dispatches an empty error when throwing a random object without serializedError properties', async () => {
|
|---|
| 145 | const dispatch = vi.fn()
|
|---|
| 146 |
|
|---|
| 147 | const args = 123
|
|---|
| 148 | let generatedRequestId = ''
|
|---|
| 149 |
|
|---|
| 150 | const errorObject = { wny: 'dothis' }
|
|---|
| 151 |
|
|---|
| 152 | const thunkActionCreator = createAsyncThunk(
|
|---|
| 153 | 'testType',
|
|---|
| 154 | async (args: number, { requestId }) => {
|
|---|
| 155 | generatedRequestId = requestId
|
|---|
| 156 | throw errorObject
|
|---|
| 157 | },
|
|---|
| 158 | )
|
|---|
| 159 |
|
|---|
| 160 | const thunkFunction = thunkActionCreator(args)
|
|---|
| 161 |
|
|---|
| 162 | try {
|
|---|
| 163 | await thunkFunction(dispatch, () => {}, undefined)
|
|---|
| 164 | } catch (e) {}
|
|---|
| 165 |
|
|---|
| 166 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 167 | 1,
|
|---|
| 168 | thunkActionCreator.pending(generatedRequestId, args),
|
|---|
| 169 | )
|
|---|
| 170 |
|
|---|
| 171 | expect(dispatch).toHaveBeenCalledTimes(2)
|
|---|
| 172 |
|
|---|
| 173 | const errorAction = dispatch.mock.calls[1][0]
|
|---|
| 174 | expect(errorAction.error).toEqual({})
|
|---|
| 175 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
|---|
| 176 | expect(errorAction.meta.arg).toBe(args)
|
|---|
| 177 | })
|
|---|
| 178 |
|
|---|
| 179 | it('dispatches an action with a formatted error when throwing an object with known error keys', async () => {
|
|---|
| 180 | const dispatch = vi.fn()
|
|---|
| 181 |
|
|---|
| 182 | const args = 123
|
|---|
| 183 | let generatedRequestId = ''
|
|---|
| 184 |
|
|---|
| 185 | const errorObject = {
|
|---|
| 186 | name: 'Custom thrown error',
|
|---|
| 187 | message: 'This is not necessary',
|
|---|
| 188 | code: '400',
|
|---|
| 189 | }
|
|---|
| 190 |
|
|---|
| 191 | const thunkActionCreator = createAsyncThunk(
|
|---|
| 192 | 'testType',
|
|---|
| 193 | async (args: number, { requestId }) => {
|
|---|
| 194 | generatedRequestId = requestId
|
|---|
| 195 | throw errorObject
|
|---|
| 196 | },
|
|---|
| 197 | )
|
|---|
| 198 |
|
|---|
| 199 | const thunkFunction = thunkActionCreator(args)
|
|---|
| 200 |
|
|---|
| 201 | try {
|
|---|
| 202 | await thunkFunction(dispatch, () => {}, undefined)
|
|---|
| 203 | } catch (e) {}
|
|---|
| 204 |
|
|---|
| 205 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 206 | 1,
|
|---|
| 207 | thunkActionCreator.pending(generatedRequestId, args),
|
|---|
| 208 | )
|
|---|
| 209 |
|
|---|
| 210 | expect(dispatch).toHaveBeenCalledTimes(2)
|
|---|
| 211 |
|
|---|
| 212 | // Have to check the bits of the action separately since the error was processed
|
|---|
| 213 | const errorAction = dispatch.mock.calls[1][0]
|
|---|
| 214 | expect(errorAction.error).toEqual(miniSerializeError(errorObject))
|
|---|
| 215 | expect(Object.keys(errorAction.error)).not.toContain('stack')
|
|---|
| 216 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
|---|
| 217 | expect(errorAction.meta.arg).toBe(args)
|
|---|
| 218 | })
|
|---|
| 219 |
|
|---|
| 220 | it('dispatches a rejected action with a customized payload when a user returns rejectWithValue()', async () => {
|
|---|
| 221 | const dispatch = vi.fn()
|
|---|
| 222 |
|
|---|
| 223 | const args = 123
|
|---|
| 224 | let generatedRequestId = ''
|
|---|
| 225 |
|
|---|
| 226 | const errorPayload = {
|
|---|
| 227 | errorMessage:
|
|---|
| 228 | 'I am a fake server-provided 400 payload with validation details',
|
|---|
| 229 | errors: [
|
|---|
| 230 | { field_one: 'Must be a string' },
|
|---|
| 231 | { field_two: 'Must be a number' },
|
|---|
| 232 | ],
|
|---|
| 233 | }
|
|---|
| 234 |
|
|---|
| 235 | const thunkActionCreator = createAsyncThunk(
|
|---|
| 236 | 'testType',
|
|---|
| 237 | async (args: number, { requestId, rejectWithValue }) => {
|
|---|
| 238 | generatedRequestId = requestId
|
|---|
| 239 |
|
|---|
| 240 | return rejectWithValue(errorPayload)
|
|---|
| 241 | },
|
|---|
| 242 | )
|
|---|
| 243 |
|
|---|
| 244 | const thunkFunction = thunkActionCreator(args)
|
|---|
| 245 |
|
|---|
| 246 | try {
|
|---|
| 247 | await thunkFunction(dispatch, () => {}, undefined)
|
|---|
| 248 | } catch (e) {}
|
|---|
| 249 |
|
|---|
| 250 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 251 | 1,
|
|---|
| 252 | thunkActionCreator.pending(generatedRequestId, args),
|
|---|
| 253 | )
|
|---|
| 254 |
|
|---|
| 255 | expect(dispatch).toHaveBeenCalledTimes(2)
|
|---|
| 256 |
|
|---|
| 257 | // Have to check the bits of the action separately since the error was processed
|
|---|
| 258 | const errorAction = dispatch.mock.calls[1][0]
|
|---|
| 259 |
|
|---|
| 260 | expect(errorAction.error.message).toEqual('Rejected')
|
|---|
| 261 | expect(errorAction.payload).toBe(errorPayload)
|
|---|
| 262 | expect(errorAction.meta.arg).toBe(args)
|
|---|
| 263 | })
|
|---|
| 264 |
|
|---|
| 265 | it('dispatches a rejected action with a customized payload when a user throws rejectWithValue()', async () => {
|
|---|
| 266 | const dispatch = vi.fn()
|
|---|
| 267 |
|
|---|
| 268 | const args = 123
|
|---|
| 269 | let generatedRequestId = ''
|
|---|
| 270 |
|
|---|
| 271 | const errorPayload = {
|
|---|
| 272 | errorMessage:
|
|---|
| 273 | 'I am a fake server-provided 400 payload with validation details',
|
|---|
| 274 | errors: [
|
|---|
| 275 | { field_one: 'Must be a string' },
|
|---|
| 276 | { field_two: 'Must be a number' },
|
|---|
| 277 | ],
|
|---|
| 278 | }
|
|---|
| 279 |
|
|---|
| 280 | const thunkActionCreator = createAsyncThunk(
|
|---|
| 281 | 'testType',
|
|---|
| 282 | async (args: number, { requestId, rejectWithValue }) => {
|
|---|
| 283 | generatedRequestId = requestId
|
|---|
| 284 |
|
|---|
| 285 | throw rejectWithValue(errorPayload)
|
|---|
| 286 | },
|
|---|
| 287 | )
|
|---|
| 288 |
|
|---|
| 289 | const thunkFunction = thunkActionCreator(args)
|
|---|
| 290 |
|
|---|
| 291 | try {
|
|---|
| 292 | await thunkFunction(dispatch, () => {}, undefined)
|
|---|
| 293 | } catch (e) {}
|
|---|
| 294 |
|
|---|
| 295 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 296 | 1,
|
|---|
| 297 | thunkActionCreator.pending(generatedRequestId, args),
|
|---|
| 298 | )
|
|---|
| 299 |
|
|---|
| 300 | expect(dispatch).toHaveBeenCalledTimes(2)
|
|---|
| 301 |
|
|---|
| 302 | // Have to check the bits of the action separately since the error was processed
|
|---|
| 303 | const errorAction = dispatch.mock.calls[1][0]
|
|---|
| 304 |
|
|---|
| 305 | expect(errorAction.error.message).toEqual('Rejected')
|
|---|
| 306 | expect(errorAction.payload).toBe(errorPayload)
|
|---|
| 307 | expect(errorAction.meta.arg).toBe(args)
|
|---|
| 308 | })
|
|---|
| 309 |
|
|---|
| 310 | it('dispatches a rejected action with a miniSerializeError when rejectWithValue conditions are not satisfied', async () => {
|
|---|
| 311 | const dispatch = vi.fn()
|
|---|
| 312 |
|
|---|
| 313 | const args = 123
|
|---|
| 314 | let generatedRequestId = ''
|
|---|
| 315 |
|
|---|
| 316 | const error = new Error('Panic!')
|
|---|
| 317 |
|
|---|
| 318 | const errorPayload = {
|
|---|
| 319 | errorMessage:
|
|---|
| 320 | 'I am a fake server-provided 400 payload with validation details',
|
|---|
| 321 | errors: [
|
|---|
| 322 | { field_one: 'Must be a string' },
|
|---|
| 323 | { field_two: 'Must be a number' },
|
|---|
| 324 | ],
|
|---|
| 325 | }
|
|---|
| 326 |
|
|---|
| 327 | const thunkActionCreator = createAsyncThunk(
|
|---|
| 328 | 'testType',
|
|---|
| 329 | async (args: number, { requestId, rejectWithValue }) => {
|
|---|
| 330 | generatedRequestId = requestId
|
|---|
| 331 |
|
|---|
| 332 | try {
|
|---|
| 333 | throw error
|
|---|
| 334 | } catch (err) {
|
|---|
| 335 | if (!(err as any).response) {
|
|---|
| 336 | throw err
|
|---|
| 337 | }
|
|---|
| 338 | return rejectWithValue(errorPayload)
|
|---|
| 339 | }
|
|---|
| 340 | },
|
|---|
| 341 | )
|
|---|
| 342 |
|
|---|
| 343 | const thunkFunction = thunkActionCreator(args)
|
|---|
| 344 |
|
|---|
| 345 | try {
|
|---|
| 346 | await thunkFunction(dispatch, () => {}, undefined)
|
|---|
| 347 | } catch (e) {}
|
|---|
| 348 |
|
|---|
| 349 | expect(dispatch).toHaveBeenNthCalledWith(
|
|---|
| 350 | 1,
|
|---|
| 351 | thunkActionCreator.pending(generatedRequestId, args),
|
|---|
| 352 | )
|
|---|
| 353 |
|
|---|
| 354 | expect(dispatch).toHaveBeenCalledTimes(2)
|
|---|
| 355 |
|
|---|
| 356 | // Have to check the bits of the action separately since the error was processed
|
|---|
| 357 | const errorAction = dispatch.mock.calls[1][0]
|
|---|
| 358 | expect(errorAction.error).toEqual(miniSerializeError(error))
|
|---|
| 359 | expect(errorAction.payload).toEqual(undefined)
|
|---|
| 360 | expect(errorAction.meta.requestId).toBe(generatedRequestId)
|
|---|
| 361 | expect(errorAction.meta.arg).toBe(args)
|
|---|
| 362 | })
|
|---|
| 363 | })
|
|---|
| 364 |
|
|---|
| 365 | describe('createAsyncThunk with abortController', () => {
|
|---|
| 366 | const asyncThunk = createAsyncThunk(
|
|---|
| 367 | 'test',
|
|---|
| 368 | function abortablePayloadCreator(_: any, { signal }) {
|
|---|
| 369 | return new Promise((resolve, reject) => {
|
|---|
| 370 | if (signal.aborted) {
|
|---|
| 371 | reject(
|
|---|
| 372 | new DOMException(
|
|---|
| 373 | 'This should never be reached as it should already be handled.',
|
|---|
| 374 | 'AbortError',
|
|---|
| 375 | ),
|
|---|
| 376 | )
|
|---|
| 377 | }
|
|---|
| 378 | signal.addEventListener('abort', () => {
|
|---|
| 379 | reject(new DOMException('Was aborted while running', 'AbortError'))
|
|---|
| 380 | })
|
|---|
| 381 | setTimeout(resolve, 100)
|
|---|
| 382 | })
|
|---|
| 383 | },
|
|---|
| 384 | )
|
|---|
| 385 |
|
|---|
| 386 | let store = configureStore({
|
|---|
| 387 | reducer(store: UnknownAction[] = []) {
|
|---|
| 388 | return store
|
|---|
| 389 | },
|
|---|
| 390 | })
|
|---|
| 391 |
|
|---|
| 392 | beforeEach(() => {
|
|---|
| 393 | store = configureStore({
|
|---|
| 394 | reducer(store: UnknownAction[] = [], action) {
|
|---|
| 395 | return [...store, action]
|
|---|
| 396 | },
|
|---|
| 397 | })
|
|---|
| 398 | })
|
|---|
| 399 |
|
|---|
| 400 | test('normal usage', async () => {
|
|---|
| 401 | await store.dispatch(asyncThunk({}))
|
|---|
| 402 | expect(store.getState()).toEqual([
|
|---|
| 403 | expect.any(Object),
|
|---|
| 404 | expect.objectContaining({ type: 'test/pending' }),
|
|---|
| 405 | expect.objectContaining({ type: 'test/fulfilled' }),
|
|---|
| 406 | ])
|
|---|
| 407 | })
|
|---|
| 408 |
|
|---|
| 409 | test('abort after dispatch', async () => {
|
|---|
| 410 | const promise = store.dispatch(asyncThunk({}))
|
|---|
| 411 | promise.abort('AbortReason')
|
|---|
| 412 | const result = await promise
|
|---|
| 413 | const expectedAbortedAction = {
|
|---|
| 414 | type: 'test/rejected',
|
|---|
| 415 | error: {
|
|---|
| 416 | message: 'AbortReason',
|
|---|
| 417 | name: 'AbortError',
|
|---|
| 418 | },
|
|---|
| 419 | meta: { aborted: true, requestId: promise.requestId },
|
|---|
| 420 | }
|
|---|
| 421 |
|
|---|
| 422 | // abortedAction with reason is dispatched after test/pending is dispatched
|
|---|
| 423 | expect(store.getState()).toMatchObject([
|
|---|
| 424 | {},
|
|---|
| 425 | { type: 'test/pending' },
|
|---|
| 426 | expectedAbortedAction,
|
|---|
| 427 | ])
|
|---|
| 428 |
|
|---|
| 429 | // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
|
|---|
| 430 | expect(result).toMatchObject(expectedAbortedAction)
|
|---|
| 431 |
|
|---|
| 432 | // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
|
|---|
| 433 | expect(() => unwrapResult(result)).toThrowError(
|
|---|
| 434 | expect.objectContaining(expectedAbortedAction.error),
|
|---|
| 435 | )
|
|---|
| 436 | })
|
|---|
| 437 |
|
|---|
| 438 | test('even when the payloadCreator does not directly support the signal, no further actions are dispatched', async () => {
|
|---|
| 439 | const unawareAsyncThunk = createAsyncThunk('unaware', async () => {
|
|---|
| 440 | await new Promise((resolve) => setTimeout(resolve, 100))
|
|---|
| 441 | return 'finished'
|
|---|
| 442 | })
|
|---|
| 443 |
|
|---|
| 444 | const promise = store.dispatch(unawareAsyncThunk())
|
|---|
| 445 | promise.abort('AbortReason')
|
|---|
| 446 | const result = await promise
|
|---|
| 447 |
|
|---|
| 448 | const expectedAbortedAction = {
|
|---|
| 449 | type: 'unaware/rejected',
|
|---|
| 450 | error: {
|
|---|
| 451 | message: 'AbortReason',
|
|---|
| 452 | name: 'AbortError',
|
|---|
| 453 | },
|
|---|
| 454 | }
|
|---|
| 455 |
|
|---|
| 456 | // abortedAction with reason is dispatched after test/pending is dispatched
|
|---|
| 457 | expect(store.getState()).toEqual([
|
|---|
| 458 | expect.any(Object),
|
|---|
| 459 | expect.objectContaining({ type: 'unaware/pending' }),
|
|---|
| 460 | expect.objectContaining(expectedAbortedAction),
|
|---|
| 461 | ])
|
|---|
| 462 |
|
|---|
| 463 | // same abortedAction is returned, but with the AbortError from the abortablePayloadCreator
|
|---|
| 464 | expect(result).toMatchObject(expectedAbortedAction)
|
|---|
| 465 |
|
|---|
| 466 | // calling unwrapResult on the returned object re-throws the error from the abortablePayloadCreator
|
|---|
| 467 | expect(() => unwrapResult(result)).toThrowError(
|
|---|
| 468 | expect.objectContaining(expectedAbortedAction.error),
|
|---|
| 469 | )
|
|---|
| 470 | })
|
|---|
| 471 |
|
|---|
| 472 | test('dispatch(asyncThunk) returns on abort and does not wait for the promiseProvider to finish', async () => {
|
|---|
| 473 | let running = false
|
|---|
| 474 | const longRunningAsyncThunk = createAsyncThunk('longRunning', async () => {
|
|---|
| 475 | running = true
|
|---|
| 476 | await new Promise((resolve) => setTimeout(resolve, 30000))
|
|---|
| 477 | running = false
|
|---|
| 478 | })
|
|---|
| 479 |
|
|---|
| 480 | const promise = store.dispatch(longRunningAsyncThunk())
|
|---|
| 481 | expect(running).toBeTruthy()
|
|---|
| 482 | promise.abort()
|
|---|
| 483 | const result = await promise
|
|---|
| 484 | expect(running).toBeTruthy()
|
|---|
| 485 | expect(result).toMatchObject({
|
|---|
| 486 | type: 'longRunning/rejected',
|
|---|
| 487 | error: { message: 'Aborted', name: 'AbortError' },
|
|---|
| 488 | meta: { aborted: true },
|
|---|
| 489 | })
|
|---|
| 490 | })
|
|---|
| 491 |
|
|---|
| 492 | describe('behavior with missing AbortController', () => {
|
|---|
| 493 | let keepAbortController: (typeof window)['AbortController']
|
|---|
| 494 | let freshlyLoadedModule: typeof import('../createAsyncThunk')
|
|---|
| 495 |
|
|---|
| 496 | beforeEach(async () => {
|
|---|
| 497 | keepAbortController = window.AbortController
|
|---|
| 498 | delete (window as any).AbortController
|
|---|
| 499 | vi.resetModules()
|
|---|
| 500 | freshlyLoadedModule = await import('../createAsyncThunk')
|
|---|
| 501 | vi.stubEnv('NODE_ENV', 'development')
|
|---|
| 502 | })
|
|---|
| 503 |
|
|---|
| 504 | afterEach(() => {
|
|---|
| 505 | vi.unstubAllEnvs()
|
|---|
| 506 | vi.clearAllMocks()
|
|---|
| 507 | vi.stubGlobal('AbortController', keepAbortController)
|
|---|
| 508 | vi.resetModules()
|
|---|
| 509 | })
|
|---|
| 510 |
|
|---|
| 511 | test('calling a thunk made with createAsyncThunk should fail if no global abortController is not available', async () => {
|
|---|
| 512 | const longRunningAsyncThunk = freshlyLoadedModule.createAsyncThunk(
|
|---|
| 513 | 'longRunning',
|
|---|
| 514 | async () => {
|
|---|
| 515 | await new Promise((resolve) => setTimeout(resolve, 30000))
|
|---|
| 516 | },
|
|---|
| 517 | )
|
|---|
| 518 |
|
|---|
| 519 | expect(longRunningAsyncThunk()).toThrow('AbortController is not defined')
|
|---|
| 520 | })
|
|---|
| 521 | })
|
|---|
| 522 | })
|
|---|
| 523 |
|
|---|
| 524 | test('non-serializable arguments are ignored by serializableStateInvariantMiddleware', async () => {
|
|---|
| 525 | const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(noop)
|
|---|
| 526 | const nonSerializableValue = new Map()
|
|---|
| 527 | const asyncThunk = createAsyncThunk('test', (arg: Map<any, any>) => {})
|
|---|
| 528 |
|
|---|
| 529 | configureStore({
|
|---|
| 530 | reducer: () => 0,
|
|---|
| 531 | }).dispatch(asyncThunk(nonSerializableValue))
|
|---|
| 532 |
|
|---|
| 533 | expect(consoleErrorSpy).not.toHaveBeenCalled()
|
|---|
| 534 |
|
|---|
| 535 | consoleErrorSpy.mockRestore()
|
|---|
| 536 | })
|
|---|
| 537 |
|
|---|
| 538 | describe('conditional skipping of asyncThunks', () => {
|
|---|
| 539 | const arg = {}
|
|---|
| 540 | const getState = vi.fn(() => ({}))
|
|---|
| 541 | const dispatch = vi.fn((x: any) => x)
|
|---|
| 542 | const payloadCreator = vi.fn((x: typeof arg) => 10)
|
|---|
| 543 | const condition = vi.fn(() => false)
|
|---|
| 544 | const extra = {}
|
|---|
| 545 |
|
|---|
| 546 | beforeEach(() => {
|
|---|
| 547 | getState.mockClear()
|
|---|
| 548 | dispatch.mockClear()
|
|---|
| 549 | payloadCreator.mockClear()
|
|---|
| 550 | condition.mockClear()
|
|---|
| 551 | })
|
|---|
| 552 |
|
|---|
| 553 | test('returning false from condition skips payloadCreator and returns a rejected action', async () => {
|
|---|
| 554 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 555 | const result = await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 556 |
|
|---|
| 557 | expect(condition).toHaveBeenCalled()
|
|---|
| 558 | expect(payloadCreator).not.toHaveBeenCalled()
|
|---|
| 559 | expect(asyncThunk.rejected.match(result)).toBe(true)
|
|---|
| 560 | expect((result as any).meta.condition).toBe(true)
|
|---|
| 561 | })
|
|---|
| 562 |
|
|---|
| 563 | test('return falsy from condition does not skip payload creator', async () => {
|
|---|
| 564 | // Override TS's expectation that this is a boolean
|
|---|
| 565 | condition.mockReturnValueOnce(undefined as unknown as boolean)
|
|---|
| 566 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 567 | const result = await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 568 |
|
|---|
| 569 | expect(condition).toHaveBeenCalled()
|
|---|
| 570 | expect(payloadCreator).toHaveBeenCalled()
|
|---|
| 571 | expect(asyncThunk.fulfilled.match(result)).toBe(true)
|
|---|
| 572 | expect(result.payload).toBe(10)
|
|---|
| 573 | })
|
|---|
| 574 |
|
|---|
| 575 | test('returning true from condition executes payloadCreator', async () => {
|
|---|
| 576 | condition.mockReturnValueOnce(true)
|
|---|
| 577 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 578 | const result = await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 579 |
|
|---|
| 580 | expect(condition).toHaveBeenCalled()
|
|---|
| 581 | expect(payloadCreator).toHaveBeenCalled()
|
|---|
| 582 | expect(asyncThunk.fulfilled.match(result)).toBe(true)
|
|---|
| 583 | expect(result.payload).toBe(10)
|
|---|
| 584 | })
|
|---|
| 585 |
|
|---|
| 586 | test('condition is called with arg, getState and extra', async () => {
|
|---|
| 587 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 588 | await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 589 |
|
|---|
| 590 | expect(condition).toHaveBeenCalledOnce()
|
|---|
| 591 | expect(condition).toHaveBeenLastCalledWith(
|
|---|
| 592 | arg,
|
|---|
| 593 | expect.objectContaining({ getState, extra }),
|
|---|
| 594 | )
|
|---|
| 595 | })
|
|---|
| 596 |
|
|---|
| 597 | test('pending is dispatched synchronously if condition is synchronous', async () => {
|
|---|
| 598 | const condition = () => true
|
|---|
| 599 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 600 | const thunkCallPromise = asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 601 | expect(dispatch).toHaveBeenCalledOnce()
|
|---|
| 602 | await thunkCallPromise
|
|---|
| 603 | expect(dispatch).toHaveBeenCalledTimes(2)
|
|---|
| 604 | })
|
|---|
| 605 |
|
|---|
| 606 | test('async condition', async () => {
|
|---|
| 607 | const condition = () => Promise.resolve(false)
|
|---|
| 608 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 609 | await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 610 | expect(dispatch).not.toHaveBeenCalled()
|
|---|
| 611 | })
|
|---|
| 612 |
|
|---|
| 613 | test('async condition with rejected promise', async () => {
|
|---|
| 614 | const condition = () => Promise.reject()
|
|---|
| 615 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 616 | await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 617 | expect(dispatch).toHaveBeenCalledOnce()
|
|---|
| 618 | expect(dispatch).toHaveBeenLastCalledWith(
|
|---|
| 619 | expect.objectContaining({ type: 'test/rejected' }),
|
|---|
| 620 | )
|
|---|
| 621 | })
|
|---|
| 622 |
|
|---|
| 623 | test('async condition with AbortController signal first', async () => {
|
|---|
| 624 | const condition = async () => {
|
|---|
| 625 | await delay(25)
|
|---|
| 626 | return true
|
|---|
| 627 | }
|
|---|
| 628 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 629 |
|
|---|
| 630 | try {
|
|---|
| 631 | const thunkPromise = asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 632 | thunkPromise.abort()
|
|---|
| 633 | await thunkPromise
|
|---|
| 634 | } catch (err) {}
|
|---|
| 635 | expect(dispatch).not.toHaveBeenCalled()
|
|---|
| 636 | })
|
|---|
| 637 |
|
|---|
| 638 | test('rejected action is not dispatched by default', async () => {
|
|---|
| 639 | const asyncThunk = createAsyncThunk('test', payloadCreator, { condition })
|
|---|
| 640 | await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 641 |
|
|---|
| 642 | expect(dispatch).not.toHaveBeenCalled()
|
|---|
| 643 | })
|
|---|
| 644 |
|
|---|
| 645 | test('does not fail when attempting to abort a canceled promise', async () => {
|
|---|
| 646 | const asyncPayloadCreator = vi.fn(async (x: typeof arg) => {
|
|---|
| 647 | await delay(200)
|
|---|
| 648 | return 10
|
|---|
| 649 | })
|
|---|
| 650 |
|
|---|
| 651 | const asyncThunk = createAsyncThunk('test', asyncPayloadCreator, {
|
|---|
| 652 | condition,
|
|---|
| 653 | })
|
|---|
| 654 | const promise = asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 655 | promise.abort(
|
|---|
| 656 | `If the promise was 1. somehow canceled, 2. in a 'started' state and 3. we attempted to abort, this would crash the tests`,
|
|---|
| 657 | )
|
|---|
| 658 | })
|
|---|
| 659 |
|
|---|
| 660 | test('rejected action can be dispatched via option', async () => {
|
|---|
| 661 | const asyncThunk = createAsyncThunk('test', payloadCreator, {
|
|---|
| 662 | condition,
|
|---|
| 663 | dispatchConditionRejection: true,
|
|---|
| 664 | })
|
|---|
| 665 | await asyncThunk(arg)(dispatch, getState, extra)
|
|---|
| 666 |
|
|---|
| 667 | expect(dispatch).toHaveBeenCalledOnce()
|
|---|
| 668 | expect(dispatch).toHaveBeenLastCalledWith(
|
|---|
| 669 | expect.objectContaining({
|
|---|
| 670 | error: {
|
|---|
| 671 | message: 'Aborted due to condition callback returning false.',
|
|---|
| 672 | name: 'ConditionError',
|
|---|
| 673 | },
|
|---|
| 674 | meta: {
|
|---|
| 675 | aborted: false,
|
|---|
| 676 | arg,
|
|---|
| 677 | rejectedWithValue: false,
|
|---|
| 678 | condition: true,
|
|---|
| 679 | requestId: expect.stringContaining(''),
|
|---|
| 680 | requestStatus: 'rejected',
|
|---|
| 681 | },
|
|---|
| 682 | payload: undefined,
|
|---|
| 683 | type: 'test/rejected',
|
|---|
| 684 | }),
|
|---|
| 685 | )
|
|---|
| 686 | })
|
|---|
| 687 | })
|
|---|
| 688 |
|
|---|
| 689 | test('serializeError implementation', async () => {
|
|---|
| 690 | function serializeError() {
|
|---|
| 691 | return 'serialized!'
|
|---|
| 692 | }
|
|---|
| 693 | const errorObject = 'something else!'
|
|---|
| 694 |
|
|---|
| 695 | const store = configureStore({
|
|---|
| 696 | reducer: (state = [], action) => [...state, action],
|
|---|
| 697 | })
|
|---|
| 698 |
|
|---|
| 699 | const asyncThunk = createAsyncThunk<
|
|---|
| 700 | unknown,
|
|---|
| 701 | void,
|
|---|
| 702 | { serializedErrorType: string }
|
|---|
| 703 | >('test', () => Promise.reject(errorObject), { serializeError })
|
|---|
| 704 | const rejected = await store.dispatch(asyncThunk())
|
|---|
| 705 | if (!asyncThunk.rejected.match(rejected)) {
|
|---|
| 706 | throw new Error()
|
|---|
| 707 | }
|
|---|
| 708 |
|
|---|
| 709 | const expectation = {
|
|---|
| 710 | type: 'test/rejected',
|
|---|
| 711 | payload: undefined,
|
|---|
| 712 | error: 'serialized!',
|
|---|
| 713 | meta: expect.any(Object),
|
|---|
| 714 | }
|
|---|
| 715 | expect(rejected).toEqual(expectation)
|
|---|
| 716 | expect(store.getState()[2]).toEqual(expectation)
|
|---|
| 717 | expect(rejected.error).not.toEqual(miniSerializeError(errorObject))
|
|---|
| 718 | })
|
|---|
| 719 |
|
|---|
| 720 | describe('unwrapResult', () => {
|
|---|
| 721 | const getState = vi.fn(() => ({}))
|
|---|
| 722 | const dispatch = vi.fn((x: any) => x)
|
|---|
| 723 | const extra = {}
|
|---|
| 724 | test('fulfilled case', async () => {
|
|---|
| 725 | const asyncThunk = createAsyncThunk('test', () => {
|
|---|
| 726 | return 'fulfilled!' as const
|
|---|
| 727 | })
|
|---|
| 728 |
|
|---|
| 729 | const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
|
|---|
| 730 | unwrapResult,
|
|---|
| 731 | )
|
|---|
| 732 |
|
|---|
| 733 | await expect(unwrapPromise).resolves.toBe('fulfilled!')
|
|---|
| 734 |
|
|---|
| 735 | const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
|
|---|
| 736 | const res = await unwrapPromise2.unwrap()
|
|---|
| 737 | expect(res).toBe('fulfilled!')
|
|---|
| 738 | })
|
|---|
| 739 | test('error case', async () => {
|
|---|
| 740 | const error = new Error('Panic!')
|
|---|
| 741 | const asyncThunk = createAsyncThunk('test', () => {
|
|---|
| 742 | throw error
|
|---|
| 743 | })
|
|---|
| 744 |
|
|---|
| 745 | const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
|
|---|
| 746 | unwrapResult,
|
|---|
| 747 | )
|
|---|
| 748 |
|
|---|
| 749 | await expect(unwrapPromise).rejects.toEqual(miniSerializeError(error))
|
|---|
| 750 |
|
|---|
| 751 | const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
|
|---|
| 752 | await expect(unwrapPromise2.unwrap()).rejects.toEqual(
|
|---|
| 753 | miniSerializeError(error),
|
|---|
| 754 | )
|
|---|
| 755 | })
|
|---|
| 756 | test('rejectWithValue case', async () => {
|
|---|
| 757 | const asyncThunk = createAsyncThunk('test', (_, { rejectWithValue }) => {
|
|---|
| 758 | return rejectWithValue('rejectWithValue!')
|
|---|
| 759 | })
|
|---|
| 760 |
|
|---|
| 761 | const unwrapPromise = asyncThunk()(dispatch, getState, extra).then(
|
|---|
| 762 | unwrapResult,
|
|---|
| 763 | )
|
|---|
| 764 |
|
|---|
| 765 | await expect(unwrapPromise).rejects.toBe('rejectWithValue!')
|
|---|
| 766 |
|
|---|
| 767 | const unwrapPromise2 = asyncThunk()(dispatch, getState, extra)
|
|---|
| 768 | await expect(unwrapPromise2.unwrap()).rejects.toBe('rejectWithValue!')
|
|---|
| 769 | })
|
|---|
| 770 | })
|
|---|
| 771 |
|
|---|
| 772 | describe('idGenerator option', () => {
|
|---|
| 773 | const getState = () => ({})
|
|---|
| 774 | const dispatch = (x: any) => x
|
|---|
| 775 | const extra = {}
|
|---|
| 776 |
|
|---|
| 777 | test('idGenerator implementation - can customizes how request IDs are generated', async () => {
|
|---|
| 778 | function makeFakeIdGenerator() {
|
|---|
| 779 | let id = 0
|
|---|
| 780 | return vi.fn(() => {
|
|---|
| 781 | id++
|
|---|
| 782 | return `fake-random-id-${id}`
|
|---|
| 783 | })
|
|---|
| 784 | }
|
|---|
| 785 |
|
|---|
| 786 | let generatedRequestId = ''
|
|---|
| 787 |
|
|---|
| 788 | const idGenerator = makeFakeIdGenerator()
|
|---|
| 789 | const asyncThunk = createAsyncThunk(
|
|---|
| 790 | 'test',
|
|---|
| 791 | async (args: void, { requestId }) => {
|
|---|
| 792 | generatedRequestId = requestId
|
|---|
| 793 | },
|
|---|
| 794 | { idGenerator },
|
|---|
| 795 | )
|
|---|
| 796 |
|
|---|
| 797 | // dispatching the thunks should be using the custom id generator
|
|---|
| 798 | const promise0 = asyncThunk()(dispatch, getState, extra)
|
|---|
| 799 | expect(generatedRequestId).toEqual('fake-random-id-1')
|
|---|
| 800 | expect(promise0.requestId).toEqual('fake-random-id-1')
|
|---|
| 801 | expect((await promise0).meta.requestId).toEqual('fake-random-id-1')
|
|---|
| 802 |
|
|---|
| 803 | const promise1 = asyncThunk()(dispatch, getState, extra)
|
|---|
| 804 | expect(generatedRequestId).toEqual('fake-random-id-2')
|
|---|
| 805 | expect(promise1.requestId).toEqual('fake-random-id-2')
|
|---|
| 806 | expect((await promise1).meta.requestId).toEqual('fake-random-id-2')
|
|---|
| 807 |
|
|---|
| 808 | const promise2 = asyncThunk()(dispatch, getState, extra)
|
|---|
| 809 | expect(generatedRequestId).toEqual('fake-random-id-3')
|
|---|
| 810 | expect(promise2.requestId).toEqual('fake-random-id-3')
|
|---|
| 811 | expect((await promise2).meta.requestId).toEqual('fake-random-id-3')
|
|---|
| 812 |
|
|---|
| 813 | generatedRequestId = ''
|
|---|
| 814 | const defaultAsyncThunk = createAsyncThunk(
|
|---|
| 815 | 'test',
|
|---|
| 816 | async (args: void, { requestId }) => {
|
|---|
| 817 | generatedRequestId = requestId
|
|---|
| 818 | },
|
|---|
| 819 | )
|
|---|
| 820 | // dispatching the default options thunk should still generate an id,
|
|---|
| 821 | // but not using the custom id generator
|
|---|
| 822 | const promise3 = defaultAsyncThunk()(dispatch, getState, extra)
|
|---|
| 823 | expect(generatedRequestId).toEqual(promise3.requestId)
|
|---|
| 824 | expect(promise3.requestId).not.toEqual('')
|
|---|
| 825 | expect(promise3.requestId).not.toEqual(
|
|---|
| 826 | expect.stringContaining('fake-random-id'),
|
|---|
| 827 | )
|
|---|
| 828 | expect((await promise3).meta.requestId).not.toEqual(
|
|---|
| 829 | expect.stringContaining('fake-fandom-id'),
|
|---|
| 830 | )
|
|---|
| 831 | })
|
|---|
| 832 |
|
|---|
| 833 | test('idGenerator should be called with thunkArg', async () => {
|
|---|
| 834 | const customIdGenerator = vi.fn((seed) => `fake-unique-random-id-${seed}`)
|
|---|
| 835 | let generatedRequestId = ''
|
|---|
| 836 | const asyncThunk = createAsyncThunk(
|
|---|
| 837 | 'test',
|
|---|
| 838 | async (args: any, { requestId }) => {
|
|---|
| 839 | generatedRequestId = requestId
|
|---|
| 840 | },
|
|---|
| 841 | { idGenerator: customIdGenerator },
|
|---|
| 842 | )
|
|---|
| 843 |
|
|---|
| 844 | const thunkArg = 1
|
|---|
| 845 | const expected = 'fake-unique-random-id-1'
|
|---|
| 846 | const asyncThunkPromise = asyncThunk(thunkArg)(dispatch, getState, extra)
|
|---|
| 847 |
|
|---|
| 848 | expect(customIdGenerator).toHaveBeenCalledWith(thunkArg)
|
|---|
| 849 | expect(asyncThunkPromise.requestId).toEqual(expected)
|
|---|
| 850 | expect((await asyncThunkPromise).meta.requestId).toEqual(expected)
|
|---|
| 851 | })
|
|---|
| 852 | })
|
|---|
| 853 |
|
|---|
| 854 | test('`condition` will see state changes from a synchronously invoked asyncThunk', () => {
|
|---|
| 855 | type State = ReturnType<typeof store.getState>
|
|---|
| 856 | const onStart = vi.fn()
|
|---|
| 857 | const asyncThunk = createAsyncThunk<
|
|---|
| 858 | void,
|
|---|
| 859 | { force?: boolean },
|
|---|
| 860 | { state: State }
|
|---|
| 861 | >('test', onStart, {
|
|---|
| 862 | condition({ force }, { getState }) {
|
|---|
| 863 | return force || !getState().started
|
|---|
| 864 | },
|
|---|
| 865 | })
|
|---|
| 866 | const store = configureStore({
|
|---|
| 867 | reducer: createReducer({ started: false }, (builder) => {
|
|---|
| 868 | builder.addCase(asyncThunk.pending, (state) => {
|
|---|
| 869 | state.started = true
|
|---|
| 870 | })
|
|---|
| 871 | }),
|
|---|
| 872 | })
|
|---|
| 873 |
|
|---|
| 874 | store.dispatch(asyncThunk({ force: false }))
|
|---|
| 875 | expect(onStart).toHaveBeenCalledOnce()
|
|---|
| 876 | store.dispatch(asyncThunk({ force: false }))
|
|---|
| 877 | expect(onStart).toHaveBeenCalledOnce()
|
|---|
| 878 | store.dispatch(asyncThunk({ force: true }))
|
|---|
| 879 | expect(onStart).toHaveBeenCalledTimes(2)
|
|---|
| 880 | })
|
|---|
| 881 |
|
|---|
| 882 | const getNewStore = () =>
|
|---|
| 883 | configureStore({
|
|---|
| 884 | reducer(actions: UnknownAction[] = [], action) {
|
|---|
| 885 | return [...actions, action]
|
|---|
| 886 | },
|
|---|
| 887 | })
|
|---|
| 888 |
|
|---|
| 889 | describe('meta', () => {
|
|---|
| 890 | let store = getNewStore()
|
|---|
| 891 |
|
|---|
| 892 | beforeEach(() => {
|
|---|
| 893 | store = getNewStore()
|
|---|
| 894 | })
|
|---|
| 895 |
|
|---|
| 896 | test('pendingMeta', () => {
|
|---|
| 897 | const pendingThunk = createAsyncThunk('test', (arg: string) => {}, {
|
|---|
| 898 | getPendingMeta({ arg, requestId }) {
|
|---|
| 899 | expect(arg).toBe('testArg')
|
|---|
| 900 | expect(requestId).toEqual(expect.any(String))
|
|---|
| 901 | return { extraProp: 'foo' }
|
|---|
| 902 | },
|
|---|
| 903 | })
|
|---|
| 904 | const ret = store.dispatch(pendingThunk('testArg'))
|
|---|
| 905 | expect(store.getState()[1]).toEqual({
|
|---|
| 906 | meta: {
|
|---|
| 907 | arg: 'testArg',
|
|---|
| 908 | extraProp: 'foo',
|
|---|
| 909 | requestId: ret.requestId,
|
|---|
| 910 | requestStatus: 'pending',
|
|---|
| 911 | },
|
|---|
| 912 | payload: undefined,
|
|---|
| 913 | type: 'test/pending',
|
|---|
| 914 | })
|
|---|
| 915 | })
|
|---|
| 916 |
|
|---|
| 917 | test('fulfilledMeta', async () => {
|
|---|
| 918 | const fulfilledThunk = createAsyncThunk<
|
|---|
| 919 | string,
|
|---|
| 920 | string,
|
|---|
| 921 | { fulfilledMeta: { extraProp: string } }
|
|---|
| 922 | >('test', (arg: string, { fulfillWithValue }) => {
|
|---|
| 923 | return fulfillWithValue('hooray!', { extraProp: 'bar' })
|
|---|
| 924 | })
|
|---|
| 925 | const ret = store.dispatch(fulfilledThunk('testArg'))
|
|---|
| 926 | expect(await ret).toEqual({
|
|---|
| 927 | meta: {
|
|---|
| 928 | arg: 'testArg',
|
|---|
| 929 | extraProp: 'bar',
|
|---|
| 930 | requestId: ret.requestId,
|
|---|
| 931 | requestStatus: 'fulfilled',
|
|---|
| 932 | },
|
|---|
| 933 | payload: 'hooray!',
|
|---|
| 934 | type: 'test/fulfilled',
|
|---|
| 935 | })
|
|---|
| 936 | })
|
|---|
| 937 |
|
|---|
| 938 | test('rejectedMeta', async () => {
|
|---|
| 939 | const fulfilledThunk = createAsyncThunk<
|
|---|
| 940 | string,
|
|---|
| 941 | string,
|
|---|
| 942 | { rejectedMeta: { extraProp: string } }
|
|---|
| 943 | >('test', (arg: string, { rejectWithValue }) => {
|
|---|
| 944 | return rejectWithValue('damn!', { extraProp: 'baz' })
|
|---|
| 945 | })
|
|---|
| 946 | const promise = store.dispatch(fulfilledThunk('testArg'))
|
|---|
| 947 | const ret = await promise
|
|---|
| 948 | expect(ret).toEqual({
|
|---|
| 949 | meta: {
|
|---|
| 950 | arg: 'testArg',
|
|---|
| 951 | extraProp: 'baz',
|
|---|
| 952 | requestId: promise.requestId,
|
|---|
| 953 | requestStatus: 'rejected',
|
|---|
| 954 | rejectedWithValue: true,
|
|---|
| 955 | aborted: false,
|
|---|
| 956 | condition: false,
|
|---|
| 957 | },
|
|---|
| 958 | error: { message: 'Rejected' },
|
|---|
| 959 | payload: 'damn!',
|
|---|
| 960 | type: 'test/rejected',
|
|---|
| 961 | })
|
|---|
| 962 |
|
|---|
| 963 | if (ret.meta.requestStatus === 'rejected' && ret.meta.rejectedWithValue) {
|
|---|
| 964 | } else {
|
|---|
| 965 | // could be caused by a `throw`, `abort()` or `condition` - no `rejectedMeta` in that case
|
|---|
| 966 | // @ts-expect-error
|
|---|
| 967 | ret.meta.extraProp
|
|---|
| 968 | }
|
|---|
| 969 | })
|
|---|
| 970 |
|
|---|
| 971 | test('typed createAsyncThunk.withTypes', () => {
|
|---|
| 972 | const typedCAT = createAsyncThunk.withTypes<{
|
|---|
| 973 | state: { s: string }
|
|---|
| 974 | rejectValue: string
|
|---|
| 975 | extra: { s: string; n: number }
|
|---|
| 976 | }>()
|
|---|
| 977 | const thunk = typedCAT('a', () => 'b')
|
|---|
| 978 | const expectFunction = expect.any(Function)
|
|---|
| 979 | expect(thunk.fulfilled).toEqual(expectFunction)
|
|---|
| 980 | expect(thunk.pending).toEqual(expectFunction)
|
|---|
| 981 | expect(thunk.rejected).toEqual(expectFunction)
|
|---|
| 982 | expect(thunk.settled).toEqual(expectFunction)
|
|---|
| 983 | expect(thunk.fulfilled.type).toBe('a/fulfilled')
|
|---|
| 984 | })
|
|---|
| 985 | test('createAsyncThunkWrapper using CreateAsyncThunkFunction', async () => {
|
|---|
| 986 | const customSerializeError = () => 'serialized!'
|
|---|
| 987 | const createAppAsyncThunk: CreateAsyncThunkFunction<{
|
|---|
| 988 | serializedErrorType: ReturnType<typeof customSerializeError>
|
|---|
| 989 | }> = (prefix: string, payloadCreator: any, options: any) =>
|
|---|
| 990 | createAsyncThunk(prefix, payloadCreator, {
|
|---|
| 991 | ...options,
|
|---|
| 992 | serializeError: customSerializeError,
|
|---|
| 993 | }) as any
|
|---|
| 994 |
|
|---|
| 995 | const asyncThunk = createAppAsyncThunk('test', async () => {
|
|---|
| 996 | throw new Error('Panic!')
|
|---|
| 997 | })
|
|---|
| 998 |
|
|---|
| 999 | const promise = store.dispatch(asyncThunk())
|
|---|
| 1000 | const result = await promise
|
|---|
| 1001 | if (!asyncThunk.rejected.match(result)) {
|
|---|
| 1002 | throw new Error('should have thrown')
|
|---|
| 1003 | }
|
|---|
| 1004 | expect(result.error).toEqual('serialized!')
|
|---|
| 1005 | })
|
|---|
| 1006 | })
|
|---|
| 1007 |
|
|---|
| 1008 | describe('dispatch config', () => {
|
|---|
| 1009 | let store = getNewStore()
|
|---|
| 1010 |
|
|---|
| 1011 | beforeEach(() => {
|
|---|
| 1012 | store = getNewStore()
|
|---|
| 1013 | })
|
|---|
| 1014 | test('accepts external signal', async () => {
|
|---|
| 1015 | const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => {
|
|---|
| 1016 | signal.throwIfAborted()
|
|---|
| 1017 | const { promise, reject } = promiseWithResolvers<never>()
|
|---|
| 1018 | signal.addEventListener('abort', () => reject(signal.reason))
|
|---|
| 1019 | return promise
|
|---|
| 1020 | })
|
|---|
| 1021 |
|
|---|
| 1022 | const abortController = new AbortController()
|
|---|
| 1023 | const promise = store.dispatch(
|
|---|
| 1024 | asyncThunk(undefined, { signal: abortController.signal }),
|
|---|
| 1025 | )
|
|---|
| 1026 | abortController.abort()
|
|---|
| 1027 | await expect(promise.unwrap()).rejects.toThrow(
|
|---|
| 1028 | 'External signal was aborted',
|
|---|
| 1029 | )
|
|---|
| 1030 | })
|
|---|
| 1031 | test('handles already aborted external signal', async () => {
|
|---|
| 1032 | const asyncThunk = createAsyncThunk('test', async (_: void, { signal }) => {
|
|---|
| 1033 | signal.throwIfAborted()
|
|---|
| 1034 | const { promise, reject } = promiseWithResolvers<never>()
|
|---|
| 1035 | signal.addEventListener('abort', () => reject(signal.reason))
|
|---|
| 1036 | return promise
|
|---|
| 1037 | })
|
|---|
| 1038 |
|
|---|
| 1039 | const signal = AbortSignal.abort()
|
|---|
| 1040 | const promise = store.dispatch(asyncThunk(undefined, { signal }))
|
|---|
| 1041 | await expect(promise.unwrap()).rejects.toThrow(
|
|---|
| 1042 | 'Aborted due to condition callback returning false.',
|
|---|
| 1043 | )
|
|---|
| 1044 | })
|
|---|
| 1045 | })
|
|---|