Skip to content

Commit

Permalink
feat: override cache option
Browse files Browse the repository at this point in the history
  • Loading branch information
arthurfiorette committed Jun 5, 2022
1 parent d87307a commit 268fccb
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 10 deletions.
9 changes: 9 additions & 0 deletions src/cache/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,15 @@ export type CacheProperties<R = unknown, D = unknown> = {
* @link https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control#stale-if-error
*/
staleIfError: StaleIfErrorPredicate<R, D>;

/**
* This options makes the interceptors ignore the available cache and always make a new
* request. But, different from `cache: false`, this will not delete the current cache
* and will update the cache when the request is successful.
*
* @default false
*/
override: boolean;
};

export interface CacheInstance {
Expand Down
4 changes: 3 additions & 1 deletion src/cache/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,9 @@ export function setupCache(

interpretHeader: options.interpretHeader ?? true,

staleIfError: options.staleIfError ?? true
staleIfError: options.staleIfError ?? true,

override: false
};

// Apply interceptors
Expand Down
35 changes: 27 additions & 8 deletions src/interceptors/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
if (config.cache === false) {
if (__ACI_DEV__) {
axios.debug?.({
msg: 'Ignoring cache because config.cache is false',
msg: 'Ignoring cache because config.cache === false',
data: config
});
}
Expand All @@ -43,15 +43,20 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {

// Assumes that the storage handled staled responses
let cache = await axios.storage.get(key, config);
const overrideCache = config.cache.override;

// Not cached, continue the request, and mark it as fetching
emptyOrStale: if (cache.state === 'empty' || cache.state === 'stale') {
ignoreAndRequest: if (
cache.state === 'empty' ||
cache.state === 'stale' ||
overrideCache
) {
/**
* This checks for simultaneous access to a new key. The js event loop jumps on the
* first await statement, so the second (asynchronous call) request may have already
* started executing.
*/
if (axios.waiting[key]) {
if (axios.waiting[key] && !overrideCache) {
cache = (await axios.storage.get(key, config)) as
| CachedStorageValue
| LoadingStorageValue;
Expand All @@ -71,7 +76,7 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
});
}

break emptyOrStale;
break ignoreAndRequest;
}
}

Expand All @@ -88,14 +93,24 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
key,
{
state: 'loading',
previous: cache.state,
previous: overrideCache
? // Simply determine if the request is stale or not
// based if it had previous data or not
cache.data
? 'stale'
: 'empty'
: // Typescript doesn't know that cache.state here can only be 'empty' or 'stale'
(cache.state as 'stale'),

// Eslint complains a lot :)
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
data: cache.data as any,

// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-explicit-any
createdAt: cache.createdAt as any
// If the cache is empty and asked to override it, use the current timestamp
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
createdAt:
// eslint-disable-next-line @typescript-eslint/no-explicit-any
overrideCache && !cache.createdAt ? Date.now() : (cache.createdAt as any)
},
config
);
Expand All @@ -116,7 +131,11 @@ export function defaultRequestInterceptor(axios: AxiosCacheInstance) {
if (__ACI_DEV__) {
axios.debug?.({
id: key,
msg: 'Sending request, waiting for response'
msg: 'Sending request, waiting for response',
data: {
overrideCache,
state: cache.state
}
});
}

Expand Down
2 changes: 1 addition & 1 deletion src/interceptors/response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function defaultResponseInterceptor(
axios.debug?.({
id,
msg: 'Useful response configuration found',
data: { cacheConfig, ttl, cacheResponse: data }
data: { cacheConfig, cacheResponse: data }
});
}

Expand Down
116 changes: 116 additions & 0 deletions test/interceptors/request.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { setTimeout } from 'timers/promises';
import type { LoadingStorageValue } from '../../src';
import type { CacheRequestConfig } from '../../src/cache/axios';
import { mockAxios } from '../mocks/axios';
import { sleep } from '../utils';
Expand Down Expand Up @@ -187,4 +189,118 @@ describe('test request interceptor', () => {
expect(newState).not.toBe('empty');
expect(axios.waiting[ID]).toBeUndefined();
});

it('tests cache.override = true with previous cache', async () => {
const axios = mockAxios();

// First normal request to populate cache
const { id, ...initialResponse } = await axios.get('url');

expect(initialResponse.cached).toBe(false);

// Ensure cache was populated
const c1 = await axios.storage.get(id);
expect(c1.state).toBe('cached');

// Make a request with cache.override = true
const promise = axios.get('url', {
id,
cache: { override: true },

// Simple adapter that resolves after the deferred is completed.
adapter: async (config: CacheRequestConfig) => {
await setTimeout(150);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const response = await axios.defaults.adapter!(config);

// Changes the response to be different from `true` (default)
response.data = 'overridden response';

return response;
}
});

// These two setTimeouts is to ensure this code is executed after
// the request interceptor, but before the response interceptor.
// Leading to test the intermediate loading state.

{
await setTimeout(50);

const c2 = (await axios.storage.get(id)) as LoadingStorageValue;

expect(c2.state).toBe('loading');
expect(c2.previous).toBe('stale');
expect(c2.data).toBe(c1.data);
expect(c2.createdAt).toBe(c1.createdAt);
}

// Waits for the promise completion
const newResponse = await promise;

// This step is after the cache was updated with the new response.
{
const c3 = await axios.storage.get(id);

expect(newResponse.cached).toBe(false);
expect(c3.state).toBe('cached');
expect(c3.data).not.toBe(c1.data); // `'overridden response'`, not `true`
expect(c3.createdAt).not.toBe(c1.createdAt);
}
});

it('tests cache.override = true without previous cache', async () => {
const id = 'CUSTOM_RANDOM_ID';

const axios = mockAxios();

const c1 = await axios.storage.get(id);

expect(c1.state).toBe('empty');

// Make a request with cache.override = true
const promise = axios.get('url', {
id,
cache: { override: true },

// Simple adapter that resolves after the deferred is completed.
adapter: async (config: CacheRequestConfig) => {
await setTimeout(150);

// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
return axios.defaults.adapter!(config);
}
});

// These two setTimeouts is to ensure this code is executed after
// the request interceptor, but before the response interceptor.
// Leading to test the intermediate loading state.

{
await setTimeout(50);

const c2 = (await axios.storage.get(id)) as LoadingStorageValue;

expect(c2.state).toBe('loading');
expect(c2.previous).toBe('empty');

expect(c2.data).toBeUndefined();
expect(c2.createdAt).not.toBe(c1.createdAt);
}

// Waits for the promise completion
const newResponse = await promise;

// This step is after the cache was updated with the new response.
{
const c3 = await axios.storage.get(id);

expect(newResponse.cached).toBe(false);
expect(c3.state).toBe('cached');

expect(c3.data).not.toBeUndefined();
expect(c3.createdAt).not.toBe(c1.createdAt);
}
});
});

0 comments on commit 268fccb

Please sign in to comment.