Skip to content

Commit

Permalink
Add support of composite keys caching
Browse files Browse the repository at this point in the history
  • Loading branch information
redneckz committed May 4, 2023
1 parent e3ca399 commit 2e77adf
Show file tree
Hide file tree
Showing 8 changed files with 63 additions and 51 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
{
"typescript.enablePromptUseWorkspaceTsdk": true,
"editor.formatOnSave": true
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
13 changes: 6 additions & 7 deletions demo/ui-kit/src/Joke.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { JSX } from '@redneckz/uni-jsx';
import { useCallback } from '@redneckz/uni-jsx/lib/hooks';
import { useCallback, useMemo } from '@redneckz/uni-jsx/lib/hooks';
import { useAsyncData } from '@redneckz/uni-jsx/lib/hooks/useAsyncData';

const CHUCK_JOKE_URL = 'https://api.chucknorris.io/jokes/random';
Expand Down Expand Up @@ -34,17 +34,16 @@ const CATEGORIES = [

export const Joke = JSX<JokeProps>(({ timeout = 0, rnd = 0 }) => {
const fetcher = useCallback(
async (url: string) => {
async (url: string, category: string) => {
await delay(timeout);
return fetchJSON<ChuckJoke>(url);
return fetchJSON<ChuckJoke>(`${url}?category=${category}`);
},
[timeout]
);

const { data: chuckJoke } = useAsyncData<ChuckJoke>(
`${CHUCK_JOKE_URL}?category=${CATEGORIES[rnd % CATEGORIES.length]}`,
fetcher
);
const key: [string, string] = useMemo(() => [CHUCK_JOKE_URL, CATEGORIES[rnd % CATEGORIES.length]], [rnd]);

const { data: chuckJoke } = useAsyncData(key, fetcher);

return (
<section>
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,6 @@
"start:preact": "npm run start -w @demo/preact-demo"
},
"dependencies": {
"@redneckz/uni-jsx": "^2.6.0"
"@redneckz/uni-jsx": "^2.6.1"
}
}
2 changes: 1 addition & 1 deletion uni-jsx/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@redneckz/uni-jsx",
"version": "2.6.0",
"version": "2.6.1",
"license": "MIT",
"author": {
"name": "redneckz",
Expand Down
34 changes: 22 additions & 12 deletions uni-jsx/src/hooks/AsyncCache.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,39 @@
import { isFilled } from './isFilled';

export interface AsyncCache<K = string, V = any> {
get(key: K): Promise<V> | undefined;
has(key: K): boolean;
set(key: K, value: Promise<V>): this;
get(key: K): Promise<V> | V | undefined;
set(key: K, value: Promise<V> | V): void;
delete(key: K): boolean;
clear(): void;
}

const defaultCache: AsyncCache = new Map();
export const noCache: AsyncCache = {
get: () => undefined,
set: () => {},
delete: () => false,
clear: () => {}
};

export const defaultCache: AsyncCache = new Map();

export const applyCache =
<Data>(fetcher: (key: string) => Promise<Data> | Data, cache = defaultCache) =>
(key: string): Promise<Data> | Data => {
<Data>(fetcher: (...args: any[]) => Promise<Data> | Data, cache = defaultCache) =>
(args: any[]): Promise<Data> | Data => {
const key = computeKey(args);

const cachedData = cache.get(key);
if (isFilled(cachedData)) {
return cachedData as Promise<Data>;
if (key && isFilled(cachedData)) {
return cachedData as Promise<Data> | Data;
}

const data = fetcher(key);
if (isFilled(data)) {
const data = fetcher(...args);
if (key && isFilled(data)) {
cache.set(key, Promise.resolve(data));
} else {
cache.delete(key);
}

return data;
};

const isKey = (args: any[]): args is any[] =>
Boolean(args?.every(_ => (Array.isArray(_) ? isKey(_) : !_ || typeof _ === 'string')));
const computeKey = (args: any[]): string => (isKey(args) ? args.toString() : '');
37 changes: 22 additions & 15 deletions uni-jsx/src/hooks/useAsyncData.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { AsyncCache } from './AsyncCache';
import { AsyncCache, noCache } from './AsyncCache';
import { useAsyncData } from './useAsyncData';

let stateSlots = [undefined, undefined]; // Data and Error
Expand All @@ -22,14 +22,6 @@ jest.mock('./core', () => ({
const DATA_KEY = 'dummyKey';

describe('useAsyncData', () => {
const cache: AsyncCache = {
get: () => undefined,
set: () => cache,
has: () => false,
delete: () => false,
clear: () => {}
};

beforeEach(() => {
stateSlots = [undefined, undefined];
slotIndex = 0;
Expand All @@ -41,9 +33,9 @@ describe('useAsyncData', () => {
const dummyData = { value: 'dummyValue' };
const fetcher = () => dummyData;

useAsyncData(DATA_KEY, fetcher, { cache });
useAsyncData(DATA_KEY, fetcher, { cache: noCache });
await DATA_KEY;
const { data, error } = useAsyncData(DATA_KEY, fetcher, { cache });
const { data, error } = useAsyncData(DATA_KEY, fetcher, { cache: noCache });

expect(data).toEqual(dummyData);
expect(error).toBeFalsy();
Expand All @@ -57,8 +49,8 @@ describe('useAsyncData', () => {
throw dummyError;
};

useAsyncData(DATA_KEY, errorFetcher, { cache });
const { data, error } = useAsyncData(DATA_KEY, errorFetcher, { cache });
useAsyncData(DATA_KEY, errorFetcher, { cache: noCache });
const { data, error } = useAsyncData(DATA_KEY, errorFetcher, { cache: noCache });

expect(data).toBeFalsy();
expect(error).toEqual(dummyError);
Expand All @@ -68,9 +60,24 @@ describe('useAsyncData', () => {
const fallbackData = { value: 'fallbackValue' };
const fetcher = () => ({ value: 'dummyValue' });

useAsyncData(DATA_KEY, fetcher, { fallback: { key: fallbackData }, cache });
const { data } = useAsyncData(DATA_KEY, fetcher, { cache });
useAsyncData(DATA_KEY, fetcher, { fallback: { key: fallbackData }, cache: noCache });
const { data } = useAsyncData(DATA_KEY, fetcher, { cache: noCache });

expect(data).toEqual(fallbackData);
});

it('should return data from cache if available', () => {
const cache: AsyncCache = {
get: key => (key === DATA_KEY ? 'cachedValue' : undefined),
set: () => {},
delete: () => false,
clear: () => {}
};
const fetcher = () => ({ value: 'dummyValue' });

useAsyncData(DATA_KEY, fetcher, { cache });
const { data } = useAsyncData(DATA_KEY, fetcher, { cache });

expect(data).toBe('cachedValue');
});
});
19 changes: 7 additions & 12 deletions uni-jsx/src/hooks/useAsyncData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,13 @@ import { isFilled } from './isFilled';
import { AsyncCache, applyCache } from './AsyncCache';

type Empty = null | undefined | false;
type ArgumentsTuple = [any, ...unknown[]] | readonly [any, ...unknown[]];
type SimpleKey = string;
type Arguments = SimpleKey | ArgumentsTuple | Record<any, any> | Empty;
type ArgumentsTuple = [any, ...unknown[]];
type Arguments = string | ArgumentsTuple | Empty;

export type Key = Arguments | (() => Arguments);

type FetcherResponse<Data = unknown> = Data | Promise<Data>;

type SimpleFetcher<Data = unknown> = (key: SimpleKey) => FetcherResponse<Data>;

export type Fetcher<Data = unknown, K extends Key = Key> = K extends () => readonly [...infer Args] | Empty
? (...args: [...Args]) => FetcherResponse<Data>
: K extends readonly [...infer Args]
Expand All @@ -26,7 +23,7 @@ export type Fetcher<Data = unknown, K extends Key = Key> = K extends () => reado
: never;

export interface AsyncDataOptions {
fallback?: Record<SimpleKey, unknown>;
fallback?: Record<string, unknown>;
cache?: AsyncCache;
}

Expand All @@ -39,7 +36,7 @@ export interface AsyncDataResponse<Data = any, Error = any> {
mutate: Mutator<Data>; // TODO: No supported
}

const isSimpleKey = (args?: any[]): args is [SimpleKey] =>
const isFallbackKey = (args?: any[]): args is [string] =>
Boolean(args && Array.isArray(args) && args.length === 1 && typeof args[0] === 'string');

export function useAsyncData<Data = any, Err = any, K extends Key = string>(
Expand All @@ -49,8 +46,6 @@ export function useAsyncData<Data = any, Err = any, K extends Key = string>(
): AsyncDataResponse<Data, Err> {
const args = useMemo(() => keyToArgs(key), [key]);

const [simpleKey] = isSimpleKey(args) ? args : [];

const [data, setData] = useState<Data | undefined>(undefined);
const [error, setError] = useState<Err | undefined>(undefined);

Expand All @@ -70,7 +65,7 @@ export function useAsyncData<Data = any, Err = any, K extends Key = string>(

(async () => {
try {
setResult(await (simpleKey ? applyCache(fetcher as SimpleFetcher<Data>, cache)(simpleKey) : fetcher(...args)));
setResult(await applyCache(fetcher, cache)(args));
} catch (err) {
setResult(undefined, err as Err);
}
Expand All @@ -81,7 +76,7 @@ export function useAsyncData<Data = any, Err = any, K extends Key = string>(
};
}, [fetcher, cache, args]);

const fallbackData = fallback && simpleKey && (fallback[simpleKey] as Data);
const fallbackData = fallback && isFallbackKey(args) && (fallback[args[0]] as Data);

return {
data: !data && fallbackData ? fallbackData : data,
Expand All @@ -90,7 +85,7 @@ export function useAsyncData<Data = any, Err = any, K extends Key = string>(
};
}

function keyToArgs<K extends Key = null>(key: K): Parameters<Fetcher<unknown, Key>> {
function keyToArgs<K extends Key = null>(key: K): [any, ...unknown[]] {
if (Array.isArray(key)) {
return key;
} else if (key instanceof Function) {
Expand Down

0 comments on commit 2e77adf

Please sign in to comment.