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

Commit a8ae9f6

Browse files
authored
Merge pull request #10 from mnightingale/feature/if-none-match
Use if-none-match header
2 parents b7f2397 + 5018223 commit a8ae9f6

File tree

7 files changed

+338
-108
lines changed

7 files changed

+338
-108
lines changed

src/FileSystem.tsx

Lines changed: 50 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import {
2525
delayWhen,
2626
concatAll,
2727
take,
28+
tap,
2829
} from 'rxjs/operators'
2930
import uuid from 'react-native-uuid'
3031
import { CacheStrategy } from '.'
@@ -33,6 +34,7 @@ import { HeaderFn } from './types'
3334
export interface CacheFileInfo {
3435
path: string | null
3536
fileName: string
37+
md5?: string
3638
}
3739

3840
/**
@@ -270,6 +272,7 @@ export class FileSystem {
270272
FileSystem.cacheObservables[fileName].next({
271273
path: 'file://' + path,
272274
fileName,
275+
md5: await RNFS.hash(path, 'md5').catch(() => undefined),
273276
})
274277
}
275278

@@ -301,26 +304,54 @@ export class FileSystem {
301304
// Logic here prunes cache directory on "cache" writes to ensure cache doesn't get too large.
302305
from(cacheDirExists ? this.pruneCache() : RNFS.mkdir(this.baseFilePath)),
303306
),
304-
mergeMap(() => from(RNFS.stat(path)).pipe(catchError(() => of(null)))),
305-
// Hit network and download file to local disk.
306-
mergeMap((stat) =>
307-
from(
308-
RNFS.downloadFile({
307+
mergeMap(() => from(RNFS.exists(path))),
308+
mergeMap((alreadyExists) =>
309+
// Hit network and download file to local disk.
310+
defer(async () => {
311+
let beginResult: RNFS.DownloadBeginCallbackResult | undefined
312+
313+
const downloadResult = await RNFS.downloadFile({
309314
fromUrl: url,
310315
toFile: path,
311316
headers,
312-
}).promise,
313-
).pipe(
314-
// Only need to emit or throw errors if the file has changed or this is the first download
315-
filter((downloadResult) => stat === null || downloadResult.statusCode === 200),
316-
map((downloadResult) => {
317-
if (stat === null && downloadResult.statusCode !== 200) {
318-
throw new Error('Request failed ' + downloadResult.statusCode)
317+
begin: (res: RNFS.DownloadBeginCallbackResult) => {
318+
beginResult = res
319+
},
320+
}).promise
321+
return Promise.resolve({ downloadResult, beginResult })
322+
}).pipe(
323+
// If "304 Not Modified" response touch local file
324+
tap(({ downloadResult }) => {
325+
if (alreadyExists && downloadResult.statusCode === 304) {
326+
// Unfortunately react-native-fs only shares headers for status >=200 && status < 300
327+
RNFS.touch(path, new Date(), new Date())
319328
}
320-
329+
}),
330+
// Only need to emit if the local file has changed
331+
filter(({ downloadResult }) => downloadResult.statusCode === 200),
332+
mergeMap(({ downloadResult, beginResult }) =>
333+
defer(async () => {
334+
if (beginResult !== undefined && 'ETag' in beginResult.headers) {
335+
return {
336+
md5: beginResult.headers['ETag'],
337+
}
338+
}
339+
return {
340+
md5: await RNFS.hash(path, 'md5').catch(() => undefined),
341+
}
342+
}).pipe(
343+
map((extra) => ({
344+
downloadResult,
345+
beginResult,
346+
extra,
347+
})),
348+
),
349+
),
350+
map(({ extra }) => {
321351
return {
322352
path: 'file://' + path,
323353
fileName: pathLib.basename(path),
354+
md5: extra.md5,
324355
}
325356
}),
326357
),
@@ -454,27 +485,29 @@ export class FileSystem {
454485

455486
const subject$ = new ReplaySubject<CacheFileInfo>()
456487

457-
const obs$ = from(RNFS.stat(this.baseFilePath + fileName)).pipe(
488+
const obs$ = from(RNFS.hash(this.baseFilePath + fileName, 'md5')).pipe(
458489
catchError(() => of(null)),
459-
switchMap((stat) => {
460-
if (stat !== null) {
490+
switchMap((md5) => {
491+
if (md5 !== null) {
461492
switch (cacheStrategy) {
462493
case 'immutable': {
463494
return of({
464495
path: 'file://' + this.baseFilePath + fileName,
465496
fileName,
497+
md5,
466498
} as CacheFileInfo)
467499
}
468500
case 'mutable': {
469501
return from([
470502
of({
471503
path: 'file://' + this.baseFilePath + fileName,
472504
fileName,
505+
md5,
473506
} as CacheFileInfo),
474507
defer(async () => (headers instanceof Function ? await headers() : headers)).pipe(
475508
mergeMap((headers) =>
476509
this.fetchFile(url, fileName, {
477-
'if-modified-since': new Date(stat.mtime).toUTCString(),
510+
'if-none-match': md5,
478511
...headers,
479512
}),
480513
),

src/index.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import FileSystemFactory, { CacheFileInfo, FileSystem } from './FileSystem'
1919
import traverse from 'traverse'
2020
import validator from 'validator'
2121
import uuid from 'react-native-uuid'
22-
import { Image, ImageStyle, Platform, StyleProp } from 'react-native'
22+
import { Image, ImageStyle, StyleProp } from 'react-native'
2323
import { BehaviorSubject, Subscription } from 'rxjs'
2424
import { skip, takeUntil } from 'rxjs/operators'
2525
import URL from 'url-parse'
@@ -310,11 +310,11 @@ const imageCacheHoc = <P extends object>(
310310
FileSystem.unlockCacheFile(fileName, this.componentId)
311311
}
312312

313-
onSourceLoaded({ path }: CacheFileInfo) {
313+
onSourceLoaded({ path, md5 }: CacheFileInfo) {
314314
this.setState({
315315
source: path
316316
? {
317-
uri: path + (Platform.OS === 'android' ? '?' + Date.now() : ''),
317+
uri: path + (md5 !== undefined ? '?' + md5 : ''),
318318
}
319319
: undefined,
320320
})

tests/CacheableImage.test.tsx

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,22 @@ describe('CacheableImage', function () {
106106
expect(MockedRNFS.copyFile).toHaveBeenCalled()
107107
})
108108

109+
it('When local file exists but it fails to be copied to the cache.', async () => {
110+
MockedRNFS.copyFile.mockRejectedValueOnce(new Error('Copy failed'))
111+
112+
const CacheableImage = imageCacheHoc(Image)
113+
114+
const local = '/exists.png'
115+
const url = 'https://example.com/exists.png'
116+
117+
const result = await CacheableImage.cacheLocalFile(local, url)
118+
119+
expect(result).toStrictEqual({
120+
url: 'https://example.com/exists.png',
121+
path: null,
122+
})
123+
})
124+
109125
it('When local file exists, it should be moved to the cache', async () => {
110126
const CacheableImage = imageCacheHoc(Image)
111127

@@ -160,6 +176,34 @@ describe('CacheableImage', function () {
160176
path:
161177
'file:///base/file/path/react-native-image-cache-hoc/90c1be491d18ff2a7280039e9b65749461a65403.png',
162178
fileName: '90c1be491d18ff2a7280039e9b65749461a65403.png',
179+
md5: '#md5#',
180+
})
181+
done()
182+
},
183+
)
184+
})
185+
186+
it('When local file and observable exist, it should be notified of changes even if hash fails.', async (done) => {
187+
MockedRNFS.hash.mockRejectedValueOnce(new Error('File not found.'))
188+
189+
FileSystem.cacheObservables[
190+
'90c1be491d18ff2a7280039e9b65749461a65403.png'
191+
] = new ReplaySubject<CacheFileInfo>(1)
192+
193+
const CacheableImage = imageCacheHoc(Image)
194+
195+
const local = '/exists.png'
196+
const url = 'https://example.com/exists.png'
197+
198+
await CacheableImage.cacheLocalFile(local, url)
199+
200+
FileSystem.cacheObservables['90c1be491d18ff2a7280039e9b65749461a65403.png'].subscribe(
201+
(value) => {
202+
expect(value).toStrictEqual({
203+
path:
204+
'file:///base/file/path/react-native-image-cache-hoc/90c1be491d18ff2a7280039e9b65749461a65403.png',
205+
fileName: '90c1be491d18ff2a7280039e9b65749461a65403.png',
206+
md5: undefined, // Hash failed
163207
})
164208
done()
165209
},
@@ -301,22 +345,74 @@ describe('CacheableImage', function () {
301345
setImmediate(() => {
302346
expect(wrapper.prop('source')).toStrictEqual({
303347
uri:
304-
'file:///base/file/path/react-native-image-cache-hoc/d3b74e9fa8248a5805e2dcf17a8577acd28c089b.png',
348+
'file:///base/file/path/react-native-image-cache-hoc/d3b74e9fa8248a5805e2dcf17a8577acd28c089b.png?#md5#',
305349
})
306350

307351
wrapper.setProps({ source: { uri: 'https://example.com/B.jpg' } })
308352

309353
setImmediate(() => {
310354
expect(wrapper.prop('source')).toStrictEqual({
311355
uri:
312-
'file:///base/file/path/react-native-image-cache-hoc/a940ee9ea388fcea7628d9a64dfac6a698aa0228.jpg',
356+
'file:///base/file/path/react-native-image-cache-hoc/a940ee9ea388fcea7628d9a64dfac6a698aa0228.jpg?#md5#',
313357
})
314358

315359
done()
316360
})
317361
})
318362
})
319363

364+
it('componentDidUpdate should validate the source prop correctly.', (done) => {
365+
console.warn = jest.fn()
366+
367+
const CacheableImage = imageCacheHoc(Image)
368+
369+
const wrapper = shallow(<CacheableImage {...mockData.mockCacheableImageProps} />)
370+
371+
setImmediate(() => {
372+
expect(wrapper.prop('source')).toBeDefined()
373+
374+
// Update to an invalid uri
375+
wrapper.setProps({ source: { uri: './local-file.jpg' } })
376+
377+
setImmediate(() => {
378+
expect(console.warn).toHaveBeenNthCalledWith(
379+
1,
380+
'Invalid source prop. <CacheableImage> props.source.uri should be a web accessible url with a valid protocol and host. NOTE: Default valid protocol is https, default valid hosts are *.',
381+
)
382+
383+
expect(wrapper.prop('source')).toBeUndefined()
384+
385+
done()
386+
})
387+
})
388+
})
389+
390+
it('componentDidUpdate should allow file protocol.', (done) => {
391+
console.warn = jest.fn()
392+
393+
const CacheableImage = imageCacheHoc(Image)
394+
395+
const wrapper = shallow(<CacheableImage {...mockData.mockCacheableImageProps} />)
396+
397+
setImmediate(() => {
398+
expect(wrapper.prop('source')).toBeDefined()
399+
400+
// Update to a local file uri
401+
wrapper.setProps({ source: { uri: 'file:///local-file.jpg' } })
402+
403+
setImmediate(() => {
404+
expect(console.warn).toHaveBeenNthCalledWith(
405+
1,
406+
'Invalid source prop. <CacheableImage> props.source.uri should be a web accessible url with a valid protocol and host. NOTE: Default valid protocol is https, default valid hosts are *.',
407+
)
408+
409+
expect(wrapper.prop('source')).toHaveProperty('uri', 'file:///local-file.jpg')
410+
411+
done()
412+
})
413+
})
414+
})
415+
320416
it('#render with valid props does not throw an error.', (done) => {
321417
const CacheableImage = imageCacheHoc(Image)
322418

@@ -325,7 +421,7 @@ describe('CacheableImage', function () {
325421
setImmediate(() => {
326422
expect(wrapper.prop('source')).toStrictEqual({
327423
uri:
328-
'file:///base/file/path/react-native-image-cache-hoc/d3b74e9fa8248a5805e2dcf17a8577acd28c089b.png',
424+
'file:///base/file/path/react-native-image-cache-hoc/d3b74e9fa8248a5805e2dcf17a8577acd28c089b.png?#md5#',
329425
})
330426

331427
wrapper.setState({

0 commit comments

Comments
 (0)