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

Commit c112aaf

Browse files
committed
Deduplicate requests
1 parent 7003ddb commit c112aaf

File tree

7 files changed

+319
-223
lines changed

7 files changed

+319
-223
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@
5858
"path": "^0.12.7",
5959
"prop-types": "^15.6.0",
6060
"react-native-uuid": "^1.4.9",
61+
"rxjs": "^6.6.0",
6162
"traverse": "^0.6.6",
6263
"url-parse": "^1.2.0",
6364
"validator": "^13.1.1"

src/FileSystem.tsx

Lines changed: 151 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,23 @@ import pathLib from 'path'
1212
import RNFS from 'react-native-fs'
1313
import sha1 from 'crypto-js/sha1'
1414
import URL from 'url-parse'
15+
import { forkJoin, from, Observable, of, throwError } from 'rxjs'
16+
import {
17+
switchMap,
18+
catchError,
19+
mapTo,
20+
publishReplay,
21+
refCount,
22+
mergeMap,
23+
map,
24+
delayWhen,
25+
} from 'rxjs/operators'
26+
import uuid from 'react-native-uuid'
27+
28+
export interface CacheFileInfo {
29+
path: string | null
30+
fileName: string
31+
}
1532

1633
/**
1734
* Resolves if 'unlink' resolves or if the file doesn't exist.
@@ -53,6 +70,9 @@ export class FileSystem {
5370
[component: string]: boolean
5471
}
5572
} = {}
73+
static cacheObservables: {
74+
[key: string]: Observable<CacheFileInfo>
75+
} = {}
5676
baseFilePath: string
5777
cachePruneTriggerLimit: number
5878

@@ -79,6 +99,10 @@ export class FileSystem {
7999
Object.keys(FileSystem.cacheLock[fileName]).length === 0
80100
) {
81101
delete FileSystem.cacheLock[fileName]
102+
103+
if (FileSystem.cacheObservables[fileName]) {
104+
delete FileSystem.cacheObservables[fileName]
105+
}
82106
}
83107
}
84108

@@ -154,7 +178,7 @@ export class FileSystem {
154178
* @throws error on invalid (non jpg, png, gif, bmp) url file type. NOTE file extension or content-type header does not guarantee file mime type. We are trusting that it is set correctly on the server side.
155179
* @returns fileName {string} - A SHA1 filename that is unique to the resource located at passed in URL and includes an appropriate extension.
156180
*/
157-
async getFileNameFromUrl(url: string) {
181+
getFileNameFromUrl(url: string) {
158182
const urlParts = new URL(url)
159183
const urlExt = urlParts.pathname.split('.').pop()
160184

@@ -173,30 +197,19 @@ export class FileSystem {
173197
* @param permanent {Boolean} - True persists the file locally indefinitely, false caches the file temporarily (until file is removed during cache pruning).
174198
* @returns {Promise<string|null>} promise that resolves to the local file path of downloaded url file.
175199
*/
176-
async getLocalFilePathFromUrl(url: string, permanent: boolean) {
177-
let filePath = null
178-
179-
const fileName = await this.getFileNameFromUrl(url)
180-
181-
const permanentFileExists = this.exists('permanent/' + fileName)
182-
const cacheFileExists = this.exists('cache/' + fileName)
200+
async getLocalFilePathFromUrl(url: string, permanent = false) {
201+
const fileName = this.getFileNameFromUrl(url)
202+
const requestId = uuid.v4()
183203

184-
const exists = await Promise.all([permanentFileExists, cacheFileExists])
204+
try {
205+
FileSystem.lockCacheFile(fileName, requestId)
185206

186-
if (exists[0]) {
187-
filePath = this.baseFilePath + 'permanent/' + fileName
188-
} else if (exists[1]) {
189-
filePath = this.baseFilePath + 'cache/' + fileName
190-
} else {
191-
const result = await this.fetchFile(url, permanent, null, true) // Clobber must be true to allow concurrent CacheableImage components with same source url (ie: bullet point images).
192-
filePath = result.path
193-
}
207+
const { path } = await this.observable(url, requestId, permanent, fileName).toPromise()
194208

195-
if (filePath) {
196-
return Platform.OS === 'android' ? 'file://' + filePath : filePath
209+
return path
210+
} finally {
211+
FileSystem.unlockCacheFile(fileName, requestId)
197212
}
198-
199-
return null
200213
}
201214

202215
/**
@@ -213,7 +226,7 @@ export class FileSystem {
213226
* @returns {Promise} promise that resolves to an object that contains cached file info.
214227
*/
215228
async cacheLocalFile(local: string, url: string, permanent = false, move = false) {
216-
const fileName = await this.getFileNameFromUrl(url)
229+
const fileName = this.getFileNameFromUrl(url)
217230
const path = this.baseFilePath + (permanent ? 'permanent/' : 'cache/') + fileName
218231
this._validatePath(path, true)
219232

@@ -254,52 +267,66 @@ export class FileSystem {
254267
* @param url {String} - url of file to download.
255268
* @param permanent {Boolean} - True persists the file locally indefinitely, false caches the file temporarily (until file is removed during cache pruning).
256269
* @param fileName {String} - defaults to a sha1 hash of the url param with extension of same filetype.
257-
* @param clobber {String} - whether or not to overwrite a file that already exists at path. defaults to false.
258-
* @returns {Promise} promise that resolves to an object that contains the local path of the downloaded file and the filename.
270+
* @param clobber {Boolean} - whether or not to overwrite a file that already exists at path. defaults to false.
271+
* @returns {Observable<CacheFileInfo>} observable that resolves to an object that contains the local path of the downloaded file and the filename.
259272
*/
260-
async fetchFile(url: string, permanent = false, fileName: string | null, clobber = false) {
261-
fileName = fileName || (await this.getFileNameFromUrl(url))
273+
fetchFile(
274+
url: string,
275+
permanent = false,
276+
fileName: string | null = null,
277+
clobber = false,
278+
): Observable<CacheFileInfo> {
279+
fileName = fileName || this.getFileNameFromUrl(url)
262280
const path = this.baseFilePath + (permanent ? 'permanent/' : 'cache/') + fileName
263281
this._validatePath(path, true)
264282

265-
// Clobber logic
266-
const fileExistsAtPath = await this.exists((permanent ? 'permanent/' : 'cache/') + fileName)
267-
if (!clobber && fileExistsAtPath) {
268-
throw new Error('A file already exists at ' + path + ' and clobber is set to false.')
269-
}
270-
271-
// Logic here prunes cache directory on "cache" writes to ensure cache doesn't get too large.
272-
if (!permanent) {
273-
await this.pruneCache()
274-
}
275-
276-
// Hit network and download file to local disk.
277-
try {
278-
const cacheDirExists = await this.exists(permanent ? 'permanent' : 'cache')
279-
if (!cacheDirExists) {
280-
await RNFS.mkdir(`${this.baseFilePath}${permanent ? 'permanent' : 'cache'}`)
281-
}
282-
283-
const { promise } = RNFS.downloadFile({
284-
fromUrl: url,
285-
toFile: path,
286-
})
287-
const response = await promise
288-
if (response.statusCode !== 200) {
289-
throw response
290-
}
291-
} catch (error) {
292-
await RNFSUnlinkIfExists(path)
293-
return {
294-
path: null,
295-
fileName: pathLib.basename(path),
296-
}
297-
}
298-
299-
return {
300-
path,
301-
fileName: pathLib.basename(path),
302-
}
283+
return from(this.exists((permanent ? 'permanent/' : 'cache/') + fileName)).pipe(
284+
// Clobber logic
285+
delayWhen((fileExistsAtPath) =>
286+
from(
287+
!clobber && fileExistsAtPath
288+
? throwError('A file already exists at ' + path + ' and clobber is set to false.')
289+
: Promise.resolve(),
290+
),
291+
),
292+
// Logic here prunes cache directory on "cache" writes to ensure cache doesn't get too large.
293+
delayWhen(() => from(!permanent ? this.pruneCache() : Promise.resolve())),
294+
delayWhen(() =>
295+
from(
296+
this.exists(permanent ? 'permanent' : 'cache').then((cacheDirExists) =>
297+
!cacheDirExists
298+
? RNFS.mkdir(`${this.baseFilePath}${permanent ? 'permanent' : 'cache'}`)
299+
: Promise.resolve(),
300+
),
301+
),
302+
),
303+
// Hit network and download file to local disk.
304+
mergeMap(() =>
305+
from(
306+
RNFS.downloadFile({
307+
fromUrl: url,
308+
toFile: path,
309+
}).promise,
310+
),
311+
),
312+
map((downloadResult) => {
313+
if (downloadResult.statusCode !== 200) {
314+
throw new Error('Request failed ' + downloadResult.statusCode)
315+
}
316+
return {
317+
path: Platform.OS === 'android' ? 'file://' + path : path,
318+
fileName: pathLib.basename(path),
319+
}
320+
}),
321+
catchError(() => {
322+
return from(RNFSUnlinkIfExists(path)).pipe(
323+
mapTo({
324+
path: null,
325+
fileName: pathLib.basename(path),
326+
}),
327+
)
328+
}),
329+
)
303330
}
304331

305332
/**
@@ -365,6 +392,66 @@ export class FileSystem {
365392
return false
366393
}
367394
}
395+
396+
observable(
397+
url: string,
398+
componentId: string,
399+
permanent = false,
400+
fileName: string | null = null,
401+
): Observable<CacheFileInfo> {
402+
if (!url) {
403+
return of({
404+
path: null,
405+
fileName: '',
406+
})
407+
}
408+
409+
fileName = fileName || this.getFileNameFromUrl(url)
410+
411+
if (!FileSystem.cacheLock[fileName] || !FileSystem.cacheLock[fileName][componentId]) {
412+
throw new Error('A lock must be aquired before requesting an observable')
413+
}
414+
415+
if (!FileSystem.cacheObservables[fileName]) {
416+
const permanentFileExists = this.exists('permanent/' + fileName)
417+
const cacheFileExists = this.exists('cache/' + fileName)
418+
419+
return (FileSystem.cacheObservables[fileName] = forkJoin([
420+
permanentFileExists,
421+
cacheFileExists,
422+
]).pipe(
423+
switchMap(([existsPermanent, existsCache]) => {
424+
// Check caches
425+
if (existsPermanent) {
426+
return of({
427+
path:
428+
(Platform.OS === 'android' ? 'file://' : '') +
429+
this.baseFilePath +
430+
'permanent/' +
431+
fileName,
432+
fileName,
433+
} as CacheFileInfo)
434+
} else if (existsCache) {
435+
return of({
436+
path:
437+
(Platform.OS === 'android' ? 'file://' : '') +
438+
this.baseFilePath +
439+
'cache/' +
440+
fileName,
441+
fileName,
442+
} as CacheFileInfo)
443+
}
444+
445+
// Download
446+
return this.fetchFile(url, permanent, fileName, true)
447+
}),
448+
publishReplay(1),
449+
refCount(),
450+
))
451+
}
452+
453+
return FileSystem.cacheObservables[fileName]
454+
}
368455
}
369456

370457
/**

0 commit comments

Comments
 (0)