Skip to content

Instantly share code, notes, and snippets.

@Akiyamka
Last active August 24, 2022 19:12
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save Akiyamka/1db279bf12942aa664f299faa3e232cc to your computer and use it in GitHub Desktop.
Save Akiyamka/1db279bf12942aa664f299faa3e232cc to your computer and use it in GitHub Desktop.
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;
}
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