From f4e8b6b1942aa1ea0a36f596c34e0271abbb0b16 Mon Sep 17 00:00:00 2001 From: Nicolas Gallagher Date: Mon, 24 Feb 2020 13:03:42 -0800 Subject: [PATCH] [change] modernize Image Rewrite Image to use function components and hooks. Fix #1322 --- .../src/exports/Image/ImageUriCache.js | 70 --- .../__snapshots__/index-test.js.snap | 8 +- .../src/exports/Image/__tests__/index-test.js | 90 ++- .../src/exports/Image/index.js | 537 ++++++++---------- .../src/exports/View/types.js | 4 +- .../src/modules/ImageLoader/index.js | 100 +++- 6 files changed, 409 insertions(+), 400 deletions(-) delete mode 100644 packages/react-native-web/src/exports/Image/ImageUriCache.js diff --git a/packages/react-native-web/src/exports/Image/ImageUriCache.js b/packages/react-native-web/src/exports/Image/ImageUriCache.js deleted file mode 100644 index 32cb1be5..00000000 --- a/packages/react-native-web/src/exports/Image/ImageUriCache.js +++ /dev/null @@ -1,70 +0,0 @@ -/** - * Copyright (c) Nicolas Gallagher. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - * @flow - */ - -const dataUriPattern = /^data:/; - -export default class ImageUriCache { - static _maximumEntries: number = 256; - static _entries = {}; - - static has(uri: string) { - const entries = ImageUriCache._entries; - const isDataUri = dataUriPattern.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]; - } - } - } -} diff --git a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap index 3d6d2a17..bee18f10 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap +++ b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap @@ -317,14 +317,14 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`] >
{ ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { onLoad(); }); + const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); - render(); + const onLoadEndStub = jest.fn(); + render( + + ); jest.runOnlyPendingTimers(); - expect(ImageLoader.load).toBeCalled(); expect(onLoadStub).toBeCalled(); }); @@ -101,32 +109,74 @@ describe('components/Image', () => { ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { onLoad(); }); + const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); + const onLoadEndStub = jest.fn(); const uri = 'https://test.com/img.jpg'; ImageUriCache.add(uri); - render(); + render( + + ); jest.runOnlyPendingTimers(); - expect(ImageLoader.load).not.toBeCalled(); expect(onLoadStub).toBeCalled(); ImageUriCache.remove(uri); }); test('is called on update if "uri" is different', () => { + const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); + const onLoadEndStub = jest.fn(); const { rerender } = render( - + ); - rerender(); + act(() => { + rerender( + + ); + }); expect(onLoadStub.mock.calls.length).toBe(2); + expect(onLoadEndStub.mock.calls.length).toBe(2); }); test('is not called on update if "uri" is the same', () => { + const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); + const onLoadEndStub = jest.fn(); const { rerender } = render( - + ); - rerender(); + act(() => { + rerender( + + ); + }); expect(onLoadStub.mock.calls.length).toBe(1); + expect(onLoadEndStub.mock.calls.length).toBe(1); }); }); @@ -178,15 +228,19 @@ describe('components/Image', () => { ImageUriCache.remove(uriOne); expect(container.firstChild).toMatchSnapshot(); // props update - rerender(); - ImageUriCache.remove(uriTwo); + act(() => { + rerender(); + ImageUriCache.remove(uriTwo); + }); expect(container.firstChild).toMatchSnapshot(); }); test('is correctly updated when missing in initial render', () => { const uri = 'https://testing.com/img.jpg'; const { container, rerender } = render(); - rerender(); + act(() => { + rerender(); + }); expect(container.firstChild).toMatchSnapshot(); }); @@ -199,7 +253,9 @@ describe('components/Image', () => { }); const { container } = render(); expect(container.firstChild).toMatchSnapshot(); - loadCallback(); + act(() => { + loadCallback(); + }); expect(container.firstChild).toMatchSnapshot(); }); @@ -215,8 +271,10 @@ describe('components/Image', () => { let { container } = render(); expect(container.querySelector('img').src).toBe('http://localhost/static/img.png'); - PixelRatio.get = jest.fn(() => 2.2); - ({ container } = render()); + act(() => { + PixelRatio.get = jest.fn(() => 2.2); + ({ container } = render()); + }); expect(container.querySelector('img').src).toBe('http://localhost/static/img@2x.png'); }); }); diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 10d20d8f..d6c0ae0f 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -11,18 +11,16 @@ import type { ViewProps } from '../View'; import type { ResizeMode, Source, Style } from './types'; -import applyNativeMethods from '../../modules/applyNativeMethods'; import createElement from '../createElement'; import css from '../StyleSheet/css'; import { getAssetByID } from '../../modules/AssetRegistry'; import resolveShadowValue from '../StyleSheet/resolveShadowValue'; import ImageLoader from '../../modules/ImageLoader'; -import ImageUriCache from './ImageUriCache'; import PixelRatio from '../PixelRatio'; import StyleSheet from '../StyleSheet'; import TextAncestorContext from '../Text/TextAncestorContext'; import View from '../View'; -import React from 'react'; +import React, { forwardRef, useContext, useEffect, useRef, useState } from 'react'; export type ImageProps = { ...ViewProps, @@ -40,34 +38,84 @@ export type ImageProps = { style?: Style }; -type State = { - layout: Object, - shouldDisplaySource: boolean -}; +const ERRORED = 'ERRORED'; +const LOADED = 'LOADED'; +const LOADING = 'LOADING'; +const IDLE = 'IDLE'; -const STATUS_ERRORED = 'ERRORED'; -const STATUS_LOADED = 'LOADED'; -const STATUS_LOADING = 'LOADING'; -const STATUS_PENDING = 'PENDING'; -const STATUS_IDLE = 'IDLE'; +let _filterId = 0; +const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; -const getImageState = (uri, shouldDisplaySource) => { - return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE; -}; +function createTintColorSVG(tintColor, id) { + return tintColor && id != null ? ( + + + + + + + + + ) : null; +} -const resolveAssetDimensions = source => { +function getFlatStyle(style, blurRadius, filterId) { + const flatStyle = { ...StyleSheet.flatten(style) }; + + const { filter, resizeMode, shadowOffset, tintColor } = flatStyle; + + // Add CSS filters + // React Native exposes these features as props and proprietary styles + const filters = []; + let _filter = null; + + if (filter) { + filters.push(filter); + } + // + if (blurRadius) { + filters.push(`blur(${blurRadius}px)`); + } + if (shadowOffset) { + const shadowString = resolveShadowValue(flatStyle); + if (shadowString) { + filters.push(`drop-shadow(${shadowString})`); + } + } + if (tintColor && filterId != null) { + filters.push(`url(#tint-${filterId})`); + } + + if (filters.length > 0) { + _filter = filters.join(' '); + } + + // These styles are converted to CSS filters applied to the + // element displaying the background image. + delete flatStyle.shadowColor; + delete flatStyle.shadowOpacity; + delete flatStyle.shadowOffset; + delete flatStyle.shadowRadius; + delete flatStyle.tintColor; + // These styles are not supported on View + delete flatStyle.overlayColor; + delete flatStyle.resizeMode; + + return [flatStyle, resizeMode, _filter, tintColor]; +} + +function resolveAssetDimensions(source) { if (typeof source === 'number') { const { height, width } = getAssetByID(source); return { height, width }; - } else if (typeof source === 'object') { + } else if (source != null && typeof source === 'object') { const { height, width } = source; return { height, width }; } -}; +} -const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; -const resolveAssetUri = source => { - let uri = ''; +function resolveAssetUri(source): ?string { + let uri = null; if (typeof source === 'number') { // get the URI from the packager const asset = getAssetByID(source); @@ -98,306 +146,193 @@ const resolveAssetUri = source => { } return uri; -}; +} -let filterId = 0; +const Image = forwardRef((props, ref) => { + const { + accessibilityLabel, + accessibilityRelationship, + accessibilityRole, + accessibilityState, + accessible, + blurRadius, + defaultSource, + draggable, + importantForAccessibility, + nativeID, + onError, + onLayout, + onLoad, + onLoadEnd, + onLoadStart, + pointerEvents, + source, + testID + } = props; -const createTintColorSVG = (tintColor, id) => - tintColor && id != null ? ( - - - - - - - - - ) : null; - -class Image extends React.Component { - static displayName = 'Image'; - - static getSize(uri, success, failure) { - ImageLoader.getSize(uri, success, failure); - } - - static prefetch(uri) { - return ImageLoader.prefetch(uri).then(() => { - // Add the uri to the cache so it can be immediately displayed when used - // but also immediately remove it to correctly reflect that it has no active references - ImageUriCache.add(uri); - ImageUriCache.remove(uri); - }); - } - - static queryCache(uris) { - const result = {}; - uris.forEach(u => { - if (ImageUriCache.has(u)) { - result[u] = 'disk/memory'; - } - }); - return Promise.resolve(result); - } - - _filterId = 0; - _imageRef = null; - _imageRequestId = null; - _imageState = null; - _isMounted = false; - - constructor(props, context) { - super(props, context); - // If an image has been loaded before, render it immediately - const uri = resolveAssetUri(props.source); - const shouldDisplaySource = ImageUriCache.has(uri); - this.state = { layout: {}, shouldDisplaySource }; - this._imageState = getImageState(uri, shouldDisplaySource); - this._filterId = filterId; - filterId++; - } - - componentDidMount() { - this._isMounted = true; - if (this._imageState === STATUS_PENDING) { - this._createImageLoader(); - } else if (this._imageState === STATUS_LOADED) { - this._onLoad({ target: this._imageRef }); + if (process.env.NODE_ENV !== 'production') { + if (props.children) { + throw new Error( + 'The component cannot contain children. If you want to render content on top of the image, consider using the component or absolute positioning.' + ); } } - componentDidUpdate(prevProps) { - const prevUri = resolveAssetUri(prevProps.source); - const uri = resolveAssetUri(this.props.source); - const hasDefaultSource = this.props.defaultSource != null; - if (prevUri !== uri) { - ImageUriCache.remove(prevUri); - const isPreviouslyLoaded = ImageUriCache.has(uri); - isPreviouslyLoaded && ImageUriCache.add(uri); - this._updateImageState(getImageState(uri, isPreviouslyLoaded), hasDefaultSource); - } else if (hasDefaultSource && prevProps.defaultSource !== this.props.defaultSource) { - this._updateImageState(this._imageState, hasDefaultSource); - } - if (this._imageState === STATUS_PENDING) { - this._createImageLoader(); - } - } - - componentWillUnmount() { - const uri = resolveAssetUri(this.props.source); - ImageUriCache.remove(uri); - this._destroyImageLoader(); - this._isMounted = false; - } - - renderImage(hasTextAncestor) { - const { shouldDisplaySource } = this.state; - const { - accessibilityLabel, - accessibilityRelationship, - accessibilityRole, - accessibilityState, - accessible, - blurRadius, - defaultSource, - draggable, - importantForAccessibility, - nativeID, - pointerEvents, - resizeMode, - source, - testID - } = this.props; - - if (process.env.NODE_ENV !== 'production') { - if (this.props.children) { - throw new Error( - 'The component cannot contain children. If you want to render content on top of the image, consider using the component or absolute positioning.' - ); - } - } - - const selectedSource = shouldDisplaySource ? source : defaultSource; - const displayImageUri = resolveAssetUri(selectedSource); - const imageSizeStyle = resolveAssetDimensions(selectedSource); - const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; - const flatStyle = { ...StyleSheet.flatten(this.props.style) }; - const finalResizeMode = resizeMode || flatStyle.resizeMode || 'cover'; - - // CSS filters - const filters = []; - const tintColor = flatStyle.tintColor; - if (flatStyle.filter) { - filters.push(flatStyle.filter); - } - if (blurRadius) { - filters.push(`blur(${blurRadius}px)`); - } - if (flatStyle.shadowOffset) { - const shadowString = resolveShadowValue(flatStyle); - if (shadowString) { - filters.push(`drop-shadow(${shadowString})`); - } - } - if (flatStyle.tintColor) { - filters.push(`url(#tint-${this._filterId})`); - } - - // these styles were converted to filters - delete flatStyle.shadowColor; - delete flatStyle.shadowOpacity; - delete flatStyle.shadowOffset; - delete flatStyle.shadowRadius; - delete flatStyle.tintColor; - // these styles are not supported on View - delete flatStyle.overlayColor; - delete flatStyle.resizeMode; - - // Accessibility image allows users to trigger the browser's image context menu - const hiddenImage = displayImageUri - ? createElement('img', { - alt: accessibilityLabel || '', - classList: [classes.accessibilityImage], - draggable: draggable || false, - ref: this._setImageRef, - src: displayImageUri - }) - : null; - - return ( - - 0 && { filter: filters.join(' ') } - ]} - /> - {hiddenImage} - {createTintColorSVG(tintColor, this._filterId)} - - ); - } - - render() { - return ( - - {hasTextAncestor => this.renderImage(hasTextAncestor)} - - ); - } - - _createImageLoader() { - const { source } = this.props; - this._destroyImageLoader(); + const [state, updateState] = useState(() => { const uri = resolveAssetUri(source); - this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError); - this._onLoadStart(); - } - - _destroyImageLoader() { - if (this._imageRequestId) { - ImageLoader.abort(this._imageRequestId); - this._imageRequestId = null; + if (uri != null) { + const isLoaded = ImageLoader.has(uri); + if (isLoaded) { + return LOADED; + } } - } + return IDLE; + }); - _createLayoutHandler = resizeMode => { - const { onLayout } = this.props; - if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) { - return e => { - const { layout } = e.nativeEvent; - onLayout && onLayout(e); - this.setState(() => ({ layout })); - }; - } - }; + const [layout, updateLayout] = useState({}); + const hasTextAncestor = useContext(TextAncestorContext); + const hiddenImageRef = useRef(null); + const filterRef = useRef(_filterId++); + const requestRef = useRef(null); + const shouldDisplaySource = state === LOADED || (state === LOADING && defaultSource == null); + const [flatStyle, _resizeMode, filter, tintColor] = getFlatStyle( + props.style, + blurRadius, + filterRef.current + ); + const resizeMode = props.resizeMode || _resizeMode || 'cover'; + const selectedSource = shouldDisplaySource ? source : defaultSource; + const displayImageUri = resolveAssetUri(selectedSource); + const imageSizeStyle = resolveAssetDimensions(selectedSource); + const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; + const backgroundSize = getBackgroundSize(); - _getBackgroundSize = resizeMode => { - if (this._imageRef && (resizeMode === 'center' || resizeMode === 'repeat')) { - const { naturalHeight, naturalWidth } = this._imageRef; - const { height, width } = this.state.layout; + // Accessibility image allows users to trigger the browser's image context menu + const hiddenImage = displayImageUri + ? createElement('img', { + alt: accessibilityLabel || '', + classList: [classes.accessibilityImage], + draggable: draggable || false, + ref: hiddenImageRef, + src: displayImageUri + }) + : null; + + function getBackgroundSize(): ?string { + if (hiddenImageRef.current != null && (resizeMode === 'center' || resizeMode === 'repeat')) { + const { naturalHeight, naturalWidth } = hiddenImageRef.current; + const { height, width } = layout; if (naturalHeight && naturalWidth && height && width) { const scaleFactor = Math.min(1, width / naturalWidth, height / naturalHeight); const x = Math.ceil(scaleFactor * naturalWidth); const y = Math.ceil(scaleFactor * naturalHeight); - return { - backgroundSize: `${x}px ${y}px` - }; + return `${x}px ${y}px`; } } - }; + } - _onError = () => { - const { onError, source } = this.props; - this._updateImageState(STATUS_ERRORED); - if (onError) { - onError({ - nativeEvent: { - error: `Failed to load resource ${resolveAssetUri(source)} (404)` + function handleLayout(e) { + if (resizeMode === 'center' || resizeMode === 'repeat' || onLayout) { + const { layout } = e.nativeEvent; + onLayout && onLayout(e); + updateLayout(layout); + } + } + + // Image loading + useEffect(() => { + abortPendingRequest(); + + const uri = resolveAssetUri(source); + + if (uri != null) { + updateState(LOADING); + if (onLoadStart) { + onLoadStart(); + } + + requestRef.current = ImageLoader.load( + uri, + function load(e) { + updateState(LOADED); + if (onLoad) { + onLoad(); + } + if (onLoadEnd) { + onLoadEnd(); + } + }, + function error() { + updateState(ERRORED); + if (onError) { + onError({ + nativeEvent: { + error: `Failed to load resource ${uri} (404)` + } + }); + } + if (onLoadEnd) { + onLoadEnd(); + } } - }); + ); } - this._onLoadEnd(); - }; - _onLoad = e => { - const { onLoad, source } = this.props; - const event = { nativeEvent: e }; - ImageUriCache.add(resolveAssetUri(source)); - this._updateImageState(STATUS_LOADED); - if (onLoad) { - onLoad(event); - } - this._onLoadEnd(); - }; - - _onLoadEnd() { - const { onLoadEnd } = this.props; - if (onLoadEnd) { - onLoadEnd(); - } - } - - _onLoadStart() { - const { defaultSource, onLoadStart } = this.props; - this._updateImageState(STATUS_LOADING, defaultSource != null); - if (onLoadStart) { - onLoadStart(); - } - } - - _setImageRef = ref => { - this._imageRef = ref; - }; - - _updateImageState(status: ?string, hasDefaultSource: ?boolean = false) { - this._imageState = status; - const shouldDisplaySource = - this._imageState === STATUS_LOADED || - (this._imageState === STATUS_LOADING && !hasDefaultSource); - // only triggers a re-render when the image is loading and has no default image (to support PJPEG), loaded, or failed - if (shouldDisplaySource !== this.state.shouldDisplaySource) { - if (this._isMounted) { - this.setState(() => ({ shouldDisplaySource })); + function abortPendingRequest() { + if (requestRef.current != null) { + ImageLoader.abort(requestRef.current); + requestRef.current = null; } } - } -} + + return abortPendingRequest; + }, [source, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]); + + return ( + + + {hiddenImage} + {createTintColorSVG(tintColor, filterRef.current)} + + ); +}); + +Image.displayName = 'Image'; + +// $FlowFixMe +Image.getSize = function(uri, success, failure) { + ImageLoader.getSize(uri, success, failure); +}; + +// $FlowFixMe +Image.prefetch = function(uri) { + return ImageLoader.prefetch(uri); +}; + +// $FlowFixMe +Image.queryCache = function(uris) { + return ImageLoader.queryCache(uris); +}; const classes = css.create({ accessibilityImage: { @@ -454,4 +389,4 @@ const resizeModeStyles = StyleSheet.create({ } }); -export default applyNativeMethods(Image); +export default Image; diff --git a/packages/react-native-web/src/exports/View/types.js b/packages/react-native-web/src/exports/View/types.js index 552fbaed..b116ceea 100644 --- a/packages/react-native-web/src/exports/View/types.js +++ b/packages/react-native-web/src/exports/View/types.js @@ -33,14 +33,14 @@ export type ViewStyle = { backgroundBlendMode?: string, backgroundClip?: string, backgroundColor?: ColorValue, - backgroundImage?: string, + backgroundImage?: ?string, backgroundOrigin?: 'border-box' | 'content-box' | 'padding-box', backgroundPosition?: string, backgroundRepeat?: string, backgroundSize?: string, boxShadow?: string, clip?: string, - filter?: string, + filter?: ?string, opacity?: number, outlineColor?: ColorValue, outlineOffset?: string | number, diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 72a77662..605d739b 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -4,9 +4,71 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @noflow + * @flow */ +const dataUriPattern = /^data:/; + +export class ImageUriCache { + static _maximumEntries: number = 256; + static _entries = {}; + + static has(uri: string) { + const entries = ImageUriCache._entries; + const isDataUri = dataUriPattern.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]; + } + } + } +} + let id = 0; const requests = {}; @@ -14,11 +76,13 @@ const ImageLoader = { abort(requestId: number) { let image = requests[`${requestId}`]; if (image) { - image.onerror = image.onload = image = null; + image.onerror = null; + image.onload = null; + image = null; delete requests[`${requestId}`]; } }, - getSize(uri, success, failure) { + getSize(uri: string, success: Function, failure: Function) { let complete = false; const interval = setInterval(callback, 16); const requestId = ImageLoader.load(uri, callback, errorCallback); @@ -46,13 +110,16 @@ const ImageLoader = { clearInterval(interval); } }, - load(uri, onLoad, onError): number { + has(uri: string) { + return ImageUriCache.has(uri); + }, + load(uri: string, onLoad: Function, onError: Function): number { id += 1; const image = new window.Image(); image.onerror = onError; image.onload = e => { // avoid blocking the main thread - const onDecode = () => onLoad(e); + const onDecode = () => onLoad(); if (typeof image.decode === 'function') { // Safari currently throws exceptions when decoding svgs. // We want to catch that error and allow the load handler @@ -66,10 +133,29 @@ const ImageLoader = { requests[`${id}`] = image; return id; }, - prefetch(uri): Promise { + prefetch(uri: string): Promise<*> { return new Promise((resolve, reject) => { - ImageLoader.load(uri, resolve, reject); + ImageLoader.load( + uri, + () => { + // Add the uri to the cache so it can be immediately displayed when used + // but also immediately remove it to correctly reflect that it has no active references + ImageUriCache.add(uri); + ImageUriCache.remove(uri); + resolve(); + }, + reject + ); }); + }, + queryCache(uris: Array): Object { + const result = {}; + uris.forEach(u => { + if (ImageUriCache.has(u)) { + result[u] = 'disk/memory'; + } + }); + return Promise.resolve(result); } };