diff --git a/src/components/Image/ImageUriCache.js b/src/components/Image/ImageUriCache.js new file mode 100644 index 00000000..84bfc1bd --- /dev/null +++ b/src/components/Image/ImageUriCache.js @@ -0,0 +1,61 @@ +class ImageUriCache { + static _maximumEntries: number = 256; + static _entries = {}; + + static has(uri: string) { + const entries = ImageUriCache._entries; + const isDataUri = /^data:/.test(uri); + return isDataUri || Boolean(entries[uri]); + } + + static add(uri: string) { + const entries = ImageUriCache._entries; + const lastUsedTimestamp = Date.now(); + if (entries[uri]) { + entries[uri].lastUsedTimestamp = lastUsedTimestamp; + entries[uri].refCount += 1; + } else { + entries[uri] = { + lastUsedTimestamp, + refCount: 1 + }; + } + } + + static remove(uri: string) { + const entries = ImageUriCache._entries; + if (entries[uri]) { + entries[uri].refCount -= 1; + } + // Free up entries when the cache is "full" + ImageUriCache._cleanUpIfNeeded(); + } + + static _cleanUpIfNeeded() { + const entries = ImageUriCache._entries; + const imageUris = Object.keys(entries); + + if (imageUris.length + 1 > ImageUriCache._maximumEntries) { + let leastRecentlyUsedKey; + let leastRecentlyUsedEntry; + + imageUris.forEach(uri => { + const entry = entries[uri]; + if ( + (!leastRecentlyUsedEntry || + entry.lastUsedTimestamp < leastRecentlyUsedEntry.lastUsedTimestamp) && + entry.refCount === 0 + ) { + leastRecentlyUsedKey = uri; + leastRecentlyUsedEntry = entry; + } + }); + + if (leastRecentlyUsedKey) { + delete entries[leastRecentlyUsedKey]; + } + } + } +} + +module.exports = ImageUriCache; diff --git a/src/components/Image/__tests__/index-test.js b/src/components/Image/__tests__/index-test.js index 5e5210af..a9c90326 100644 --- a/src/components/Image/__tests__/index-test.js +++ b/src/components/Image/__tests__/index-test.js @@ -1,8 +1,9 @@ /* eslint-env jasmine, jest */ import Image from '../'; +import ImageUriCache from '../ImageUriCache'; import React from 'react'; -import { render } from 'enzyme'; +import { mount, render } from 'enzyme'; const originalImage = window.Image; @@ -88,6 +89,32 @@ describe('components/Image', () => { }); }); + describe('prop "source"', () => { + test('is not set immediately if the image has not already been loaded', () => { + const uri = 'https://google.com/favicon.ico'; + const source = { uri }; + const component = render(); + expect(component.find('img')).toBeUndefined; + }); + + test('is set immediately if the image has already been loaded', () => { + const uriOne = 'https://google.com/favicon.ico'; + const uriTwo = 'https://twitter.com/favicon.ico'; + ImageUriCache.add(uriOne); + ImageUriCache.add(uriTwo); + + // initial render + const component = mount(); + ImageUriCache.remove(uriOne); + expect(component.render().find('img').attr('src')).toBe(uriOne); + + // props update + component.setProps({ source: { uri: uriTwo } }); + ImageUriCache.remove(uriTwo); + expect(component.render().find('img').attr('src')).toBe(uriTwo); + }); + }); + describe('prop "style"', () => { test('correctly supports "resizeMode" property', () => { const component = render(); diff --git a/src/components/Image/index.js b/src/components/Image/index.js index 79d210c3..e3528884 100644 --- a/src/components/Image/index.js +++ b/src/components/Image/index.js @@ -1,9 +1,10 @@ /* global window */ import applyNativeMethods from '../../modules/applyNativeMethods'; import createDOMElement from '../../modules/createDOMElement'; -import ImageResizeMode from './ImageResizeMode'; import ImageLoader from '../../modules/ImageLoader'; +import ImageResizeMode from './ImageResizeMode'; import ImageStylePropTypes from './ImageStylePropTypes'; +import ImageUriCache from './ImageUriCache'; import requestIdleCallback, { cancelIdleCallback } from '../../modules/requestIdleCallback'; import StyleSheet from '../../apis/StyleSheet'; import StyleSheetPropType from '../../propTypes/StyleSheetPropType'; @@ -29,6 +30,10 @@ const ImageSourcePropType = oneOfType([ string ]); +const getImageState = (uri, isPreviouslyLoaded) => { + return isPreviouslyLoaded ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE; +}; + const resolveAssetDimensions = source => { if (typeof source === 'object') { const { height, width } = source; @@ -73,17 +78,20 @@ class Image extends Component { constructor(props, context) { super(props, context); - this.state = { shouldDisplaySource: false }; + // If an image has been loaded before, render it immediately const uri = resolveAssetSource(props.source); - this._imageState = uri ? STATUS_PENDING : STATUS_IDLE; + const isPreviouslyLoaded = ImageUriCache.has(uri); + this.state = { shouldDisplaySource: isPreviouslyLoaded }; + this._imageState = getImageState(uri, isPreviouslyLoaded); + isPreviouslyLoaded && ImageUriCache.add(uri); this._isMounted = false; } componentDidMount() { + this._isMounted = true; if (this._imageState === STATUS_PENDING) { this._createImageLoader(); } - this._isMounted = true; } componentDidUpdate() { @@ -93,13 +101,18 @@ class Image extends Component { } componentWillReceiveProps(nextProps) { + const uri = resolveAssetSource(this.props.source); const nextUri = resolveAssetSource(nextProps.source); - if (resolveAssetSource(this.props.source) !== nextUri) { - this._updateImageState(nextUri ? STATUS_PENDING : STATUS_IDLE); + if (uri !== nextUri) { + ImageUriCache.remove(uri); + const isPreviouslyLoaded = ImageUriCache.has(nextUri); + isPreviouslyLoaded && ImageUriCache.add(uri); + this._updateImageState(getImageState(uri, isPreviouslyLoaded)); } } componentWillUnmount() { + ImageUriCache.remove(resolveAssetSource(this.props.source)); this._destroyImageLoader(); this._isMounted = false; } @@ -164,8 +177,8 @@ class Image extends Component { } _createImageLoader() { + this._destroyImageLoader(); this._loadRequest = requestIdleCallback(() => { - this._destroyImageLoader(); const uri = resolveAssetSource(this.props.source); this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError); this._onLoadStart(); @@ -198,9 +211,9 @@ class Image extends Component { }; _onLoad = e => { - const { onLoad } = this.props; + const { onLoad, source } = this.props; const event = { nativeEvent: e }; - + ImageUriCache.add(resolveAssetSource(source)); this._updateImageState(STATUS_LOADED); if (onLoad) { onLoad(event);