Skip to content

Commit 7a7c4f2

Browse files
authored
Merge pull request #3310 from StoDevX/stored-data
Make it easy to cache requests, and do it
2 parents aeb77e8 + 396a6c7 commit 7a7c4f2

File tree

33 files changed

+668
-607
lines changed

33 files changed

+668
-607
lines changed

.eslintrc.yaml

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,6 @@ env:
2525
es6: true
2626
react-native/react-native: true
2727

28-
globals:
29-
fetch: true
30-
Headers: true
31-
FormData: true
32-
fetchJson: true
33-
rawFetch: true
34-
3528
rules:
3629
array-callback-return: error
3730
camelcase: warn

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
99
- Added Renovate as our new automated dependency management tool, with a nice configuration (#3193)
1010
- Add "open webpage" row to student work detail
1111
- Added some logic to skip native builds if nothing that might affect them has changed (#3209)
12+
- All network requests are now cached according to the server's caching headers (#3310)
1213

1314
### Changed
1415
- Adjusted and deduplicated logic in API scaffolding

flow-typed/aao/fetch.js

Lines changed: 0 additions & 2 deletions
This file was deleted.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
// flow-typed signature: 1b482b0f0210e8b192691df605784b1a
2+
// flow-typed version: <<STUB>>/http-cache-semantics_v4.0.1/flow_v0.78.0
3+
4+
declare module 'http-cache-semantics' {
5+
// declare type Options = {
6+
// shared?: boolean,
7+
// cacheHeuristic?: number,
8+
// immutableMinTimeToLive?: number,
9+
// ignoreCargoCult?: boolean,
10+
// trustServerDate?: boolean,
11+
// }
12+
//
13+
// declare type BasicHeaders = {[string]: string}
14+
//
15+
// declare type MinimalRequest = {
16+
// url: string,
17+
// headers: BasicHeaders,
18+
// method: string,
19+
// }
20+
//
21+
// declare type MinimalResponse = {
22+
// status: number,
23+
// headers: BasicHeaders,
24+
// }
25+
//
26+
// declare class CachePolicy {
27+
// constructor(req: MinimalRequest, res: MinimalResponse, o?: Options): CachePolicy;
28+
// storable(): boolean;
29+
// satisfiesWithoutRevalidation(req: MinimalRequest): boolean;
30+
// responseHeaders(): BasicHeaders;
31+
// timeToLive(): number;
32+
// static fromObject(obj: Object): CachePolicy;
33+
// toObject(): Object;
34+
// }
35+
//
36+
// declare export default CachePolicy;
37+
declare export default any;
38+
}

modules/ccc-calendar/index.js

Lines changed: 11 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,11 @@
22

33
import * as React from 'react'
44
import {timezone} from '@frogpond/constants'
5-
import {reportNetworkProblem} from '@frogpond/analytics'
65
import type {NavigationScreenProp} from 'react-navigation'
6+
import {fetch} from '@frogpond/fetch'
77
import {EventList, type PoweredBy} from '@frogpond/event-list'
88
import {type EventType} from '@frogpond/event-type'
99
import moment from 'moment-timezone'
10-
import delay from 'delay'
1110
import {LoadingView} from '@frogpond/notice'
1211
import {API} from '@frogpond/api'
1312

@@ -25,7 +24,7 @@ type Props = {
2524

2625
type State = {
2726
events: EventType[],
28-
loading: boolean,
27+
initialLoadComplete: boolean,
2928
refreshing: boolean,
3029
error: ?Error,
3130
now: moment,
@@ -34,15 +33,15 @@ type State = {
3433
export class CccCalendarView extends React.Component<Props, State> {
3534
state = {
3635
events: [],
37-
loading: true,
36+
initialLoadComplete: false,
3837
refreshing: false,
3938
error: null,
4039
now: moment.tz(timezone()),
4140
}
4241

4342
componentDidMount() {
4443
this.getEvents().then(() => {
45-
this.setState(() => ({loading: false}))
44+
this.setState(() => ({initialLoadComplete: true}))
4645
})
4746
}
4847

@@ -65,7 +64,7 @@ export class CccCalendarView extends React.Component<Props, State> {
6564
return events
6665
}
6766

68-
getEvents = async (now: moment = moment.tz(timezone())) => {
67+
getEvents = async (reload?: boolean, now: moment = moment.tz(timezone())) => {
6968
let url
7069
if (typeof this.props.calendar === 'string') {
7170
url = API(`/calendar/named/${this.props.calendar}`)
@@ -79,35 +78,21 @@ export class CccCalendarView extends React.Component<Props, State> {
7978
throw new Error('invalid calendar type!')
8079
}
8180

82-
let data: EventType[] = []
83-
try {
84-
data = await fetchJson(url)
85-
} catch (err) {
86-
reportNetworkProblem(err)
87-
this.setState({error: err.message})
88-
console.warn(err)
89-
}
81+
let events: Array<EventType> = await fetch(url, {
82+
delay: reload ? 500 : 0,
83+
}).json()
9084

91-
this.setState({now, events: this.convertEvents(data)})
85+
this.setState({now, events: this.convertEvents(events)})
9286
}
9387

9488
refresh = async () => {
95-
let start = Date.now()
9689
this.setState(() => ({refreshing: true}))
97-
98-
await this.getEvents()
99-
100-
// wait 0.5 seconds – if we let it go at normal speed, it feels broken.
101-
let elapsed = Date.now() - start
102-
if (elapsed < 500) {
103-
await delay(500 - elapsed)
104-
}
105-
90+
await this.getEvents(true)
10691
this.setState(() => ({refreshing: false}))
10792
}
10893

10994
render() {
110-
if (this.state.loading) {
95+
if (!this.state.initialLoadComplete) {
11196
return <LoadingView />
11297
}
11398

modules/ccc-calendar/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"dependencies": {
1818
"@frogpond/analytics": "^1.0.0",
1919
"@frogpond/api": "^1.0.0",
20+
"@frogpond/fetch": "^1.0.0",
2021
"@frogpond/event-list": "^1.0.0",
2122
"@frogpond/event-type": "^1.0.0",
2223
"@frogpond/notice": "^1.0.0"

modules/fetch/cached.js

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
// @flow
2+
/* globals Request, Response, Headers */
3+
4+
import {AsyncStorage} from 'react-native'
5+
import CachePolicy from 'http-cache-semantics'
6+
import fromPairs from 'lodash/fromPairs'
7+
8+
const ROOT = 'fp'
9+
const debug = false
10+
11+
// Pulls out the important bits from a Request for storage
12+
async function serializeResponse(r: Request) {
13+
let {headers, status, statusText} = r
14+
let body = await r.clone().text()
15+
if ('entries' in headers) {
16+
headers = [...headers.entries()]
17+
}
18+
return {headers, status, statusText, body}
19+
}
20+
21+
// Converts a whatwg Headers instance into a plain object for http-cache-semantics
22+
function headersInstanceToObject(headers: Headers) {
23+
return fromPairs([...Object.entries(headers)])
24+
}
25+
26+
// Converts a whatwg Response into a plain object for http-cache-semantics
27+
function responseForCachePolicy({url, method, headers}: Response) {
28+
// Response must have a headers property with all header names in lower
29+
// case. `url` and `method` are optional.
30+
31+
return {url, method, headers: headersInstanceToObject(headers)}
32+
}
33+
34+
// Converts a whatwg Request into a plain object for http-cache-semantics
35+
function requestForCachePolicy({status, headers}: Request) {
36+
// Request must have a headers property with all header names in lower
37+
// case. `url` and `status` are optional.
38+
39+
return {status, headers: headersInstanceToObject(headers)}
40+
}
41+
42+
export async function insertForUrl(url: string, data: mixed) {
43+
let key = `urlcache:${url}`
44+
45+
let {policy: oldPolicy} = await getItem(key)
46+
47+
if (oldPolicy) {
48+
return
49+
}
50+
51+
let req = new Request(url)
52+
let resp = new Response(JSON.stringify(data), {status: 200})
53+
let policy = new CachePolicy(
54+
requestForCachePolicy(req),
55+
responseForCachePolicy(resp),
56+
)
57+
58+
return cacheItem({key, response: resp, policy})
59+
}
60+
61+
// Does the magic: stores a Request into AsyncStorage
62+
type CacheItemArgs = {key: string, response: Response, policy: CachePolicy}
63+
async function cacheItem({key, response, policy}: CacheItemArgs) {
64+
response = await serializeResponse(response)
65+
66+
await AsyncStorage.multiSet([
67+
[`${ROOT}:${key}:response`, JSON.stringify(response)],
68+
[`${ROOT}:${key}:policy`, JSON.stringify(policy.toObject())],
69+
[`${ROOT}:${key}:ttl`, JSON.stringify(policy.timeToLive())],
70+
])
71+
}
72+
73+
// Does more magic: gets a Request from AsyncStorage
74+
type GetItemResult = {response: Response, policy: ?CachePolicy}
75+
async function getItem(key: string): Promise<GetItemResult> {
76+
let [[, response], [, policy]] = await AsyncStorage.multiGet([
77+
`${ROOT}:${key}:response`,
78+
`${ROOT}:${key}:policy`,
79+
])
80+
81+
if (!response) {
82+
return {response, policy: null}
83+
}
84+
85+
let {body, ...init} = JSON.parse(response)
86+
87+
return {
88+
response: new Response(body, init),
89+
policy: CachePolicy.fromObject(JSON.parse(policy)),
90+
}
91+
}
92+
93+
// Requests an URL and retrieves it from the cache if possible
94+
export async function cachedFetch(request: Request): Promise<Response> {
95+
let {url} = request
96+
97+
let cachePolicyRequest = requestForCachePolicy(request)
98+
99+
let key = `urlcache:${url}`
100+
let {response: oldResponse, policy: oldPolicy} = await getItem(key)
101+
102+
// If nothing has ever been cached, go fetch it
103+
if (!oldPolicy) {
104+
debug && console.log(`fetch(${request.url}): no policy cached; fetching`)
105+
106+
let response = await fetch(request)
107+
let cachePolicyResponse = responseForCachePolicy(response)
108+
109+
let policy = new CachePolicy(cachePolicyRequest, cachePolicyResponse)
110+
111+
if (policy.storable()) {
112+
debug && console.log(`fetch(${request.url}): caching`)
113+
await cacheItem({key, response, policy})
114+
} else {
115+
debug && console.log(`fetch(${request.url}): not cachable`)
116+
}
117+
118+
return response
119+
}
120+
121+
// If we can re-use the cached data, return it; otherwise, we're serving requests from the cache
122+
if (oldPolicy.satisfiesWithoutRevalidation(cachePolicyRequest)) {
123+
debug && console.log(`fetch(${request.url}): fresh; returning`)
124+
oldResponse.headers = new Headers(oldPolicy.responseHeaders())
125+
return oldResponse
126+
}
127+
128+
// Update the request to ask the origin server if the cached response can be used
129+
request.headers = new Headers(
130+
oldPolicy.revalidationHeaders(cachePolicyRequest),
131+
)
132+
133+
debug && console.log(`fetch(${request.url}): stale; validating`)
134+
135+
// Send request to the origin server. The server may respond with status 304
136+
let newResponse = await fetch(request)
137+
let newCachePolicyResponse = responseForCachePolicy(newResponse)
138+
139+
// Create updated policy and combined response from the old and new data
140+
let {policy, modified} = oldPolicy.revalidatedPolicy(
141+
cachePolicyRequest,
142+
newCachePolicyResponse,
143+
)
144+
145+
if (debug) {
146+
if (modified) {
147+
console.log(`fetch(${request.url}): validated; did change`)
148+
} else {
149+
console.log(`fetch(${request.url}): validated; 304 no change`)
150+
}
151+
}
152+
153+
let response = modified ? newResponse : oldResponse
154+
155+
// Update the cache with the newer/fresher response
156+
await cacheItem({key, policy, response})
157+
158+
// And proceed returning cached response as usual
159+
response.headers = new Headers(policy.responseHeaders())
160+
161+
debug && console.log(`fetch(${request.url}): returning updated response`)
162+
163+
return response
164+
}

0 commit comments

Comments
 (0)