Skip to content
This repository was archived by the owner on Jun 15, 2023. It is now read-only.

Commit 9db6077

Browse files
authored
Cache Preloading API (#10)
* Upgrade flow-bin * Add failing test case * Preloading * Default resolver improvement * Change behavior when preloading fails (allow retry) * Update readme
1 parent 9f8bd8e commit 9db6077

File tree

8 files changed

+233
-39
lines changed

8 files changed

+233
-39
lines changed

README.md

+31-3
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ Declaratively fetch multiple APIs with a single React component.
1818
- [Using lifecycle props](#using-lifecycle-props)
1919
- [Delaying loading](#delaying-loading)
2020
- [Caching responses](#caching-responses)
21+
- [Preloading](#preloading)
2122
- [Complex fetching](#complex-fetching)
2223
- [Writing a resolver](#writing-a-resolver)
2324
- [Contributing](#contributing)
@@ -176,11 +177,9 @@ Accio supports delaying loading so that your loading component will only be rend
176177

177178
### Caching responses
178179

179-
This is an experimental feature. Use it at your own risk!
180-
181180
Accio can cache your responses if it detects at least two identical endpoints with the same request payload. But you have to make it *explicit* in order to do so:
182181
```jsx
183-
import { Accio, AccioCacheProvider } from 'accio'
182+
import { Accio, AccioCacheProvider } from 'react-accio'
184183

185184
// on top of your app
186185
<AccioCacheProvider>
@@ -201,6 +200,35 @@ import { Accio, AccioCacheProvider } from 'accio'
201200
</div>
202201
```
203202

203+
### Preloading
204+
205+
You can preload _deferred_ Accio calls ahead of time so that by the time you need the data, you will get it instantly.
206+
207+
Let's say you want to preload the cache whenever your users hover over your fetch trigger button:
208+
```jsx
209+
import { Accio, AccioCacheProvider } from 'react-accio'
210+
211+
// Preloading only works when `AccioCacheProvider` is around.
212+
<AccioCacheProvider>
213+
<MyApp />
214+
</AccioCacheProvider>
215+
216+
// Prepare a ref
217+
const resource = React.createRef();
218+
219+
// Your app
220+
<Accio url="https://api.example.com/data" defer ref={resource}>
221+
{({ trigger }) => (
222+
<button
223+
onClick={trigger}
224+
onMouseOver={() => resource.current.preload()}
225+
>
226+
Fetch
227+
</button>
228+
)}
229+
</Accio>
230+
```
231+
204232
### Complex fetching
205233

206234
Sometimes you want to do complex fetching mechanism such as polling. You cannot do that using render-prop without too many hacks. Thankfully, Accio provides an escape hatch for this use case where you can access its resolver anytime conveniently. That said, you can go back to imperative style coding by extracting Accio resolver:

package.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"babel-preset-flow": "^6.23.0",
3434
"babel-preset-react": "^6.24.1",
3535
"dom-testing-library": "^2.3.2",
36-
"flow-bin": "^0.74.0",
36+
"flow-bin": "^0.78.0",
3737
"jest": "^23.0.1",
3838
"react": "^16.3.2",
3939
"react-dom": "^16.3.2",

src/__tests__/Accio.test.js

+91
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,97 @@ describe('<Accio />', () => {
235235
notFunctionTypes.length + unsupportedMethods.length
236236
);
237237
});
238+
239+
test('Preload deferred fetch', async () => {
240+
const resolverSpy = jest.spyOn(Accio.defaults, 'resolver');
241+
242+
const resource = React.createRef();
243+
const { getByText } = render(
244+
<AccioCacheProvider>
245+
<Accio {...basicProps} defer ref={resource}>
246+
{({ trigger, loading, response }) => (
247+
<div>
248+
<button onClick={trigger}>Go!</button>
249+
{loading && <div>Loading indicator</div>}
250+
{response && <div>Response text</div>}
251+
</div>
252+
)}
253+
</Accio>
254+
</AccioCacheProvider>
255+
);
256+
257+
expect(resolverSpy).not.toHaveBeenCalled();
258+
await resource.current.preload();
259+
expect(resolverSpy).toHaveBeenCalled();
260+
261+
// try repeat preloading
262+
await resource.current.preload();
263+
// preloading is no-op once cache has been warmed up
264+
expect(resolverSpy.mock.calls.length).toBe(1);
265+
266+
// it should not yield the response just yet
267+
expect(() => {
268+
getByText('Response text');
269+
}).toThrow();
270+
271+
// trigerring Accio should not call resolver again
272+
Simulate.click(getByText('Go!'));
273+
expect(resolverSpy.mock.calls.length).toBe(1);
274+
275+
// already preloaded, no need to show loading indicator
276+
expect(() => {
277+
getByText('Loading indicator')
278+
}).toThrow();
279+
280+
// instant, no "wait-for-expect" needed
281+
expect(getByText('Response text')).toBeInTheDOM();
282+
});
283+
284+
test('Network error when preloading', async () => {
285+
// simulate network error on the resolver
286+
const errorMessage = 'error';
287+
const originalResolver = jest.fn(Accio.defaults.resolver);
288+
Accio.defaults.resolver = createResolver({ error: true, errorMessage });
289+
290+
const resolverSpy = jest.spyOn(Accio.defaults, 'resolver');
291+
292+
let error = null;
293+
const onError = jest.fn(err => {
294+
error = err;
295+
});
296+
297+
const resource = React.createRef();
298+
const { getByText } = render(
299+
<AccioCacheProvider>
300+
<Accio {...basicProps} defer ref={resource} onError={onError}>
301+
{({ trigger, loading, response }) => (
302+
<div>
303+
<button onClick={trigger}>Go!</button>
304+
{loading && <div>Loading indicator</div>}
305+
{response && <div>Response text</div>}
306+
{error && <div>Error message</div>}
307+
</div>
308+
)}
309+
</Accio>
310+
</AccioCacheProvider>
311+
);
312+
313+
expect(resolverSpy.mock.calls.length).toBe(0);
314+
315+
await resource.current.preload();
316+
expect(resolverSpy.mock.calls.length).toBe(1);
317+
318+
// remove error
319+
Accio.defaults.resolver = originalResolver;
320+
321+
// allow retry if previous one fails
322+
await resource.current.preload();
323+
expect(originalResolver.mock.calls.length).toBe(1);
324+
325+
// once success, disallow retry (TODO: unless cache is invalidated)
326+
await resource.current.preload();
327+
expect(originalResolver.mock.calls.length).toBe(1);
328+
});
238329
});
239330

240331
describe('Accio.defaults.resolver', () => {

src/defaults/resolver.js

+5
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ export default async function resolver(url, fetchOptions) {
66
if (err) {
77
throw new Error('Accio error: ' + err.message);
88
}
9+
if (res.ok === false) {
10+
throw new Error(
11+
`Accio failed to fetch: ${res.url} ${res.status} (${res.statusText})`
12+
);
13+
}
914
const [err2, jsonResponse] = await to(res.json());
1015
if (err2) {
1116
throw new Error('Error parsing response to json: ' + err2.message);

src/lib/Accio.js

+83-27
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import * as React from 'react';
33

44
import to from '../utils/to';
55
import defaults from '../defaults/index';
6+
import getCacheKey from '../utils/getCacheKey';
67

78
import { type AccioCache } from './AccioCacheContext';
89

@@ -30,6 +31,7 @@ export type Props = {
3031

3132
// private props
3233
_cache: ?AccioCache,
34+
forwardedRef: ?{ current: null | Accio },
3335
};
3436

3537
type State = {
@@ -76,6 +78,13 @@ function getFetchOptions(props) {
7678
return fetchOptions;
7779
}
7880

81+
const PreloadStatus = {
82+
PRELOAD_ERROR: -1,
83+
IDLE: 0,
84+
PRELOADING: 1,
85+
PRELOADED: 2,
86+
};
87+
7988
class Accio extends React.Component<Props, State> {
8089
static defaults: Defaults = defaults;
8190

@@ -93,24 +102,73 @@ class Accio extends React.Component<Props, State> {
93102
trigger: this.doWork.bind(this),
94103
};
95104

105+
fetchOptions: Object = getFetchOptions(this.props);
106+
107+
cacheKey: string = getCacheKey(this.props.url, this.fetchOptions);
108+
109+
preloadStatus: number = PreloadStatus.IDLE;
110+
111+
preloadError: ?Error = null;
112+
96113
timer: TimeoutID;
97114

115+
async preload() {
116+
const { _cache } = this.props;
117+
118+
if (!_cache) {
119+
console.warn(
120+
'Preloading without cache is not supported. ' +
121+
'This can be fixed by wrapping your app with <AccioCacheProvider />.'
122+
);
123+
return;
124+
}
125+
126+
if (this.preloadStatus < PreloadStatus.PRELOADING) {
127+
this.preloadStatus = PreloadStatus.PRELOADING;
128+
129+
const [err, res] = await to(this.doFetch.call(this));
130+
if (err) {
131+
this.preloadStatus = PreloadStatus.PRELOAD_ERROR;
132+
this.preloadError = err;
133+
return;
134+
}
135+
136+
this.preloadStatus = PreloadStatus.PRELOADED;
137+
return res;
138+
}
139+
}
140+
98141
componentDidMount() {
99142
if (this.props.defer === true) {
100143
return;
101144
}
102145
this.doWork.call(this);
103146
}
104147

148+
componentWillUnmount() {
149+
if (this.timer) {
150+
clearTimeout(this.timer);
151+
}
152+
}
153+
105154
async doWork() {
106-
const { timeout } = this.props;
155+
const { _cache, onStartFetching, timeout } = this.props;
156+
157+
if (_cache && this.preloadStatus === PreloadStatus.PRELOADED) {
158+
const preloadedResponse = _cache.get(this.cacheKey);
159+
this.setResponse.call(this, preloadedResponse);
160+
return;
161+
}
107162

108163
if (typeof timeout === 'number') {
109164
this.timer = setTimeout(this.setLoading.bind(this, true), timeout);
110165
} else {
111166
this.setLoading.call(this, true);
112167
}
113168

169+
if (typeof onStartFetching === 'function') {
170+
onStartFetching();
171+
}
114172
const [err, response] = await to(this.doFetch.call(this));
115173

116174
if (err) {
@@ -126,44 +184,42 @@ class Accio extends React.Component<Props, State> {
126184
}
127185

128186
doFetch(): Promise<*> {
129-
const {
130-
url,
131-
context,
132-
onStartFetching,
133-
ignoreCache,
134-
_cache,
135-
} = this.props;
187+
const { _cache, context, ignoreCache, url, onStartFetching } = this.props;
136188
const { resolver } = Accio.defaults;
137-
if (typeof onStartFetching === 'function') {
138-
onStartFetching();
139-
}
140-
const fetchOptions = getFetchOptions(this.props);
141189

142-
// resolve from cache if applicable
190+
// try resolve from cache,
191+
// otherwise resolve from network
192+
193+
const resolveNetwork = () => {
194+
return resolver(url, this.fetchOptions, context);
195+
};
196+
143197
if (_cache && ignoreCache === false) {
144-
let cacheKey = url;
145-
if (fetchOptions.body) {
146-
cacheKey = cacheKey + JSON.stringify(fetchOptions.body);
147-
}
198+
const { cacheKey } = this;
148199
// check for existing cache entry
149200
if (_cache.has(cacheKey)) {
150-
// cache hit
201+
// cache hit --> return cached entry
151202
return Promise.resolve(_cache.get(cacheKey));
152203
} else {
153-
// cache miss
154-
const promise = resolver(url, fetchOptions, context);
204+
// cache miss --> resolve network
205+
const promise = resolveNetwork();
155206
// store promise in cache
156207
_cache.set(cacheKey, promise);
157-
return promise.then((response: any) => {
158-
// when resolved, store the real
159-
// response to the cache
160-
_cache.set(cacheKey, response);
161-
return response;
162-
});
208+
return promise
209+
.then((response: any) => {
210+
// when resolved, store the real
211+
// response to the cache
212+
_cache.set(cacheKey, response);
213+
return response;
214+
})
215+
.catch(err => {
216+
_cache.delete(cacheKey);
217+
throw err;
218+
});
163219
}
164220
}
165221

166-
return resolver(url, fetchOptions, context);
222+
return resolveNetwork();
167223
}
168224

169225
setLoading(loading: boolean) {

src/lib/CachedAccio.js

+12-5
Original file line numberDiff line numberDiff line change
@@ -8,19 +8,26 @@ import { AccioCacheConsumer, type AccioCache } from './AccioCacheContext';
88

99
class CachedAccio extends React.Component<Props> {
1010
renderChild(_cache: AccioCache) {
11-
const { children, ...props } = this.props;
11+
const { children, forwardedRef, ...props } = this.props;
1212
return (
13-
<Accio {...props} _cache={_cache}>
13+
<Accio {...props} ref={forwardedRef} _cache={_cache}>
1414
{children}
1515
</Accio>
1616
);
1717
}
1818

1919
render() {
20-
return <AccioCacheConsumer>{this.renderChild.bind(this)}</AccioCacheConsumer>;
20+
return (
21+
<AccioCacheConsumer>{this.renderChild.bind(this)}</AccioCacheConsumer>
22+
);
2123
}
2224
}
2325

24-
hoistStatics(CachedAccio, Accio);
26+
// $FlowFixMe https://github.com/facebook/flow/issues/6103
27+
const CachedAccioWithRef = React.forwardRef((props, ref) => (
28+
<CachedAccio {...props} forwardedRef={ref} />
29+
));
30+
31+
hoistStatics(CachedAccioWithRef, Accio);
2532

26-
export default CachedAccio;
33+
export default CachedAccioWithRef;

src/utils/getCacheKey.js

+7
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export default function getCacheKey(url, fetchOptions) {
2+
let cacheKey = url;
3+
if (fetchOptions.body) {
4+
cacheKey = cacheKey + JSON.stringify(fetchOptions.body);
5+
}
6+
return cacheKey;
7+
}

0 commit comments

Comments
 (0)