Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -570,6 +570,7 @@ You can also use all native [`fetch()` settings](https://developer.mozilla.org/e
| cacheTime | `number` | `undefined` | Specifies the duration, in seconds, for which a cache entry is considered "fresh." Once this time has passed, the entry is considered stale and may be refreshed with a new request. Set to -1 for indefinite cache. By default no caching. |
| staleTime | `number` | `undefined` | Specifies the duration, in seconds, for which cached data is considered "fresh." During this period, cached data will be returned immediately, but a background revalidation (network request) will be triggered to update the cache. If set to `0`, background revalidation is disabled and revalidation is triggered on every access. |
| cacheStore | `CacheStore` | `undefined` | An object with `get`, `set` and `delete` methods that keeps cached responses beyond the in-memory cache, e.g. across page reloads. It can wrap localStorage, IndexedDB, AsyncStorage or any other storage, and its methods can be asynchronous. Cached successful responses are saved in it, and requests that miss the in-memory cache restore them from it. See **Persistent Cache** in the Cache Management section. |
| etag | `boolean` | `true` | Revalidates cached responses of GET and HEAD requests with their `ETag`. It's sent in the `If-None-Match` header, and when the server answers `304 Not Modified`, the cached response is reused instead of being downloaded again. Requests with a custom `fetcher` only send it when set to `true`. See **ETag Revalidation** in the Cache Management section. |
| refetchOnFocus | `boolean` | `false` | When set to `true`, automatically revalidates (refetches) data when the browser window regains focus. **Note: This bypasses the cache and always makes a fresh network request** to ensure users see the most up-to-date data when they return to your application from another tab or window. Particularly useful for applications that display real-time or frequently changing data, but should be used judiciously to avoid unnecessary network traffic. |
| refetchOnReconnect | `boolean` | `false` | When set to `true`, automatically revalidates (refetches) data when the browser regains internet connectivity after being offline. **This uses background revalidation to silently update data** without showing loading states to users. Helps ensure your application displays fresh data after network interruptions. Works by listening to the browser's `online` event. |
| logger | `Logger` | `null` | You can additionally specify logger object with your custom logger to automatically log the errors to the console. It should contain at least `error` and `warn` functions. |
Expand Down Expand Up @@ -1259,6 +1260,11 @@ The caching system can be fine-tuned using the following options when configurin
An object with `get`, `set` and `delete` methods that keeps cached responses beyond the in-memory cache, e.g. across page reloads or app restarts. Its methods can be synchronous or asynchronous, so it can wrap localStorage, IndexedDB, AsyncStorage or any other storage. See [Persistent Cache](#persistent-cache) below.
_Default:_ `undefined` (in-memory cache only).

- **`etag`**:
Type: `boolean`
Revalidates cached responses of GET and HEAD requests with their `ETag`, so that the server can answer `304 Not Modified` instead of sending them again. See [ETag Revalidation](#etag-revalidation) below.
_Default:_ `true` (`false` for requests with a custom `fetcher`).

### How It Works
1. **Cache Lookup**:
When a request is made, `fetchff` first checks the internal cache for a matching entry using the generated cache key. If a valid and "fresh" cache entry exists (within `cacheTime`), the cached response is returned immediately. If the native `fetch()` option `cache: 'reload'` is set, the internal cache is bypassed and a fresh request is made.
Expand All @@ -1278,6 +1284,9 @@ The caching system can be fine-tuned using the following options when configurin
6. **Persistent Cache**:
With a `cacheStore`, successful responses are also saved in the store whenever they are cached. A request that misses the in-memory cache restores its response from the store before going to the network, unless the stored entry has expired.

7. **ETag Revalidation**:
When a cached response has an `ETag`, requests that revalidate it send the ETag in the `If-None-Match` header. If the server answers `304 Not Modified`, the cached response is reused and cached again instead of being downloaded again.

### Persistent Cache

By default, the cache lives in memory, so it's gone after a page reload or app restart. Pass a `cacheStore` to keep cached responses in a persistent storage. It's an object with `get`, `set` and `delete` methods, which can be synchronous or return promises:
Expand Down Expand Up @@ -1325,6 +1334,29 @@ How the store is used:
- **Bypassing**: `cacheBuster` and `cache: 'reload'` bypass the store just like the in-memory cache.
- **Errors**: Errors of the store, e.g. a full localStorage, are ignored, so requests never fail because of it.

### ETag Revalidation

Many servers identify each version of a response with an `ETag` header. When a cached response has one, the requests that revalidate it, e.g. background revalidations of stale data, `refetchOnFocus` or requests with a `cacheBuster`, ask the server whether it changed by sending the ETag in the `If-None-Match` header. If it didn't, the server answers `304 Not Modified` without a body, and the cached response is reused instead of being downloaded and parsed again. It works out of the box:

```typescript
// The response is cached along with its ETag, e.g. "v1"
const { data } = await fetchf('https://api.example.com/books', {
cacheTime: 300,
staleTime: 60, // Once the books are a minute old, they are revalidated in the background
});

// The background revalidation sends "If-None-Match: v1". When the books haven't changed,
// the server answers 304 Not Modified, and the cached response stays fresh for another minute.
```

- It applies to GET and HEAD requests that use the cache (with `cacheTime` or `staleTime`). Set `etag: false` to disable it.
- A reused response is the same object with the same data. It isn't parsed, transformed with `select` or passed to `onResponse` again, and e.g. `useFetcher()` doesn't rerender after a background revalidation that is answered with `304`.
- It pairs well with a `cacheStore`: after a page reload, a stale stored response is revalidated with its ETag too.
- `mutate()` removes the ETag from the response it changes, as it no longer matches the data.
- Requests with an `If-None-Match` header of their own are sent as they are, and handle `304` responses themselves.
- Requests with a custom `fetcher` only send ETags with `etag: true`, as some fetchers, e.g. ones built on axios, treat `304` responses as errors.
- In browsers, the ETag of a cross-origin response can only be read when the server exposes it with `Access-Control-Expose-Headers: ETag`. Such servers must also allow the `If-None-Match` header with `Access-Control-Allow-Headers`, otherwise set `etag: false`.

### 🔄 Cache and Deduplication Integration

Understanding how caching works together with request deduplication is crucial for optimal performance:
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -73,23 +73,23 @@
"size-limit": [
{
"path": "dist/browser/index.mjs",
"limit": "7.75 KB"
"limit": "8.75 KB"
},
{
"path": "dist/browser/index.global.js",
"limit": "7.75 KB"
"limit": "8.75 KB"
},
{
"path": "dist/node/index.js",
"limit": "7.75 KB"
"limit": "8.75 KB"
},
{
"path": "dist/react/index.mjs",
"limit": "10.75 KB"
"limit": "11.75 KB"
},
{
"path": "dist/react/index.js",
"limit": "11.75 KB"
"limit": "12.75 KB"
}
],
"devDependencies": {
Expand Down
6 changes: 6 additions & 0 deletions src/cache-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,12 @@ export async function mutate<
data: updatedData,
};

// The ETag belongs to the original data, so the changed data must not be revalidated with it
if (updatedResponse.headers?.etag) {
updatedResponse.headers = { ...updatedResponse.headers };
delete updatedResponse.headers.etag;
}

const updatedEntry = {
...entry,
data: updatedResponse,
Expand Down
61 changes: 54 additions & 7 deletions src/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type {
DefaultResponse,
RequestConfig,
FetchResponse,
HeadersObject,
} from './types/request-handler';
import type {
DefaultParams,
Expand All @@ -11,7 +12,7 @@ import type {
import type { StoredResponse } from './types/cache-manager';
import { applyInterceptors } from './interceptor-manager';
import { ResponseError } from './errors/response-error';
import { isObject, noop } from './utils';
import { isObject, noop, processHeaders } from './utils';
import {
markInFlight,
setInFlightPromise,
Expand All @@ -20,6 +21,7 @@ import {
import { parseResponseData, prepareResponse } from './response-parser';
import {
generateCacheKey,
getCacheData,
getCachedResponse,
getStoredCache,
restoreCache,
Expand All @@ -31,7 +33,7 @@ import { fetchWithUploadProgress } from './upload-progress';
import { notifySubscribers } from './pubsub-manager';
import { addRevalidator } from './revalidator-manager';
import { enhanceError, withErrorHandling } from './error-handler';
import { FUNCTION } from './constants';
import { FUNCTION, GET, HEAD } from './constants';
import { buildConfig } from './config-handler';

const inFlightResponse = Object.freeze({
Expand Down Expand Up @@ -106,6 +108,14 @@ export async function fetchf<
pollingInterval = 0,
} = fetcherConfig;
const isCacheEnabled = cacheTime !== undefined || staleTime !== undefined;
const method = fetcherConfig.method;

// Cached responses of GET and HEAD requests are revalidated with their ETag.
// Custom fetchers may not handle 304 responses, so they only send it when it's enabled for them.
const isETagEnabled =
isCacheEnabled &&
(method === GET || method === HEAD) &&
(fetcherConfig.etag ?? !fetcherConfig.fetcher);

const needsCacheKey = !!(
cacheKey ||
Expand Down Expand Up @@ -178,6 +188,13 @@ export async function fetchf<

// The actual request logic as a function (one poll attempt, with retries)
const doRequestOnce = async (isStaleRevalidation: boolean, attempt = 0) => {
// The cached response to revalidate. It's read before the cache is marked as fetching below.
const cached =
isETagEnabled &&
getCacheData<ResponseData, RequestBody, QueryParams, PathParams>(
_cacheKey,
);

// If cache key is specified, we will handle optimistic updates
// and mark the request as in-flight, so to catch "fetching" state.
// This is useful for Optimistic UI updates (e.g., showing loading spinners).
Expand Down Expand Up @@ -254,19 +271,17 @@ export async function fetchf<

// Custom fetcher
const fn = fetcherConfig.fetcher;
const init = withETag(requestConfig, cached);

response = (fn
? await fn<ResponseData, RequestBody, QueryParams, PathParams>(
url,
requestConfig,
init,
)
: // Uploads with onUploadProgress are sent in a way that reports their progress
await (requestConfig.onUploadProgress && requestConfig.body
? fetchWithUploadProgress(url, requestConfig)
: fetch(
url,
requestConfig as RequestInit,
))) as unknown as FetchResponse<
: fetch(url, init as RequestInit))) as unknown as FetchResponse<
ResponseData,
RequestBody,
QueryParams,
Expand All @@ -275,6 +290,17 @@ export async function fetchf<

// Custom fetcher may return a raw data object instead of a Response instance
if (isObject(response)) {
// The server answered that the cached response is still valid, so it's reused instead of being sent again.
// Like other cached responses, it has been parsed and transformed already.
if (init !== requestConfig && response.status === 304) {
return cached as FetchResponse<
ResponseData,
RequestBody,
QueryParams,
PathParams
>;
}

// Case 1: Native Response instance
if (typeof Response === FUNCTION && response instanceof Response) {
response.data = requestConfig.parser
Expand Down Expand Up @@ -483,3 +509,24 @@ function toResponse<ResponseData, RequestBody, QueryParams, PathParams>(
return null;
}
}

/**
* Asks the server whether a cached response is still valid, by sending its ETag in the If-None-Match header.
* Then the server can answer 304 Not Modified instead of sending the response again.
* Requests with an If-None-Match header of their own are sent as they are.
*
* @param config - The request configuration.
* @param cached - The cached response of the request, if any.
* @returns A copy of the configuration with the If-None-Match header, or the configuration itself if the cached response has no ETag.
*/
function withETag(
config: RequestConfig,
cached: FetchResponse | null | false,
): RequestConfig {
const etag = cached && !cached.error && cached.headers?.etag;
const headers = etag && processHeaders(config.headers as HeadersObject);

return headers && !headers['if-none-match']
? { ...config, headers: { ...headers, 'if-none-match': etag } }
: config;
}
10 changes: 10 additions & 0 deletions src/types/request-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,16 @@ export interface CacheOptions<
*/
cacheStore?: CacheStore;

/**
* If `true`, cached responses of GET and HEAD requests are revalidated with their ETag.
* The request sends it in the `If-None-Match` header, and when the server answers `304 Not Modified`,
* the cached response is reused instead of being downloaded and parsed again.
* Requests with an `If-None-Match` header of their own are sent as they are.
* Cross-origin servers must expose the `ETag` header and allow the `If-None-Match` header for it.
* @default true (false for requests with a custom `fetcher`, which may not handle 304 responses)
*/
etag?: boolean;

/**
* INTERNAL, DO NOT USE.
* This is used internally to mark requests that have cache keys generated automatically.
Expand Down
Loading
Loading