From 0e976ff1bcbee2c4778da03a3ffcb15385cae10a Mon Sep 17 00:00:00 2001 From: MattCCC Date: Sun, 27 Sep 2026 21:10:46 +0000 Subject: [PATCH 1/3] perf: set error properties in the constructor to drop the class field helper from the bundle --- src/errors/fetch-error.ts | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/errors/fetch-error.ts b/src/errors/fetch-error.ts index bea4d873..98206332 100644 --- a/src/errors/fetch-error.ts +++ b/src/errors/fetch-error.ts @@ -16,20 +16,33 @@ export class FetchError< QueryParams = DefaultParams, PathParams = DefaultUrlParams, > extends Error { - status: number; - statusText: string; - config: RequestConfig; - isCancelled: boolean; + // Declared only, and set in the constructor, as class fields would need a helper in the ES2018 bundle + declare status: number; + declare statusText: string; + declare config: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >; + declare isCancelled: boolean; + declare request: RequestConfig< + ResponseData, + QueryParams, + PathParams, + RequestBody + >; + declare response: FetchResponse< + ResponseData, + RequestBody, + QueryParams, + PathParams + > | null; constructor( message: string, - public request: RequestConfig< - ResponseData, - QueryParams, - PathParams, - RequestBody - >, - public response: FetchResponse< + request: RequestConfig, + response: FetchResponse< ResponseData, RequestBody, QueryParams, @@ -38,10 +51,12 @@ export class FetchError< ) { super(message); - this.name = 'FetchError'; + this.request = request; + this.response = response; this.status = response ? response.status : 0; this.statusText = response ? response.statusText : ''; this.config = request; this.isCancelled = false; + this.name = 'FetchError'; } } From 5ae79f3071d42b4c62ade36b36e9e11387b3c6d8 Mon Sep 17 00:00:00 2001 From: MattCCC Date: Sun, 27 Sep 2026 21:10:46 +0000 Subject: [PATCH 2/3] perf: remove duplicated code from caching, config merging, retries and responses --- src/cache-manager.ts | 118 +++++++++++++------------------------ src/config-handler.ts | 52 +++++++--------- src/response-parser.ts | 45 +++++++------- src/retry-handler.ts | 49 ++++++--------- src/revalidator-manager.ts | 37 ++++-------- 5 files changed, 116 insertions(+), 185 deletions(-) diff --git a/src/cache-manager.ts b/src/cache-manager.ts index 9c4cbd9e..a091f72f 100644 --- a/src/cache-manager.ts +++ b/src/cache-manager.ts @@ -42,43 +42,20 @@ const CACHE_KEY_NEEDS_SANITIZE = /[^\w\-_|/:@.?=&~%#]/; // Non-global for fast t /** * Headers that may affect HTTP response content and should be included in cache key generation. - * All header names must be lowercase to match normalized request headers. + * Header names are matched case-insensitively. + * + * - Content negotiation: accept (response format, e.g. JSON or HTML), accept-language (localization), accept-encoding (compression) + * - Authentication: authorization (access to protected resources) + * - Request body metadata: content-type (how the request body is interpreted) + * - Optional: referer (may influence behavior in some APIs), origin (CORS or tenant-specific APIs), + * user-agent (if the server returns client-specific content), cookie (session-based responses; can fragment the cache heavily) + * - Custom headers that may affect response content: x-api-key (token-based access), x-requested-with (AJAX requests), + * x-client-id (per-client/partner identity), x-tenant-id (multi-tenant segmentation), x-user-id (explicit user context), + * x-app-version (version-specific behavior), x-feature-flag (feature rollout), x-device-id (per device/app instance), + * x-platform (e.g. 'ios', 'android', 'web'), x-session-id (if it affects the response directly), x-locale (like accept-language) */ -const CACHE_KEY_HEADER_WHITELIST = new Set([ - // Content negotiation - 'accept', // Affects response format (e.g. JSON, HTML) - 'accept-language', // Affects localization of the response - 'accept-encoding', // Affects response compression (e.g. gzip, br) - - // Authentication - 'authorization', // Affects access to protected resources - - // Request body metadata - 'content-type', // Affects how the request body is interpreted - - // Optional headers - 'referer', // May influence behavior in some APIs - 'origin', // Relevant in CORS or tenant-specific APIs - 'user-agent', // Included only for reason if server returns client-specific content - - // Cookies — only if server uses session-based responses - 'cookie', // Can fragment cache heavily; use only if necessary - - // Custom headers that may affect response content - 'x-api-key', // Token-based access, often affects authorization - 'x-requested-with', // AJAX requests (used historically for distinguishing frontend calls) - 'x-client-id', // Per-client/partner identity; often used in multi-tenant APIs - 'x-tenant-id', // Multi-tenant segmentation; often changes response per tenant - 'x-user-id', // Explicit user context (less common, but may exist) - - 'x-app-version', // Used for version-specific behavior (e.g. mobile apps) - 'x-feature-flag', // Controls feature rollout behavior server-side - 'x-device-id', // Used when response varies per device/app instance - 'x-platform', // e.g. 'ios', 'android', 'web' — used in apps that serve different content - - 'x-session-id', // Only if backend uses it to affect the response directly (rare) - 'x-locale', // Sometimes used in addition to or instead of `accept-language` -]); +const CACHE_KEY_HEADERS = + /^(accept(-language|-encoding)?|authorization|content-type|referer|origin|user-agent|cookie|x-(api-key|requested-with|client-id|tenant-id|user-id|app-version|feature-flag|device-id|platform|session-id|locale))$/i; /** * Generates a unique cache key for a given URL and fetch options, ensuring that key factors @@ -150,7 +127,7 @@ export function generateCacheKey( let str = ''; for (let i = 0; i < len; ++i) { - if (CACHE_KEY_HEADER_WHITELIST.has(keys[i].toLowerCase())) { + if (CACHE_KEY_HEADERS.test(keys[i])) { str += keys[i] + ':' + obj[keys[i]] + ';'; } } @@ -158,17 +135,19 @@ export function generateCacheKey( headersString = hash(str); } + // Concatenate all key parts into a cache key string + // Template literals are apparently slower + const cacheStr = + method + + DELIMITER + + url + + DELIMITER + + credentials + + DELIMITER + + headersString; + // For GET requests, return early with shorter cache key if (method === GET) { - const cacheStr = - method + - DELIMITER + - url + - DELIMITER + - credentials + - DELIMITER + - headersString; - return sanitizeCacheKey(cacheStr); } @@ -204,20 +183,7 @@ export function generateCacheKey( } } - // Concatenate all key parts into a cache key string - // Template literals are apparently slower - const cacheStr = - method + - DELIMITER + - url + - DELIMITER + - credentials + - DELIMITER + - headersString + - DELIMITER + - bodyString; - - return sanitizeCacheKey(cacheStr); + return sanitizeCacheKey(cacheStr + DELIMITER + bodyString); } const objectIds = new WeakMap(); @@ -341,16 +307,20 @@ export function setCache( }); if (ttlMs > 0) { - addTimeout( - 'c:' + key, - () => { - deleteCache(key, true); - }, - ttlMs, - ); + scheduleExpiry(key, ttlMs); } } +/** + * Deletes a cache entry once it expires. + * + * @param {string} key Cache key to utilize + * @param {number} ms - The time until the entry expires, in milliseconds. + */ +function scheduleExpiry(key: string, ms: number): void { + addTimeout('c:' + key, () => deleteCache(key, true), ms); +} + /** * Invalidates (deletes) a cache entry. * @@ -475,13 +445,7 @@ export function restoreCache( stores.set(key, store); if (entry.expiry) { - addTimeout( - 'c:' + key, - () => { - deleteCache(key, true); - }, - entry.expiry - timeNow(), - ); + scheduleExpiry(key, entry.expiry - timeNow()); } notifySubscribers(key, entry.data); @@ -532,8 +496,10 @@ export async function mutate< }; // 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 }; + const headers = updatedResponse.headers; + + if (headers && headers.etag) { + updatedResponse.headers = { ...headers }; delete updatedResponse.headers.etag; } diff --git a/src/config-handler.ts b/src/config-handler.ts index 5173efbc..2fc51304 100644 --- a/src/config-handler.ts +++ b/src/config-handler.ts @@ -56,7 +56,7 @@ export const defaultConfig: RequestConfig = { ], // Idempotent methods, which can be repeated safely, unlike e.g. POST requests that create resources - methods: ['GET', 'HEAD', 'PUT', 'DELETE', 'OPTIONS', 'TRACE'], + methods: [GET, HEAD, 'PUT', 'DELETE', 'OPTIONS', 'TRACE'], }, }; @@ -191,8 +191,7 @@ function setContentTypeIfNeeded( // Types that should not have Content-Type set (browser handles these) if ( body instanceof FormData || // Browser automatically sets multipart/form-data with boundary - (typeof Blob !== UNDEFINED && body instanceof Blob) || // Blob/File already have their own MIME types, don't override - (typeof File !== UNDEFINED && body instanceof File) || + (typeof Blob !== UNDEFINED && body instanceof Blob) || // Blob/File (a File is a Blob) already have their own MIME types, don't override (typeof ReadableStream !== UNDEFINED && body instanceof ReadableStream) // Stream type should be determined by the stream source ) { return; @@ -225,6 +224,14 @@ function setContentTypeIfNeeded( } } +// The interceptors that are merged instead of replaced +const INTERCEPTORS = [ + 'onRequest', + 'onResponse', + 'onError', + 'onLoadingSlow', +] as const; + /** * Merges two request configurations, applying overrides from the second config to the first. * Handles special merging for nested properties like 'retry' and 'headers' (deep merge), @@ -254,10 +261,9 @@ export function mergeConfigs( mergeConfig('headers', baseConfig, overrideConfig, targetConfig); // Merge interceptors efficiently - mergeInterceptors('onRequest', baseConfig, overrideConfig, targetConfig); - mergeInterceptors('onResponse', baseConfig, overrideConfig, targetConfig); - mergeInterceptors('onError', baseConfig, overrideConfig, targetConfig); - mergeInterceptors('onLoadingSlow', baseConfig, overrideConfig, targetConfig); + for (const property of INTERCEPTORS) { + mergeInterceptors(property, baseConfig, overrideConfig, targetConfig); + } return targetConfig; } @@ -277,30 +283,16 @@ function mergeInterceptors< const baseInterceptor = baseConfig[property]; const newInterceptor = overrideConfig[property]; - if (!baseInterceptor && !newInterceptor) { - return; - } - - if (!baseInterceptor) { - targetConfig[property] = newInterceptor; - return; + if (baseInterceptor && newInterceptor) { + // This is the only LIFO interceptor, so we apply it after the response is prepared + targetConfig[property] = ( + property === 'onResponse' + ? [].concat(newInterceptor as never, baseInterceptor as never) + : [].concat(baseInterceptor as never, newInterceptor as never) + ) as RequestConfig[K]; + } else if (baseInterceptor || newInterceptor) { + targetConfig[property] = baseInterceptor || newInterceptor; } - - if (!newInterceptor) { - targetConfig[property] = baseInterceptor; - return; - } - - const baseArr = Array.isArray(baseInterceptor) - ? baseInterceptor - : [baseInterceptor]; - const newArr = Array.isArray(newInterceptor) - ? newInterceptor - : [newInterceptor]; - - // This is the only LIFO interceptor, so we apply it after the response is prepared - targetConfig[property] = - property === 'onResponse' ? newArr.concat(baseArr) : baseArr.concat(newArr); } /** diff --git a/src/response-parser.ts b/src/response-parser.ts index 69de8c4a..9bf90049 100644 --- a/src/response-parser.ts +++ b/src/response-parser.ts @@ -40,7 +40,8 @@ export async function parseResponseData< } // Get the content-type header once - let contentType = (response as Response).headers?.get(CONTENT_TYPE); + const headers = (response as Response).headers; + let contentType = headers && headers.get(CONTENT_TYPE); if (contentType) { // Lowercase and trim for consistent matching @@ -207,26 +208,18 @@ export const prepareResponse = < statusText: response.statusText, // Convert methods to use arrow functions to preserve correct return types - blob: () => - Promise.resolve( - data instanceof ArrayBuffer ? new Blob([data]) : new Blob(), - ), // Lazily construct Blob from ArrayBuffer - json: () => Promise.resolve(data as ResponseData), // Return the already parsed JSON data - text: () => Promise.resolve(data as string), // Return the already parsed text data + // Lazily construct Blob from ArrayBuffer + blob: async () => + data instanceof ArrayBuffer ? new Blob([data]) : new Blob(), + json: async () => data as ResponseData, // Return the already parsed JSON data + text: async () => data as string, // Return the already parsed text data // The body was already read, so a copy of this response is returned instead of Response.clone() clone: () => ({ ...output }), - arrayBuffer: () => - Promise.resolve( - data instanceof ArrayBuffer ? data : new ArrayBuffer(0), - ), // Return the ArrayBuffer directly - formData: () => - Promise.resolve(data instanceof FormData ? data : new FormData()), // Return the already parsed FormData - bytes: () => - Promise.resolve( - new Uint8Array( - data instanceof ArrayBuffer ? data : new ArrayBuffer(0), - ), - ), + arrayBuffer: async () => + data instanceof ArrayBuffer ? data : new ArrayBuffer(0), // Return the ArrayBuffer directly + formData: async () => (data instanceof FormData ? data : new FormData()), // Return the already parsed FormData + bytes: async () => + new Uint8Array(data instanceof ArrayBuffer ? data : new ArrayBuffer(0)), // Enhance the response with extra information error, data, @@ -243,12 +236,14 @@ export const prepareResponse = < // If it's a custom fetcher, and it does not return any Response instance, it may have its own internal handler if (isObject(response)) { - response.error = error; - response.headers = headers; - response.isFetching = false; - response.mutate = mutatator; - response.isSuccess = response.ok && !error; - response.isError = !!error; + Object.assign(response, { + error, + headers, + isFetching: false, + mutate: mutatator, + isSuccess: response.ok && !error, + isError: !!error, + }); } return response; diff --git a/src/retry-handler.ts b/src/retry-handler.ts index dffa281e..3b4131ad 100644 --- a/src/retry-handler.ts +++ b/src/retry-handler.ts @@ -149,43 +149,32 @@ export async function withRetry< output = await requestFn(attempt > 0, attempt); const error = output.error; - // Check if we should retry based on successful response - if (!error) { - if (shouldRetry && attempt < maxRetries) { - const shouldRetryResult = await shouldRetry(output, attempt); - - if (shouldRetryResult) { - await delayInvocation(withJitter(waitTime, jitter)); - waitTime *= backoff || 1; - waitTime = Math.min(waitTime, maxDelay || waitTime); - attempt++; - continue; - } - } - - break; - } - - // Determine if we should stop retrying - const shouldStopRetrying = await getShouldStopRetrying( - output, - attempt, - maxRetries, - shouldRetry, - retryOn, - methods, - ); - - if (shouldStopRetrying) { + // Stop unless a failed response should be retried according to the retry settings, + // or a successful one according to shouldRetry + if ( + error + ? await getShouldStopRetrying( + output, + attempt, + maxRetries, + shouldRetry, + retryOn, + methods, + ) + : !( + shouldRetry && + attempt < maxRetries && + (await shouldRetry(output, attempt)) + ) + ) { break; } // The delay before the next retry, randomized with jitter unless the server asks for one below let retryDelay = withJitter(waitTime, jitter); - // If we should not stop retrying, continue to the next attempt // Handle rate limiting if the error status is 429 (Too Many Requests) or 503 (Service Unavailable) - if (error.status === 429 || error.status === 503) { + if (error && (error.status === 429 || error.status === 503)) { // Try to extract the "Retry-After" value from the response headers const retryAfterMs = getRetryAfterMs(output); diff --git a/src/revalidator-manager.ts b/src/revalidator-manager.ts index 8c3ca7fd..a28ea788 100644 --- a/src/revalidator-manager.ts +++ b/src/revalidator-manager.ts @@ -230,30 +230,19 @@ export function addRevalidator( refetchOnReconnect?: boolean, focusThrottleInterval?: number, ) { - const existing = revalidators.get(key); - - if (existing) { - // Update in-place to avoid allocating a new tuple array - existing[0] = revalidatorFn; - existing[1] = timeNow(); - existing[2] = ttl ?? DEFAULT_TTL; - existing[3] = staleTime; - existing[4] = bgRevalidatorFn; - existing[5] = refetchOnFocus; - existing[6] = refetchOnReconnect; - existing[7] = focusThrottleInterval; - } else { - revalidators.set(key, [ - revalidatorFn, - timeNow(), - ttl ?? DEFAULT_TTL, - staleTime, - bgRevalidatorFn, - refetchOnFocus, - refetchOnReconnect, - focusThrottleInterval, - ]); - } + // Existing entries are updated in place + const entry = revalidators.get(key) || ([] as unknown as RevalidatorEntry); + + entry[0] = revalidatorFn; + entry[1] = timeNow(); + entry[2] = ttl ?? DEFAULT_TTL; + entry[3] = staleTime; + entry[4] = bgRevalidatorFn; + entry[5] = refetchOnFocus; + entry[6] = refetchOnReconnect; + entry[7] = focusThrottleInterval; + + revalidators.set(key, entry); if (refetchOnFocus) { addEventHandler('focus'); From 161bbcdbf8708c2a7f2a7ddc366419756b41cd9b Mon Sep 17 00:00:00 2001 From: MattCCC Date: Sun, 27 Sep 2026 21:10:46 +0000 Subject: [PATCH 3/3] perf: avoid transpiled optional chaining and redundant promise wrapping --- src/api-handler.ts | 6 +++--- src/error-handler.ts | 16 ++++++++-------- src/inflight-manager.ts | 2 +- src/polling-handler.ts | 2 +- src/react/index.ts | 6 +++--- src/request-handler.ts | 6 ++++-- src/utils.ts | 6 ++++-- 7 files changed, 24 insertions(+), 20 deletions(-) diff --git a/src/api-handler.ts b/src/api-handler.ts index 4468ed0b..24097669 100644 --- a/src/api-handler.ts +++ b/src/api-handler.ts @@ -54,10 +54,10 @@ function createApiFetcher< * @param endpointName Endpoint Name * @returns {Promise} */ - function handleNonImplemented(endpointName: string): Promise { + async function handleNonImplemented(endpointName: string): Promise { console.error('Add ' + endpointName + " to 'endpoints'."); - return Promise.resolve(null); + return null; } const apiHandler: ApiHandlerDefaultMethods = { @@ -98,7 +98,7 @@ function createApiFetcher< // Any scheme counts here, as e.g. "http:\\host" or "http:host" can also point to another host const mergedConfig = /^[a-z][a-z\d+\-.]*:/i.test(resolvedUrl) ? // Merge endpoints configs for absolute URLs only if urls match - endpointConfig?.url === url + endpointConfig && endpointConfig.url === url ? mergeConfigs(_endpointConfig, requestConfig) : requestConfig : mergeConfigs(mergeConfigs(config, _endpointConfig), requestConfig); diff --git a/src/error-handler.ts b/src/error-handler.ts index 3a7d850e..ce5fea85 100644 --- a/src/error-handler.ts +++ b/src/error-handler.ts @@ -58,11 +58,10 @@ export async function withErrorHandling< // Only handle the error if the request was not cancelled, or if it was cancelled and rejectCancelled is true. const isCancelled = error.isCancelled; - if (!isCancelled && requestConfig.logger?.warn) { - requestConfig.logger.warn( - 'FETCH ERROR', - redactError(error as ResponseError), - ); + const logger = requestConfig.logger; + + if (!isCancelled && logger && logger.warn) { + logger.warn('FETCH ERROR', redactError(error as ResponseError)); } // The defaultResponse strategy returns the default response in place of the error data. @@ -81,7 +80,7 @@ export async function withErrorHandling< const strategy = requestConfig.strategy; // Reject the promise if (strategy === REJECT) { - return Promise.reject(error); + throw error; } // Hang the promise @@ -155,8 +154,9 @@ export function enhanceError< RequestBody >, ): void { - error.status = error.status || response?.status || 0; - error.statusText = error.statusText || response?.statusText || ''; + error.status = error.status || (response && response.status) || 0; + error.statusText = + error.statusText || (response && response.statusText) || ''; error.config = error.request = requestConfig; error.response = response; error.isCancelled = error.name === ABORT_ERROR; diff --git a/src/inflight-manager.ts b/src/inflight-manager.ts index 4f5744ac..3defcc33 100644 --- a/src/inflight-manager.ts +++ b/src/inflight-manager.ts @@ -159,7 +159,7 @@ export async function getController( ): Promise { const item = inFlight.get(key); - return item?.[0]; + return item && item[0]; } /** diff --git a/src/polling-handler.ts b/src/polling-handler.ts index 3b0bfd0c..058cd5b4 100644 --- a/src/polling-handler.ts +++ b/src/polling-handler.ts @@ -66,7 +66,7 @@ export async function withPolling< await delayInvocation(pollingInterval, signal); // Aborting the signal of the request stops polling - if (signal?.aborted) { + if (signal && signal.aborted) { return output; } } while ( diff --git a/src/react/index.ts b/src/react/index.ts index a062e672..5501f268 100644 --- a/src/react/index.ts +++ b/src/react/index.ts @@ -34,7 +34,7 @@ const DEFAULT_RESULT = Object.freeze({ data: null, error: null, isFetching: false, - mutate: () => Promise.resolve(null), + mutate: async () => null, config: {}, headers: {}, }); @@ -255,7 +255,7 @@ export function useFetcher< const [currUrl, currConfig, currCacheKey] = currentValuesRef.current; if (!currUrl) { - return Promise.resolve(null); + return null; } // Truthy check for forceRefresh to ensure it's a boolean. It is useful in onClick handlers so to avoid additional annonymous function calls. @@ -266,7 +266,7 @@ export function useFetcher< const cached = getCachedResponse(currCacheKey, cacheTime, currConfig); if (cached) { - return Promise.resolve(cached); + return cached; } } diff --git a/src/request-handler.ts b/src/request-handler.ts index a3b98a82..30c39af8 100644 --- a/src/request-handler.ts +++ b/src/request-handler.ts @@ -431,7 +431,9 @@ export async function fetchf< PathParams >(response, requestConfig, error); } finally { - userSignal?.removeEventListener('abort', abort); + if (userSignal) { + userSignal.removeEventListener('abort', abort); + } } return output; @@ -584,7 +586,7 @@ function withETag( config: RequestConfig, cached: FetchResponse | null | false, ): RequestConfig { - const etag = cached && !cached.error && cached.headers?.etag; + const etag = cached && !cached.error && cached.headers && cached.headers.etag; const headers = etag && processHeaders(config.headers as HeadersObject); return headers && !headers['if-none-match'] diff --git a/src/utils.ts b/src/utils.ts index 6cbc0cb1..cebe1596 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -136,7 +136,7 @@ export function appendQueryParams(url: string, params: QueryParams): string { const encode = encodeURIComponent; const add = (k: string, v: any) => { v = typeof v === FUNCTION ? v() : v; - v = v === null ? '' : v === undefined ? '' : v; + v = v === null || v === undefined ? '' : v; s[s.length] = encode(k) + '=' + encode(v); }; @@ -318,7 +318,9 @@ export const delayInvocation = ( new Promise((resolve) => { const done = () => { clearTimeout(timer); - signal?.removeEventListener('abort', done); + if (signal) { + signal.removeEventListener('abort', done); + } resolve(true); }; const timer = setTimeout(done, Math.min(ms, MAX_DELAY_MS));