[change] render Image 'source' immediately if previously loaded

Maintain a record of loaded images. If an image has already been loaded,
bypass the JS loading logic and render it immediately. This prevents
flashes of placeholder state when moving between screens or items in a
virtualized list.
This commit is contained in:
Nicolas Gallagher
2017-04-18 14:49:19 -07:00
parent dbc8f31be6
commit 1f80e4c105
3 changed files with 111 additions and 10 deletions
+61
View File
@@ -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;
+28 -1
View File
@@ -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(<Image source={source} />);
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(<Image source={{ uri: uriOne }} />);
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(<Image style={{ resizeMode: Image.resizeMode.contain }} />);
+22 -9
View File
@@ -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);