From 1f80e4c10595ce3c6fb357ce298f8f6fb65d338e Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Tue, 18 Apr 2017 14:49:19 -0700 Subject: [PATCH] [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. --- src/components/Image/ImageUriCache.js | 61 ++++++++++++++++++++ src/components/Image/__tests__/index-test.js | 29 +++++++++- src/components/Image/index.js | 31 +++++++--- 3 files changed, 111 insertions(+), 10 deletions(-) create mode 100644 src/components/Image/ImageUriCache.js 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);