Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
66df042
fix: Timeout hangs indefinitely in React Native when the server is un…
MattCCC Feb 7, 2026
3c395d5
fix: prevent race condition in fetchf() by yielding to microtask queu…
MattCCC Feb 8, 2026
391aac1
fix: improve async handling in tests by using act() for state updates…
MattCCC Feb 8, 2026
5256870
perf: enhance sanitizeObject to check for dangerous properties before…
MattCCC Feb 8, 2026
35842ed
fix: prevent cache key collisions from over-aggressive sanitization
MattCCC Feb 8, 2026
38292f6
perf: cache time tracking in markInFlight result instead of calling t…
MattCCC Feb 8, 2026
5a490d5
perf: single lookup instead of 2 in ensureListenerSet function
MattCCC Feb 8, 2026
1ea9134
text: adjust expected cache keys
MattCCC Feb 8, 2026
98b84dc
perf: improve request handling for retries with stale revalidation
MattCCC Feb 8, 2026
6247d52
perf: optimize revalidation loop by using for...of instead of forEach
MattCCC Feb 8, 2026
afd016c
fix: enhance timeout handling for sub-second values and improve edge …
MattCCC Feb 8, 2026
c6d3ed3
test: remove unnecessary skipped tests for null, undefined, array, an…
MattCCC Feb 8, 2026
5c4da09
chore: update dev dependencies and react package limit
MattCCC Feb 8, 2026
54c63ae
fix: improve isSlowConnection function to handle non-browser environm…
MattCCC Feb 8, 2026
04abe6b
feat: add support for react-native in exports and keywords
MattCCC Feb 8, 2026
0eb98a5
feat: add React Native support with custom event providers for focus …
MattCCC Feb 8, 2026
c0572a7
test: enhance cache and response handling tests with new scenarios
MattCCC Feb 8, 2026
1689727
chore: build
MattCCC Feb 8, 2026
8f5a389
fix: freeze inFlightResponse object and conditionally register revali…
MattCCC Feb 8, 2026
bcd1fda
perf: simplify promise storage logic in setInFlightPromise function
MattCCC Feb 8, 2026
1217211
perf: remove redundant “stale” check for cache reads as it's handled …
MattCCC Feb 9, 2026
0c5eccd
perf: update existing revalidator entries in-place for improved memor…
MattCCC Feb 9, 2026
7303a80
refactor: improve config merging logic in setDefaultConfig and mergeC…
MattCCC Feb 9, 2026
6d4c0fc
revert: convert revalidator and timeout iteration for improved clarity
MattCCC Feb 9, 2026
04abb0d
fix: normalize header keys to lowercase for compliance with RFC 2616
MattCCC Feb 9, 2026
b5d31ca
feat: enhance mergeConfig to handle Headers instances and normalize h…
MattCCC Feb 9, 2026
5496dd4
chore: Build
MattCCC Feb 10, 2026
563fb9a
docs: update benchmark command in fetchf.bench.mjs for accuracy
MattCCC Feb 10, 2026
5d57ca7
feat: add string caching optimization to fetchf function and benchmar…
MattCCC Feb 10, 2026
767130b
chore: Build
MattCCC Feb 10, 2026
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
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1149,6 +1149,8 @@ const api = createApiFetcher({
4. **Consider user experience** - Network revalidation happens silently in the background, providing smooth UX without loading spinners.

> ⚠️ **Browser Support**: These features work in all modern browsers that support the `focus` and `online` events. In server-side environments (Node.js), these options are safely ignored.
>
> **React Native**: Use `setEventProvider()` to enable these features. See the [React Native](#react-native) section for details.

</details>

Expand Down Expand Up @@ -3798,6 +3800,38 @@ For environments that do not support modern JavaScript features or APIs, you mig
- **Promise Polyfill**: For older browsers that do not support Promises. Libraries like [es6-promise](https://github.com/stefanpenner/es6-promise) can be used.
- **AbortController Polyfill**: For environments that do not support the `AbortController` API used for aborting fetch requests. You can use the [abort-controller](https://github.com/mysticatea/abort-controller) polyfill.

### React Native

`fetchff` is fully compatible with React Native. Core features like caching, retries, deduplication, and the React hook work out of the box.

To enable `refetchOnFocus` and `refetchOnReconnect`, register event providers at your app's entry point using `setEventProvider()`:

```ts
import { AppState } from 'react-native';
import NetInfo from '@react-native-community/netinfo';
import { setEventProvider } from 'fetchff';

// Refetch when app comes to foreground
setEventProvider('focus', (handler) => {
const sub = AppState.addEventListener('change', (state) => {
if (state === 'active') handler();
});
return () => sub.remove();
});

// Refetch when network reconnects
setEventProvider('online', (handler) => {
let wasConnected = true;
const unsubscribe = NetInfo.addEventListener((state) => {
if (state.isConnected && !wasConnected) handler();
wasConnected = !!state.isConnected;
});
return unsubscribe;
});
```

> **Note:** `@react-native-community/netinfo` is optional — only needed if you use `refetchOnReconnect`.

### Using `node-fetch` for Node.js < 18

If you need to support Node.js versions below 18 (not officially supported), you can use the [`node-fetch`](https://www.npmjs.com/package/node-fetch) package to polyfill the `fetch` API. Install it with:
Expand Down
4 changes: 2 additions & 2 deletions dist/browser/index.global.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/browser/index.global.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/browser/index.mjs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/browser/index.mjs.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions dist/node/index.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/node/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/react/index.js

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

2 changes: 1 addition & 1 deletion dist/react/index.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/react/index.mjs
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
import {useMemo,useRef,useCallback,useSyncExternalStore}from'react';import {generateCacheKey,buildConfig,getCache,getInFlightPromise,fetchf,getCachedResponse,subscribe,addTimeout,abortRequest,deleteCache}from'fetchff';var F=-1,D=2e3,m=new Map,q=e=>{e&&m.set(e,(m.get(e)||0)+1);},A=(e,t,s,o)=>{if(!e)return;let a=f(e);if(!a)return;let i=a-1;i<=0?(m.delete(e),t===F&&addTimeout("r:"+e,()=>{abortRequest(e,new DOMException("Request to "+o+" aborted","AbortError")),f(e)||deleteCache(e,true);},s!=null?s:D)):m.set(e,i);},f=e=>e&&m.get(e)||0;var Y=300,x=Object.freeze({data:null,error:null,isFetching:false,mutate:()=>Promise.resolve(null),config:{},headers:{}}),K=Object.freeze({...x,isFetching:true}),Z=[null,{},null],$=new Set(["GET","HEAD","get","head"]);function ae(e,t={}){var b,U,_;let s=useMemo(()=>e===null?null:generateCacheKey(buildConfig(e,t)),[t.cacheKey,e,t.url,t.method,t.headers,t.body,t.params,t.urlPathParams,t.apiUrl,t.baseURL,t.withCredentials,t.credentials]),o=(b=t.dedupeTime)!=null?b:D,a=t.cacheTime||F,i=(U=t.staleTime)!=null?U:Y,R=(_=t.immediate)!=null?_:$.has(t.method||"GET"),P=useRef(Z);P.current=[e,t,s];let g=useCallback(()=>{let r=getCache(s);if(t.strategy==="reject"&&s&&(!r||!r.data.data&&!r.data.error)){let h=getInFlightPromise(s,o);if(h)throw h;if(!r){let[u,c,l]=P.current;if(u)throw fetchf(u,{...c,cacheKey:l,dedupeTime:o,cacheTime:a,staleTime:i,strategy:"softFail",cacheErrors:true,_isAutoKey:!c.cacheKey})}}return r?r.data.isFetching&&!t.keepPreviousData?K:r.data:R?K:x},[s]),B=useCallback(r=>{q(s),R&&e&&s&&f(s)===1&&(getCachedResponse(s,a,t)||C(false));let u=subscribe(s,r);return ()=>{A(s,a,o,e),u();}},[s,R,e,o,a]),n=useSyncExternalStore(B,g,g),C=useCallback(async(r=true,h={})=>{let[u,c,l]=P.current;if(!u)return Promise.resolve(null);let E=!!r;if(!E&&l){let S=getCachedResponse(l,a,c);if(S)return Promise.resolve(S)}let Q=E?()=>true:c.cacheBuster;return fetchf(u,{...c,cacheKey:l,...h,dedupeTime:o,cacheTime:a,staleTime:i,cacheBuster:Q,strategy:"softFail",cacheErrors:true,_isAutoKey:!c.cacheKey})},[a,o]),T=n.data,p=!T&&!n.error,d=!!e&&(n.isFetching||p&&R),L=d&&p,w=d&&!p;return {data:T,error:n.error,config:n.config,headers:n.headers,isFirstFetch:L,isFetching:d,isLoading:d,isRefetching:w,isError:n.isError,isSuccess:n.isSuccess,mutate:n.mutate,refetch:C}}export{ae as useFetcher};//# sourceMappingURL=index.mjs.map
import {useMemo,useRef,useCallback,useSyncExternalStore}from'react';import {generateCacheKey,buildConfig,getCache,getInFlightPromise,fetchf,getCachedResponse,subscribe,addTimeout,abortRequest,createAbortError,deleteCache}from'fetchff';var F=-1,y=2e3,m=new Map,S=e=>{e&&m.set(e,(m.get(e)||0)+1);},q=(e,t,r,o)=>{if(!e)return;let a=f(e);if(!a)return;let i=a-1;i<=0?(m.delete(e),t===F&&addTimeout("r:"+e,()=>{abortRequest(e,createAbortError("Request to "+o+" aborted","AbortError")),f(e)||deleteCache(e,true);},r!=null?r:y)):m.set(e,i);},f=e=>e&&m.get(e)||0;var Z=300,B=Object.freeze({data:null,error:null,isFetching:false,mutate:()=>Promise.resolve(null),config:{},headers:{}}),K=Object.freeze({...B,isFetching:true}),$=[null,{},null],k=new Set(["GET","HEAD","get","head"]);function ne(e,t={}){var b,U,_;let r=useMemo(()=>e===null?null:generateCacheKey(buildConfig(e,t)),[t.cacheKey,e,t.url,t.method,t.headers,t.body,t.params,t.urlPathParams,t.apiUrl,t.baseURL,t.withCredentials,t.credentials]),o=(b=t.dedupeTime)!=null?b:y,a=t.cacheTime||F,i=(U=t.staleTime)!=null?U:Z,R=(_=t.immediate)!=null?_:k.has(t.method||"GET"),P=useRef($);P.current=[e,t,r];let g=useCallback(()=>{let s=getCache(r);if(t.strategy==="reject"&&r&&(!s||!s.data.data&&!s.data.error)){let h=getInFlightPromise(r,o);if(h)throw h;if(!s){let[u,c,l]=P.current;if(u)throw fetchf(u,{...c,cacheKey:l,dedupeTime:o,cacheTime:a,staleTime:i,strategy:"softFail",cacheErrors:true,_isAutoKey:!c.cacheKey})}}return s?s.data.isFetching&&!t.keepPreviousData?K:s.data:R?K:B},[r]),L=useCallback(s=>{S(r),R&&e&&r&&f(r)===1&&(getCachedResponse(r,a,t)||C(false));let u=subscribe(r,s);return ()=>{q(r,a,o,e),u();}},[r,R,e,o,a]),n=useSyncExternalStore(L,g,g),C=useCallback(async(s=true,h={})=>{let[u,c,l]=P.current;if(!u)return Promise.resolve(null);let E=!!s;if(!E&&l){let A=getCachedResponse(l,a,c);if(A)return Promise.resolve(A)}let Q=E?()=>true:c.cacheBuster;return fetchf(u,{...c,cacheKey:l,...h,dedupeTime:o,cacheTime:a,staleTime:i,cacheBuster:Q,strategy:"softFail",cacheErrors:true,_isAutoKey:!c.cacheKey})},[a,o]),T=n.data,p=!T&&!n.error,d=!!e&&(n.isFetching||p&&R),x=d&&p,w=d&&!p;return {data:T,error:n.error,config:n.config,headers:n.headers,isFirstFetch:x,isFetching:d,isLoading:d,isRefetching:w,isError:n.isError,isSuccess:n.isSuccess,mutate:n.mutate,refetch:C}}export{ne as useFetcher};//# sourceMappingURL=index.mjs.map
//# sourceMappingURL=index.mjs.map
2 changes: 1 addition & 1 deletion dist/react/index.mjs.map

Large diffs are not rendered by default.

Loading