Skip to content

Commit

Permalink
feat: add event callbacks onSuccess and onError (#11)
Browse files Browse the repository at this point in the history
* docs: add JSDocs to SWR options

* feat: add function to merge functions

* refactor: use SWRConfig type as the value provided by default config

* feat: add generics to SWRConfig

* feat: add onError and onSuccess callbacks on types

* feat: implement logic to merge callbacks in config

* feat: add onError and onSuccess callbacks

* docs: document onError event and onSuccess event
  • Loading branch information
edumudu committed Aug 9, 2022
1 parent f70b6cc commit caefe9b
Show file tree
Hide file tree
Showing 13 changed files with 232 additions and 14 deletions.
1 change: 1 addition & 0 deletions docs/.vitepress/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ function sidebar() {
{ text: 'Mutation', link: '/mutation' },
{ text: 'Global Configuration', link: '/global-configuration' },
{ text: 'Data Fetching', link: '/data-fetching' },
{ text: 'Error Handling', link: '/error-handling' },
{ text: 'Conditional Fetching', link: '/conditional-fetching' },
]
},
Expand Down
30 changes: 30 additions & 0 deletions docs/error-handling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Error Handling

If an error is thrown inside [fetcher](./data-fetching), it will be returned as `error` by the composable

```ts
// ...
const { data, error } = useSWR('/api/user', fetcher);
```

The `error` object will be defined if the fetch promise is rejected or `fetcher` contains an syntax error.

## Global Error Report

You can always get the `error` object inside the component reactively. But in case you want to handle the error globally, to notify the UI to show a toast or a snackbar, or report it somewhere such as [Sentry](https://sentry.io/), there's an `onError` event:

```ts
configureGlobalSWR({
onError: (error, key) => {
// We can send the error to Sentry,

if (error.status !== 403 && error.status !== 404) {
// or show a notification UI.
}
}
})
```

This is also available in the composable options.

In case that you pass an global `onError` and in a composable inside the same context also pass a `onError` the two of them will be called. First the local one followed by the global
2 changes: 2 additions & 0 deletions docs/options.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,5 @@ const { data, error, isValidating, mutate } = useSWR(key, fetcher, options)
- `revalidateOnReconnect = true` - Automatically revalidate when the browser regains a network connection
- `revalidateIfStale = true` - Automatically revalidate if there is stale data
- `dedupingInterval = 2000` - dedupe requests with the same key in this time span in milliseconds
- `onSuccess(data, key, config)` - callback function when a request finishes successfully
- `onError(err, key, config)` - callback function when a request returns an error
9 changes: 8 additions & 1 deletion lib/composables/swr/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,17 @@ export const useSWR = <Data = any, Error = any>(
config: SWRComposableConfig = {},
) => {
const { config: contextConfig, mutate } = useSWRConfig();
const mergedConfig = mergeConfig(contextConfig.value, config);

const {
cacheProvider,
revalidateOnFocus,
revalidateOnReconnect,
revalidateIfStale,
dedupingInterval,
} = mergeConfig(contextConfig.value, config);
onSuccess,
onError,
} = mergedConfig;

const { key, args: fetcherArgs } = toRefs(toReactive(computed(() => serializeKey(_key))));

Expand All @@ -46,8 +49,12 @@ export const useSWR = <Data = any, Error = any>(

data.value = fetcherResponse;
fetchedIn.value = new Date();

if (onSuccess) onSuccess(data.value, key.value, mergedConfig);
} catch (err: any) {
error.value = err;

if (onError) onError(err, key.value, mergedConfig);
} finally {
isValidating.value = false;
}
Expand Down
61 changes: 61 additions & 0 deletions lib/composables/swr/swr.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -470,4 +470,65 @@ describe('useSWR', () => {

expect(differentData.value).toEqual('should not change');
});

it('should call local and global onSuccess if fetcher successes', async () => {
const onSuccess = vi.fn();
const globalOnSuccess = vi.fn();
const fetcherResult = 'result';

useInjectedSetup(
() => configureGlobalSWR({ cacheProvider, onSuccess: globalOnSuccess }),
() => useSWR(defaultKey, () => fetcherResult, { onSuccess }),
);

await flushPromises();
expect(onSuccess).toHaveBeenCalledOnce();
expect(onSuccess).toHaveBeenCalledWith(fetcherResult, defaultKey, expect.anything());
expect(globalOnSuccess).toHaveBeenCalledOnce();
expect(globalOnSuccess).toHaveBeenCalledWith(fetcherResult, defaultKey, expect.anything());
});

it('should call local and global onError if fetcher throws', async () => {
const onError = vi.fn();
const globalOnError = vi.fn();
const error = new Error();

useInjectedSetup(
() => configureGlobalSWR({ cacheProvider, onError: globalOnError }),
() => useSWR(defaultKey, () => Promise.reject(error), { onError }),
);

await flushPromises();
expect(onError).toHaveBeenCalledOnce();
expect(onError).toHaveBeenCalledWith(error, defaultKey, expect.anything());
expect(globalOnError).toHaveBeenCalledOnce();
expect(globalOnError).toHaveBeenCalledWith(error, defaultKey, expect.anything());
});

it('should call local and global onError and onSuccess with local and global configs merged', async () => {
const onError = vi.fn();
const globalOnError = vi.fn();
const error = new Error();

const localConfig: SWRComposableConfig = { dedupingInterval: 1 };
const globalConfig: SWRComposableConfig = { revalidateOnFocus: false };
const mergedConfig = { ...localConfig, ...globalConfig };

useInjectedSetup(
() => configureGlobalSWR({ ...globalConfig, cacheProvider, onError: globalOnError }),
() => useSWR(defaultKey, () => Promise.reject(error), { ...localConfig, onError }),
);

await flushPromises();
expect(onError).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining(mergedConfig),
);
expect(globalOnError).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.objectContaining(mergedConfig),
);
});
});
3 changes: 2 additions & 1 deletion lib/config/injection-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,9 @@ import { DeepReadonly, InjectionKey, Ref } from 'vue';
import { SWRConfig } from '@/types';

/**
* Key for provide and get current context configs
* @internal
*/
export const globalConfigKey = Symbol('SWR global config key') as InjectionKey<
DeepReadonly<Ref<Required<SWRConfig>>>
DeepReadonly<Ref<SWRConfig>>
>;
2 changes: 1 addition & 1 deletion lib/config/swr-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { reactive } from 'vue';
import { SWRConfig } from '@/types';
import { MapAdapter } from '@/cache';

export const defaultConfig: Required<SWRConfig> = {
export const defaultConfig: SWRConfig = {
cacheProvider: reactive(new MapAdapter()),
revalidateOnFocus: true,
revalidateOnReconnect: true,
Expand Down
48 changes: 41 additions & 7 deletions lib/types/lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,46 @@ export type CacheState = {
fetchedIn: Ref<Date>;
};

export type SWRConfig = {
cacheProvider?: CacheProvider<CacheState>;
revalidateOnFocus?: boolean;
revalidateOnReconnect?: boolean;
revalidateIfStale?: boolean;
dedupingInterval?: number;
export type SWRConfig<Data = any, Err = any> = {
/**
* stores the cached values
* @default new Map()
*/
cacheProvider: CacheProvider<CacheState>;

/**
* automatically revalidate when window gets focused
* @default true
*/
revalidateOnFocus: boolean;

/**
* automatically revalidate when the browser regains a network connection (via `navigator.onLine`)
* @default true
*/
revalidateOnReconnect: boolean;

/**
* automatically revalidate even if there is stale data
* @default true
*/
revalidateIfStale: boolean;

/**
* dedupe requests with the same key in this time span in miliseconds
* @default 2000
*/
dedupingInterval: number;

/**
* called when a request finishes successfully
*/
onSuccess?: (data: Data, key: string, config: SWRConfig<Data, Err>) => void;

/**
* called when a request returns an error
*/
onError?: (err: Err, key: string, config: SWRConfig<Data, Err>) => void;
};

export type SWRComposableConfig = Omit<SWRConfig, 'cacheProvider'>;
export type SWRComposableConfig = Omit<Partial<SWRConfig>, 'cacheProvider'>;
21 changes: 21 additions & 0 deletions lib/utils/chain-fn/chain-fn.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { chainFns } from '.';

describe('chainFn', () => {
it('should call all cahined functions', () => {
const functions = [vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn()];
const chainedFn = chainFns(...functions);

chainedFn();

functions.forEach((fn) => expect(fn).toHaveBeenCalledOnce());
});

it('should call all chained functions with the same arguments', () => {
const functions = [vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn(), vi.fn()];
const chainedFn = chainFns(...functions);

chainedFn('arg1', 'arg2', 3);

functions.forEach((fn) => expect(fn).toHaveBeenCalledWith('arg1', 'arg2', 3));
});
});
8 changes: 8 additions & 0 deletions lib/utils/chain-fn/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { AnyFunction } from '@/types';

export const chainFns = <F extends (AnyFunction | undefined)[]>(...fns: F) => {
const validFns = fns.filter(<T>(maybeFn: T | undefined): maybeFn is T => !!maybeFn);

return (...params: Parameters<Exclude<F[number], undefined>>) =>
validFns.forEach((fn) => fn(...params));
};
1 change: 1 addition & 0 deletions lib/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './check-types';
export * from './hash';
export * from './serialize-key';
export * from './merge-config';
export * from './chain-fn';
18 changes: 14 additions & 4 deletions lib/utils/merge-config/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
import { SWRConfig } from '@/types';

import { chainFns } from '../chain-fn';

export const mergeConfig = <T extends Partial<SWRConfig>, D extends Partial<SWRConfig>>(
fallbackConfig: T,
config: D,
) => ({
...fallbackConfig,
...config,
});
) => {
const onSuccess = [config.onSuccess, fallbackConfig.onSuccess].filter(Boolean);
const onError = [config.onError, fallbackConfig.onError].filter(Boolean);

return {
...fallbackConfig,
...config,

onSuccess: onSuccess.length > 0 ? chainFns(...onSuccess) : undefined,
onError: onError.length > 0 ? chainFns(...onError) : undefined,
};
};
42 changes: 42 additions & 0 deletions lib/utils/merge-config/merge-config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,46 @@ describe('mergeConfig', () => {
])('should merge two configs: %s', (obj1, obj2, expectedResult) => {
expect(mergeConfig(obj1, obj2)).toEqual(expectedResult);
});

it('should also merge callbacks', () => {
const globalConfig = Object.freeze({
onError: vi.fn(),
onSuccess: vi.fn(),
});

const localConfig = Object.freeze({
onError: vi.fn(),
onSuccess: vi.fn(),
});

const { onError, onSuccess } = mergeConfig(globalConfig, localConfig);

onError();
onSuccess();

expect(globalConfig.onSuccess).toHaveBeenCalledOnce();
expect(globalConfig.onError).toHaveBeenCalledOnce();
expect(localConfig.onSuccess).toHaveBeenCalledOnce();
expect(localConfig.onError).toHaveBeenCalledOnce();
});

it('should not throw when one of the callbacks is missing', () => {
const globalConfig = Object.freeze({ onSuccess: vi.fn() });
const localConfig = Object.freeze({ onError: vi.fn() });

const { onError, onSuccess } = mergeConfig(globalConfig, localConfig);

onError();
onSuccess();

expect(globalConfig.onSuccess).toHaveBeenCalledOnce();
expect(localConfig.onError).toHaveBeenCalledOnce();
});

it('should return undefined when callback is missing in either configs', () => {
const { onError, onSuccess } = mergeConfig({}, {});

expect(onError).toBeUndefined();
expect(onSuccess).toBeUndefined();
});
});

0 comments on commit caefe9b

Please sign in to comment.