diff --git a/packages/react-native-web/src/exports/Image/ImageUriCache.js b/packages/react-native-web/src/exports/Image/ImageUriCache.js index 32cb1be50..0cb8335fe 100644 --- a/packages/react-native-web/src/exports/Image/ImageUriCache.js +++ b/packages/react-native-web/src/exports/Image/ImageUriCache.js @@ -7,36 +7,59 @@ * @flow */ -const dataUriPattern = /^data:/; +import ImageLoader from '../../modules/ImageLoader'; + +type ImageSource = + | string + | number + | { + method: ?string, + uri: ?string, + headers: ?Object, + body: ?string + }; export default class ImageUriCache { static _maximumEntries: number = 256; static _entries = {}; - static has(uri: string) { + static createCacheId(source: ImageSource) { + return JSON.stringify(ImageLoader.resolveSource(source)); + } + + static has(source: ImageSource) { + const entries = ImageUriCache._entries; + const cacheId = ImageUriCache.createCacheId(source); + return Boolean(entries[cacheId]); + } + + static get(source: ImageSource) { const entries = ImageUriCache._entries; - const isDataUri = dataUriPattern.test(uri); - return isDataUri || Boolean(entries[uri]); + const cacheId = ImageUriCache.createCacheId(source); + return entries[cacheId]; } - static add(uri: string) { + static add(source: ImageSource, displayImageUri: ?string) { const entries = ImageUriCache._entries; const lastUsedTimestamp = Date.now(); - if (entries[uri]) { - entries[uri].lastUsedTimestamp = lastUsedTimestamp; - entries[uri].refCount += 1; + const cacheId = ImageUriCache.createCacheId(source); + if (entries[cacheId]) { + entries[cacheId].lastUsedTimestamp = lastUsedTimestamp; + entries[cacheId].refCount += 1; } else { - entries[uri] = { + entries[cacheId] = { lastUsedTimestamp, - refCount: 1 + refCount: 1, + displayImageUri: displayImageUri || ImageLoader.resolveSource(source).uri }; } } - static remove(uri: string) { + static remove(source: ImageSource) { const entries = ImageUriCache._entries; - if (entries[uri]) { - entries[uri].refCount -= 1; + const cacheId = ImageUriCache.createCacheId(source); + if (entries[cacheId]) { + entries[cacheId].refCount -= 1; } // Free up entries when the cache is "full" ImageUriCache._cleanUpIfNeeded(); @@ -44,20 +67,20 @@ export default class ImageUriCache { static _cleanUpIfNeeded() { const entries = ImageUriCache._entries; - const imageUris = Object.keys(entries); + const cacheIds = Object.keys(entries); - if (imageUris.length + 1 > ImageUriCache._maximumEntries) { + if (cacheIds.length + 1 > ImageUriCache._maximumEntries) { let leastRecentlyUsedKey; let leastRecentlyUsedEntry; - imageUris.forEach(uri => { - const entry = entries[uri]; + cacheIds.forEach(cacheId => { + const entry = entries[cacheId]; if ( (!leastRecentlyUsedEntry || entry.lastUsedTimestamp < leastRecentlyUsedEntry.lastUsedTimestamp) && entry.refCount === 0 ) { - leastRecentlyUsedKey = uri; + leastRecentlyUsedKey = cacheId; leastRecentlyUsedEntry = entry; } }); diff --git a/packages/react-native-web/src/exports/Image/__tests__/index-test.js b/packages/react-native-web/src/exports/Image/__tests__/index-test.js index b9dfa9ae6..560537773 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Image/__tests__/index-test.js @@ -8,7 +8,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import StyleSheet from '../../StyleSheet'; -const originalImage = window.Image; +const OriginalImage = window.Image; const findImageSurfaceStyle = wrapper => StyleSheet.flatten(wrapper.childAt(0).prop('style')); @@ -19,7 +19,7 @@ describe('components/Image', () => { }); afterEach(() => { - window.Image = originalImage; + window.Image = OriginalImage; }); test('prop "accessibilityLabel"', () => { @@ -95,11 +95,12 @@ describe('components/Image', () => { describe('prop "onLoad"', () => { test('is called after image is loaded from network', () => { jest.useFakeTimers(); + const uri = 'https://test.com/img.jpg'; ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(); + onLoad(uri); }); const onLoadStub = jest.fn(); - shallow(); + shallow(); jest.runOnlyPendingTimers(); expect(ImageLoader.load).toBeCalled(); expect(onLoadStub).toBeCalled(); @@ -107,11 +108,11 @@ describe('components/Image', () => { test('is called after image is loaded from cache', () => { jest.useFakeTimers(); + const uri = 'https://test.com/img.jpg'; ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(); + onLoad(uri); }); const onLoadStub = jest.fn(); - const uri = 'https://test.com/img.jpg'; ImageUriCache.add(uri); shallow(); jest.runOnlyPendingTimers(); @@ -164,7 +165,7 @@ describe('components/Image', () => { test('is set immediately if the image was preloaded', () => { const uri = 'https://yahoo.com/favicon.ico'; ImageLoader.load = jest.fn().mockImplementationOnce((_, onLoad, onError) => { - onLoad(); + onLoad(uri); }); return Image.prefetch(uri).then(() => { const source = { uri }; @@ -222,7 +223,7 @@ describe('components/Image', () => { }); const component = shallow(); expect(component.find('img').prop('src')).toBe(defaultUri); - loadCallback(); + loadCallback(uri); expect(component.find('img').prop('src')).toBe(uri); }); }); diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 2262a68e1..6e612fbe5 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -11,7 +11,6 @@ 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 ImageResizeMode from './ImageResizeMode'; @@ -33,46 +32,15 @@ const STATUS_LOADING = 'LOADING'; const STATUS_PENDING = 'PENDING'; const STATUS_IDLE = 'IDLE'; -const getImageState = (uri, shouldDisplaySource) => { - return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE; +const getImageState = (source, shouldDisplaySource) => { + return shouldDisplaySource ? STATUS_LOADED : source ? STATUS_PENDING : STATUS_IDLE; }; const resolveAssetDimensions = source => { - if (typeof source === 'number') { - const { height, width } = getAssetByID(source); - return { height, width }; - } else if (typeof source === 'object') { - const { height, width } = source; - return { height, width }; - } -}; - -const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; -const resolveAssetUri = source => { - let uri = ''; - if (typeof source === 'number') { - // get the URI from the packager - const asset = getAssetByID(source); - const scale = asset.scales[0]; - const scaleSuffix = scale !== 1 ? `@${scale}x` : ''; - uri = asset ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` : ''; - } else if (typeof source === 'string') { - uri = source; - } else if (source && typeof source.uri === 'string') { - uri = source.uri; - } - - if (uri) { - const match = uri.match(svgDataUriPattern); - // inline SVG markup may contain characters (e.g., #, ") that need to be escaped - if (match) { - const [, prefix, svg] = match; - const encodedSvg = encodeURIComponent(svg); - return `${prefix}${encodedSvg}`; - } - } - - return uri; + return { + height: source.height, + width: source.width + }; }; let filterId = 0; @@ -91,7 +59,8 @@ const createTintColorSVG = (tintColor, id) => type State = { layout: Object, - shouldDisplaySource: boolean + shouldDisplaySource: boolean, + displayImageUri: string }; class Image extends Component<*, State> { @@ -130,10 +99,10 @@ class Image extends Component<*, State> { } static prefetch(uri) { - return ImageLoader.prefetch(uri).then(() => { + return ImageLoader.prefetch(uri).then(displayImageUri => { // 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.add(uri, displayImageUri); ImageUriCache.remove(uri); }); } @@ -157,10 +126,18 @@ class Image extends Component<*, State> { 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); + const resolvedSource = ImageLoader.resolveSource(props.source); + const resolvedDefaultSource = ImageLoader.resolveSource(props.defaultSource); + const cachedSource = ImageUriCache.get(props.source); + const shouldDisplaySource = !!cachedSource; + this.state = { + layout: {}, + shouldDisplaySource, + displayImageUri: shouldDisplaySource + ? cachedSource.displayImageUri + : resolvedDefaultSource.uri || resolvedSource.uri + }; + this._imageState = getImageState(props.source, shouldDisplaySource); this._filterId = filterId; filterId++; } @@ -170,20 +147,21 @@ class Image extends Component<*, State> { if (this._imageState === STATUS_PENDING) { this._createImageLoader(); } else if (this._imageState === STATUS_LOADED) { - this._onLoad({ target: this._imageRef }); + this._onLoad(this.state.displayImageUri, { target: this._imageRef }); } } 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) { + const { defaultSource, source } = this.props; + const prevCacheId = ImageUriCache.createCacheId(prevProps.source); + const cacheId = ImageUriCache.createCacheId(source); + const hasDefaultSource = defaultSource != null; + if (prevCacheId !== cacheId) { + ImageUriCache.remove(prevProps.source); + const shouldDisplaySource = ImageUriCache.has(source); + shouldDisplaySource && ImageUriCache.add(source); + this._updateImageState(getImageState(source, shouldDisplaySource), hasDefaultSource); + } else if (hasDefaultSource && prevProps.defaultSource !== defaultSource) { this._updateImageState(this._imageState, hasDefaultSource); } if (this._imageState === STATUS_PENDING) { @@ -192,14 +170,13 @@ class Image extends Component<*, State> { } componentWillUnmount() { - const uri = resolveAssetUri(this.props.source); - ImageUriCache.remove(uri); + ImageUriCache.remove(this.props.source); this._destroyImageLoader(); this._isMounted = false; } render() { - const { shouldDisplaySource } = this.state; + const { displayImageUri, shouldDisplaySource } = this.state; const { accessibilityLabel, accessible, @@ -233,8 +210,7 @@ class Image extends Component<*, State> { } } - const selectedSource = shouldDisplaySource ? source : defaultSource; - const displayImageUri = resolveAssetUri(selectedSource); + const selectedSource = ImageLoader.resolveSource(shouldDisplaySource ? source : defaultSource); const imageSizeStyle = resolveAssetDimensions(selectedSource); const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; const flatStyle = { ...StyleSheet.flatten(this.props.style) }; @@ -312,8 +288,12 @@ class Image extends Component<*, State> { _createImageLoader() { const { source } = this.props; this._destroyImageLoader(); - const uri = resolveAssetUri(source); - this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError); + this._imageRequestId = ImageLoader.load( + ImageLoader.resolveSource(source), + this._onLoad, + this._onError, + this._onProgress + ); this._onLoadStart(); } @@ -350,23 +330,33 @@ class Image extends Component<*, State> { } }; + _onProgress = event => { + const { onProgress } = this.props; + if (onProgress) { + onProgress({ + nativeEvent: event + }); + } + }; + _onError = () => { const { onError, source } = this.props; this._updateImageState(STATUS_ERRORED); if (onError) { onError({ nativeEvent: { - error: `Failed to load resource ${resolveAssetUri(source)} (404)` + error: `Failed to load resource ${ImageLoader.resolveSource(source).uri} (404)` } }); } this._onLoadEnd(); }; - _onLoad = e => { + _onLoad = (displayImageUri, e) => { const { onLoad, source } = this.props; const event = { nativeEvent: e }; - ImageUriCache.add(resolveAssetUri(source)); + + ImageUriCache.add(source, displayImageUri); this._updateImageState(STATUS_LOADED); if (onLoad) { onLoad(event); @@ -394,14 +384,29 @@ class Image extends Component<*, State> { }; _updateImageState(status: ?string, hasDefaultSource: ?boolean = false) { + const { source, defaultSource } = this.props; + const resolvedSource = ImageLoader.resolveSource(defaultSource); + const resolvedDefaultSource = ImageLoader.resolveSource(source); this._imageState = status; const shouldDisplaySource = this._imageState === STATUS_LOADED || (this._imageState === STATUS_LOADING && !hasDefaultSource); + const { displayImageUri } = ImageUriCache.has(source) + ? ImageUriCache.get(source) + : { + displayImageUri: resolvedSource.uri || resolvedDefaultSource.uri + }; + // 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 ( + shouldDisplaySource !== this.state.shouldDisplaySource || + displayImageUri !== this.state.displayImageUri + ) { if (this._isMounted) { - this.setState(() => ({ shouldDisplaySource })); + this.setState(() => ({ + shouldDisplaySource, + displayImageUri + })); } } } diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 72a77662b..df78ce533 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -7,8 +7,12 @@ * @noflow */ +import { getAssetByID } from '../AssetRegistry'; + let id = 0; const requests = {}; +const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; +const dataUriPattern = /^data:/; const ImageLoader = { abort(requestId: number) { @@ -21,7 +25,7 @@ const ImageLoader = { getSize(uri, success, failure) { let complete = false; const interval = setInterval(callback, 16); - const requestId = ImageLoader.load(uri, callback, errorCallback); + const requestId = ImageLoader.load({ uri }, callback, errorCallback); function callback() { const image = requests[`${requestId}`]; @@ -46,13 +50,17 @@ const ImageLoader = { clearInterval(interval); } }, - load(uri, onLoad, onError): number { + load(source, onLoad, onError, onProgress): number { + const { uri, method, headers, body } = { uri: '', method: 'GET', headers: {}, ...source }; id += 1; + + // Create image const image = new window.Image(); image.onerror = onError; image.onload = e => { // avoid blocking the main thread - const onDecode = () => onLoad(e); + const onDecode = () => onLoad(image.src, e); + if (typeof image.decode === 'function') { // Safari currently throws exceptions when decoding svgs. // We want to catch that error and allow the load handler @@ -62,14 +70,97 @@ const ImageLoader = { setTimeout(onDecode, 0); } }; - image.src = uri; requests[`${id}`] = image; + + // If the important source properties are empty, return the image directly + if (!source || !uri) { + return id; + } + + // If the image is a dataUri, display it directly via image + const isDataUri = dataUriPattern.test(uri); + if (isDataUri) { + image.src = uri; + return id; + } + + // If the image can be retrieved via GET, we can fallback to image loading method + if (method === 'GET') { + image.src = uri; + return id; + } + + // Load image via XHR + const request = new window.XMLHttpRequest(); + request.open(method, uri); + request.responseType = 'blob'; + request.withCredentials = false; + request.onerror = () => { + // Fall back to image (e.g. for CORS issues) + image.src = uri; + }; + + // Add request headers + for (const [name, value] of Object.entries(headers)) { + request.setRequestHeader(name, value); + } + + // When the request finished loading, pass it on to the image + request.onload = () => { + image.src = window.URL.createObjectURL(request.response); + }; + + // Track progress + request.onprogress = onProgress; + + // Send the request + request.send(body); + return id; }, prefetch(uri): Promise { return new Promise((resolve, reject) => { - ImageLoader.load(uri, resolve, reject); + ImageLoader.load({ uri }, resolve, reject); }); + }, + resolveSource(source) { + let resolvedSource = { + method: 'GET', + uri: '', + headers: {}, + width: undefined, + height: undefined + }; + if (typeof source === 'number') { + // get the URI from the packager + const asset = getAssetByID(source); + const scale = asset.scales[0]; + const scaleSuffix = scale !== 1 ? `@${scale}x` : ''; + resolvedSource.uri = asset + ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` + : ''; + resolvedSource.width = asset.width; + resolvedSource.height = asset.height; + } else if (typeof source === 'string') { + resolvedSource.uri = source; + } else if (typeof source === 'object') { + resolvedSource = { + ...resolvedSource, + ...source + }; + } + + if (resolvedSource.uri) { + const match = resolvedSource.uri.match(svgDataUriPattern); + // inline SVG markup may contain characters (e.g., #, ") that need to be escaped + if (match) { + const [, prefix, svg] = match; + const encodedSvg = encodeURIComponent(svg); + resolvedSource.uri = `${prefix}${encodedSvg}`; + } + } + + return resolvedSource; } }; diff --git a/packages/website/storybook/1-components/Image/ImageScreen.js b/packages/website/storybook/1-components/Image/ImageScreen.js index f72a55dc3..f87bbf0c3 100644 --- a/packages/website/storybook/1-components/Image/ImageScreen.js +++ b/packages/website/storybook/1-components/Image/ImageScreen.js @@ -11,6 +11,7 @@ import PropOnError from './examples/PropOnError'; import PropOnLoad from './examples/PropOnLoad'; import PropOnLoadEnd from './examples/PropOnLoadEnd'; import PropOnLoadStart from './examples/PropOnLoadStart'; +import PropOnProgress from './examples/PropOnProgress'; import PropResizeMode from './examples/PropResizeMode'; import PropSource from './examples/PropSource'; import StaticGetSizeExample from './examples/StaticGetSize'; @@ -105,6 +106,19 @@ const ImageScreen = () => ( }} /> + + Invoked on download progress with {'{nativeEvent: {loaded, total}}'}. + + } + example={{ + render: () => + }} + /> + - {this.state.message && {this.state.message}} + {this.state.messages.map((message, index) => { + return ( + + {message} + + ); + })} ); } _handleError = e => { - const nextState = { loading: false }; - if (this.props.logMethod === 'onError') { - nextState.message = `✘ onError ${JSON.stringify(e.nativeEvent)}`; - } - this.setState(() => nextState); + this.setState(state => { + const messages = [...state.messages]; + if (this.props.logMethod === 'onError') { + messages.push(`✘ onError ${JSON.stringify(e.nativeEvent)}`); + } + + return { + loading: false, + messages + }; + }); }; _handleLoad = () => { - const nextState = { loading: false }; - if (this.props.logMethod === 'onLoad') { - nextState.message = '✔ onLoad'; - } - this.setState(() => nextState); + this.setState(state => { + const messages = [...state.messages]; + if (this.props.logMethod === 'onLoad') { + messages.push('✔ onLoad'); + } + + return { + loading: false, + messages + }; + }); }; _handleLoadEnd = () => { - const nextState = { loading: false }; - if (this.props.logMethod === 'onLoadEnd') { - nextState.message = '✔ onLoadEnd'; - } - this.setState(() => nextState); + this.setState(state => { + const messages = [...state.messages]; + if (this.props.logMethod === 'onLoadEnd') { + messages.push('✔ onLoadEnd'); + } + + return { + loading: false, + messages + }; + }); }; _handleLoadStart = () => { - const nextState = { loading: true }; - if (this.props.logMethod === 'onLoadStart') { - nextState.message = '✔ onLoadStart'; - } - this.setState(() => nextState); + this.setState(state => { + const messages = [...state.messages]; + if (this.props.logMethod === 'onLoadStart') { + messages.push('✔ onLoadStart'); + } + + return { + loading: false, + messages + }; + }); + }; + + _handleProgress = e => { + this.setState(state => { + const messages = [...state.messages]; + if (this.props.logMethod === 'onProgress') { + const { loaded, total } = e.nativeEvent; + messages.push( + `✔ onProgress ${JSON.stringify({ + loaded, + total + })}` + ); + } + + return { + messages + }; + }); }; } diff --git a/packages/website/storybook/1-components/Image/examples/PropOnProgress.js b/packages/website/storybook/1-components/Image/examples/PropOnProgress.js new file mode 100644 index 000000000..85acbdf8b --- /dev/null +++ b/packages/website/storybook/1-components/Image/examples/PropOnProgress.js @@ -0,0 +1,14 @@ +/** + * @flow + */ + +import { createUncachedURI } from '../helpers'; +import NetworkImage from './NetworkImage'; +import React from 'react'; +import sources from '../sources'; + +const ImageOnProgressExample = () => ( + +); + +export default ImageOnProgressExample; diff --git a/packages/website/storybook/1-components/Image/examples/PropSource.js b/packages/website/storybook/1-components/Image/examples/PropSource.js index 43995f50e..70608eac3 100644 --- a/packages/website/storybook/1-components/Image/examples/PropSource.js +++ b/packages/website/storybook/1-components/Image/examples/PropSource.js @@ -36,6 +36,20 @@ const ImageSourceExample = () => ( + + + WebP + + + + Dynamic (POST) + + + + Redirect + + + ); diff --git a/packages/website/storybook/1-components/Image/sources/index.js b/packages/website/storybook/1-components/Image/sources/index.js index 254edc8e8..dd6307568 100644 --- a/packages/website/storybook/1-components/Image/sources/index.js +++ b/packages/website/storybook/1-components/Image/sources/index.js @@ -44,7 +44,18 @@ const sources = { }, dataSvg, dataBase64Png, - dataBase64Svg + dataBase64Svg, + webP: { + uri: 'https://www.gstatic.com/webp/gallery/4.sm.webp' + }, + dynamic: { + uri: 'https://chart.googleapis.com/chart', + method: 'POST', + body: 'cht=lc&chtt=Test&chs=300x200&chxt=x&chd=t:40,20,50,20,100' + }, + redirect: { + uri: 'https://twitter.com/twitter/profile_image?size=original' + } }; export default sources;