Last active
August 24, 2022 19:12
-
-
Save Akiyamka/1db279bf12942aa664f299faa3e232cc to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { isObject } from '@reatom/core'; | |
import { memo } from '@reatom/core/experiments'; | |
import { createAtom, createPrimitiveAtom } from '~utils/atoms/createPrimitives'; | |
import { store } from '~core/store/store'; | |
import { isErrorWithMessage } from '~utils/common'; | |
import { ABORT_ERROR_MESSAGE, isAbortError } from './abort-error'; | |
import type { ResourceAtomOptions, ResourceAtomState, Fetcher } from './types'; | |
import type { | |
Action, | |
Atom, | |
AtomBinded, | |
AtomSelfBinded, | |
AtomState, | |
} from '@reatom/core'; | |
type ResourceCtx = { | |
abortController?: null | AbortController; | |
}; | |
const defaultOptions: ResourceAtomOptions = { | |
inheritState: false, | |
store: store, | |
}; | |
type Deps<D extends AtomBinded, F extends Fetcher<AtomState<D> | null, any>> = { | |
request: (params: AtomState<D>) => typeof params; | |
refetch: () => null; | |
cancel: () => null; | |
_done: ( | |
params: AtomState<D>, | |
data: Awaited<ReturnType<F>>, | |
) => { params: typeof params; data: typeof data }; | |
_error: ( | |
params: AtomState<D>, | |
error: string, | |
) => { params: typeof params; error: typeof error }; | |
_loading: () => null; | |
_finally: () => null; | |
depsAtom?: Atom<ResourceAtomState<unknown, unknown>> | Atom<unknown>; | |
}; | |
export function createResourceAtom< | |
F extends Fetcher<AtomState<D> | null, any>, | |
D extends AtomBinded, | |
>( | |
atom: D | null, | |
fetcher: F, | |
name: string, | |
resourceAtomOptions: ResourceAtomOptions = {}, | |
): AtomSelfBinded< | |
ResourceAtomState<AtomState<D>, Awaited<ReturnType<F>>>, | |
Deps<D, F> | |
> { | |
const options: ResourceAtomOptions = { | |
lazy: resourceAtomOptions.lazy ?? defaultOptions.lazy, | |
inheritState: | |
resourceAtomOptions.inheritState ?? defaultOptions.inheritState, | |
store: resourceAtomOptions.store ?? defaultOptions.store, | |
}; | |
let wasNeverRequested = true; // Is this even been requested? False after first request action | |
const deps: Deps<D, F> = { | |
request: (params) => params, | |
refetch: () => null, | |
cancel: () => null, | |
_done: (params, data) => ({ params, data }), | |
_error: (params, error) => ({ params, error }), | |
_loading: () => null, | |
_finally: () => null, | |
}; | |
if (atom) { | |
deps.depsAtom = atom; | |
} | |
const resourceAtom: AtomSelfBinded< | |
ResourceAtomState<AtomState<D>, Awaited<ReturnType<F>>>, | |
Deps<D, F> | |
> = createAtom( | |
deps, | |
( | |
{ onAction, schedule, create, onChange }, | |
state: ResourceAtomState<AtomState<D>, Awaited<ReturnType<F>>> = { | |
loading: false, | |
data: null, | |
error: null, | |
lastParams: null, | |
}, | |
) => { | |
type Context = ResourceCtx; | |
const newState = { ...state }; | |
onAction('request', (params) => { | |
wasNeverRequested = false; // For unblock refetch | |
newState.loading = true; | |
newState.lastParams = params | |
schedule(async (dispatch, ctx: Context) => { | |
// Before making new request we should abort previous request | |
// If some request active right now we have abortController | |
if (ctx.abortController) { | |
ctx.abortController.abort(); | |
ctx.abortController = null; | |
dispatch(create('request', params)) | |
return; | |
} | |
const abortController = new AbortController(); | |
let requestAction: Action | null = null; | |
try { | |
ctx.abortController = abortController; | |
const fetcherResult = await fetcher(params, abortController); | |
abortController.signal.throwIfAborted(); // Alow process canceled request event of error was catched in fetcher | |
if (ctx.abortController === abortController) { | |
// Check that new request was not created | |
requestAction = create('_done', params, fetcherResult); | |
} | |
} catch (e) { | |
if (isAbortError(e)) { | |
requestAction = create('_error', params, ABORT_ERROR_MESSAGE); | |
} else if (ctx.abortController === abortController) { | |
console.error(`[${name}]:`, e); | |
const errorMessage = isErrorWithMessage(e) | |
? e.message | |
: typeof e === 'string' | |
? e | |
: 'Unknown'; | |
requestAction = create('_error', params, errorMessage); | |
} | |
} finally { | |
if (requestAction) { | |
dispatch([requestAction, create('_finally')]); | |
} | |
} | |
}); | |
}); | |
// Force refetch, useful for polling | |
onAction('refetch', () => { | |
schedule((dispatch, ctx: Context) => { | |
if (wasNeverRequested) { | |
console.error(`[${name}]:`, 'Do not call refetch before request'); | |
return; | |
} | |
dispatch(create('request', newState.lastParams!)); | |
}); | |
}); | |
onAction('_loading', () => { | |
newState.loading = true; | |
newState.error = null; | |
}); | |
onAction('_error', ({ params, error }) => { | |
newState.error = error; | |
newState.lastParams = params; | |
}); | |
onAction('_done', ({ data, params }) => { | |
newState.data = data; | |
newState.error = null; | |
newState.lastParams = params; | |
}); | |
onAction('_finally', () => { | |
newState.loading = false; | |
}); | |
if (deps.depsAtom) { | |
onChange('depsAtom', (depsAtomState: unknown) => { | |
if (isObject(depsAtomState)) { | |
// Deps is resource atom-like object | |
if (options.inheritState) { | |
newState.loading = depsAtomState.loading || newState.loading; | |
newState.error = depsAtomState.error || newState.error; | |
} | |
if (!depsAtomState.loading && !depsAtomState.error) { | |
schedule((dispatch) => | |
dispatch(create('request', depsAtomState as any)), | |
); | |
} | |
} else { | |
// Deps is primitive | |
schedule((dispatch) => | |
dispatch(create('request', depsAtomState as any)), | |
); | |
} | |
}); | |
} | |
return newState; | |
}, | |
{ | |
id: name, | |
decorators: [memo()], // This prevent updates when prev state and next state deeply equal | |
}, | |
); | |
return resourceAtom; | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { createAtom, createStore } from '@reatom/core'; | |
import { expect, test, describe, vi, beforeEach } from 'vitest'; | |
import { createResourceAtom } from './createResourceAtom'; | |
import type { Store } from '@reatom/core'; | |
import { ABORT_ERROR_MESSAGE } from './abort-error'; | |
beforeEach(async (context) => { | |
context.store = createStore(); | |
}); | |
test('Resource set error state when canceled be other request', async ({ store }) => { | |
const stateChangesLog = vi.fn(async (arg) => null); | |
const resAtomA = createResourceAtom( | |
null, | |
async (value) => { | |
await wait(5); | |
return value; | |
}, | |
'resAtomAA', | |
{ | |
store, | |
}, | |
); | |
resAtomA.subscribe((s) => stateChangesLog(s)); | |
resAtomA.request.dispatch(1); | |
await wait(1); | |
resAtomA.request.dispatch(2); | |
// State change with error should be - 3 | |
// 1 - initial state | |
// (first request) | |
// 2 - first request loading state | |
// (second request) | |
// 3 - first request canceled state | |
// 4 - second request loading state | |
// wait 3 state changes | |
while(stateChangesLog.mock.calls.length < 4) { | |
await wait(1); | |
} | |
console.log(stateChangesLog.mock.calls) | |
expect(stateChangesLog).toHaveBeenNthCalledWith(3, { | |
error: ABORT_ERROR_MESSAGE, | |
data: null, | |
lastParams: 1, | |
loading: false, | |
}); | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment