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);