From 3e5bb60db92c66132d0d80ca735957f9eeac452f Mon Sep 17 00:00:00 2001 From: Julian Hundeloh Date: Mon, 4 Nov 2019 14:31:46 +0100 Subject: [PATCH 1/4] fix: support image source objects --- .../src/exports/Image/ImageUriCache.js | 41 +++--- .../src/exports/Image/index.js | 124 ++++++++++++------ .../src/modules/ImageLoader/index.js | 53 +++++++- .../1-components/Image/examples/PropSource.js | 14 ++ .../1-components/Image/sources/index.js | 13 +- 5 files changed, 179 insertions(+), 66 deletions(-) diff --git a/packages/react-native-web/src/exports/Image/ImageUriCache.js b/packages/react-native-web/src/exports/Image/ImageUriCache.js index 32cb1be50..6d0ebf652 100644 --- a/packages/react-native-web/src/exports/Image/ImageUriCache.js +++ b/packages/react-native-web/src/exports/Image/ImageUriCache.js @@ -7,36 +7,39 @@ * @flow */ -const dataUriPattern = /^data:/; - export default class ImageUriCache { static _maximumEntries: number = 256; static _entries = {}; - static has(uri: string) { + static has(cacheId: string) { + const entries = ImageUriCache._entries; + return Boolean(entries[cacheId]); + } + + static get(cacheId: string) { const entries = ImageUriCache._entries; - const isDataUri = dataUriPattern.test(uri); - return isDataUri || Boolean(entries[uri]); + return entries[cacheId]; } - static add(uri: string) { + static add(cacheId: string, displayImageUri: string) { const entries = ImageUriCache._entries; const lastUsedTimestamp = Date.now(); - if (entries[uri]) { - entries[uri].lastUsedTimestamp = lastUsedTimestamp; - entries[uri].refCount += 1; + if (entries[cacheId]) { + entries[cacheId].lastUsedTimestamp = lastUsedTimestamp; + entries[cacheId].refCount += 1; } else { - entries[uri] = { + entries[cacheId] = { lastUsedTimestamp, - refCount: 1 + refCount: 1, + displayImageUri }; } } - static remove(uri: string) { + static remove(cacheId: string) { const entries = ImageUriCache._entries; - if (entries[uri]) { - entries[uri].refCount -= 1; + if (entries[cacheId]) { + entries[cacheId].refCount -= 1; } // Free up entries when the cache is "full" ImageUriCache._cleanUpIfNeeded(); @@ -44,20 +47,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/index.js b/packages/react-native-web/src/exports/Image/index.js index 2262a68e1..f1fd6955e 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -38,41 +38,57 @@ const getImageState = (uri, shouldDisplaySource) => { }; 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 }; - } + return { + height: source.height, + width: source.width + }; }; const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; -const resolveAssetUri = source => { - let uri = ''; +const resolveAssetSource = 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` : ''; - uri = asset ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` : ''; + resolvedSource.uri = asset + ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` + : ''; + resolvedSource.width = asset.width; + resolvedSource.height = asset.height; } else if (typeof source === 'string') { - uri = source; - } else if (source && typeof source.uri === 'string') { - uri = source.uri; + resolvedSource.uri = source; + } else if (typeof source === 'object') { + resolvedSource = { + ...resolvedSource, + ...source + }; } - if (uri) { - const match = uri.match(svgDataUriPattern); + 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); - return `${prefix}${encodedSvg}`; + resolvedSource.uri = `${prefix}${encodedSvg}`; } } - return uri; + return resolvedSource; +}; +const getCacheId = source => { + return JSON.stringify(resolveAssetSource(source)); +}; +const getCacheUrl = e => { + return e.path && e.path[0].src; }; let filterId = 0; @@ -91,7 +107,8 @@ const createTintColorSVG = (tintColor, id) => type State = { layout: Object, - shouldDisplaySource: boolean + shouldDisplaySource: boolean, + displayImageUri: string }; class Image extends Component<*, State> { @@ -130,10 +147,10 @@ class Image extends Component<*, State> { } static prefetch(uri) { - return ImageLoader.prefetch(uri).then(() => { + return ImageLoader.prefetch(uri).then(e => { // 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, getCacheUrl(e)); ImageUriCache.remove(uri); }); } @@ -157,10 +174,19 @@ 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 cacheId = getCacheId(props.source); + const resolvedSource = resolveAssetSource(props.source); + const resolvedDefaultSource = resolveAssetSource(props.defaultSource); + const cachedSource = ImageUriCache.get(cacheId); + const shouldDisplaySource = !!cachedSource; + this.state = { + layout: {}, + shouldDisplaySource, + displayImageUri: shouldDisplaySource + ? cachedSource.uri + : resolvedDefaultSource.uri || resolvedSource.uri + }; + this._imageState = getImageState(resolvedSource.uri, shouldDisplaySource); this._filterId = filterId; filterId++; } @@ -175,14 +201,14 @@ class Image extends Component<*, State> { } componentDidUpdate(prevProps) { - const prevUri = resolveAssetUri(prevProps.source); - const uri = resolveAssetUri(this.props.source); + const prevCacheId = getCacheId(prevProps.source); + const cacheId = getCacheId(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); + if (prevCacheId !== cacheId) { + ImageUriCache.remove(prevCacheId); + const isPreviouslyLoaded = ImageUriCache.has(cacheId); + isPreviouslyLoaded && ImageUriCache.add(cacheId); + this._updateImageState(getImageState(cacheId, isPreviouslyLoaded), hasDefaultSource); } else if (hasDefaultSource && prevProps.defaultSource !== this.props.defaultSource) { this._updateImageState(this._imageState, hasDefaultSource); } @@ -192,14 +218,14 @@ class Image extends Component<*, State> { } componentWillUnmount() { - const uri = resolveAssetUri(this.props.source); - ImageUriCache.remove(uri); + const cacheId = getCacheId(this.props.source); + ImageUriCache.remove(cacheId); this._destroyImageLoader(); this._isMounted = false; } render() { - const { shouldDisplaySource } = this.state; + const { displayImageUri, shouldDisplaySource } = this.state; const { accessibilityLabel, accessible, @@ -233,8 +259,7 @@ class Image extends Component<*, State> { } } - const selectedSource = shouldDisplaySource ? source : defaultSource; - const displayImageUri = resolveAssetUri(selectedSource); + const selectedSource = resolveAssetSource(shouldDisplaySource ? source : defaultSource); const imageSizeStyle = resolveAssetDimensions(selectedSource); const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; const flatStyle = { ...StyleSheet.flatten(this.props.style) }; @@ -312,8 +337,11 @@ 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( + resolveAssetSource(source), + this._onLoad, + this._onError + ); this._onLoadStart(); } @@ -356,7 +384,7 @@ class Image extends Component<*, State> { if (onError) { onError({ nativeEvent: { - error: `Failed to load resource ${resolveAssetUri(source)} (404)` + error: `Failed to load resource ${resolveAssetSource(source).uri} (404)` } }); } @@ -366,7 +394,7 @@ class Image extends Component<*, State> { _onLoad = e => { const { onLoad, source } = this.props; const event = { nativeEvent: e }; - ImageUriCache.add(resolveAssetUri(source)); + ImageUriCache.add(getCacheId(source), getCacheUrl(e)); this._updateImageState(STATUS_LOADED); if (onLoad) { onLoad(event); @@ -394,14 +422,26 @@ class Image extends Component<*, State> { }; _updateImageState(status: ?string, hasDefaultSource: ?boolean = false) { + const { source } = this.props; this._imageState = status; const shouldDisplaySource = this._imageState === STATUS_LOADED || (this._imageState === STATUS_LOADING && !hasDefaultSource); + const cachedId = getCacheId(source); + const { displayImageUri } = ImageUriCache.has(cachedId) + ? ImageUriCache.get(cachedId) + : this.state; + // 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..aae98f01e 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -9,6 +9,7 @@ let id = 0; const requests = {}; +const dataUriPattern = /^data:/; const ImageLoader = { abort(requestId: number) { @@ -21,7 +22,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,8 +47,11 @@ const ImageLoader = { clearInterval(interval); } }, - load(uri, onLoad, onError): number { + load(source, onLoad, onError): 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 => { @@ -62,13 +66,54 @@ 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); + }; + + // 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); }); } }; 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; From ba73240dabb55aae2412ae246b475a13044b9c21 Mon Sep 17 00:00:00 2001 From: jaulz Date: Tue, 5 Nov 2019 09:35:26 +0100 Subject: [PATCH 2/4] fix: catch issues when source changed --- .../src/exports/Image/ImageUriCache.js | 30 ++++- .../src/exports/Image/__tests__/index-test.js | 26 +++-- .../src/exports/Image/index.js | 106 ++++++------------ .../src/modules/ImageLoader/index.js | 43 +++++++ 4 files changed, 123 insertions(+), 82 deletions(-) diff --git a/packages/react-native-web/src/exports/Image/ImageUriCache.js b/packages/react-native-web/src/exports/Image/ImageUriCache.js index 6d0ebf652..0cb8335fe 100644 --- a/packages/react-native-web/src/exports/Image/ImageUriCache.js +++ b/packages/react-native-web/src/exports/Image/ImageUriCache.js @@ -7,23 +7,42 @@ * @flow */ +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(cacheId: 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(cacheId: string) { + static get(source: ImageSource) { const entries = ImageUriCache._entries; + const cacheId = ImageUriCache.createCacheId(source); return entries[cacheId]; } - static add(cacheId: string, displayImageUri: string) { + static add(source: ImageSource, displayImageUri: ?string) { const entries = ImageUriCache._entries; const lastUsedTimestamp = Date.now(); + const cacheId = ImageUriCache.createCacheId(source); if (entries[cacheId]) { entries[cacheId].lastUsedTimestamp = lastUsedTimestamp; entries[cacheId].refCount += 1; @@ -31,13 +50,14 @@ export default class ImageUriCache { entries[cacheId] = { lastUsedTimestamp, refCount: 1, - displayImageUri + displayImageUri: displayImageUri || ImageLoader.resolveSource(source).uri }; } } - static remove(cacheId: string) { + static remove(source: ImageSource) { const entries = ImageUriCache._entries; + const cacheId = ImageUriCache.createCacheId(source); if (entries[cacheId]) { entries[cacheId].refCount -= 1; } 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..108ee9113 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,10 +8,19 @@ 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')); +const createLoadEvent = uri => { + const target = new OriginalImage(); + target.src = uri; + const event = new window.Event('load'); + event.path = [target]; + + return event; +}; + describe('components/Image', () => { beforeEach(() => { ImageUriCache._entries = {}; @@ -19,7 +28,7 @@ describe('components/Image', () => { }); afterEach(() => { - window.Image = originalImage; + window.Image = OriginalImage; }); test('prop "accessibilityLabel"', () => { @@ -95,11 +104,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(createLoadEvent(uri)); }); const onLoadStub = jest.fn(); - shallow(); + shallow(); jest.runOnlyPendingTimers(); expect(ImageLoader.load).toBeCalled(); expect(onLoadStub).toBeCalled(); @@ -107,11 +117,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(createLoadEvent(uri)); }); const onLoadStub = jest.fn(); - const uri = 'https://test.com/img.jpg'; ImageUriCache.add(uri); shallow(); jest.runOnlyPendingTimers(); @@ -164,7 +174,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(createLoadEvent(uri)); }); return Image.prefetch(uri).then(() => { const source = { uri }; @@ -222,7 +232,7 @@ describe('components/Image', () => { }); const component = shallow(); expect(component.find('img').prop('src')).toBe(defaultUri); - loadCallback(); + loadCallback(createLoadEvent(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 f1fd6955e..61544ea15 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,8 +32,8 @@ 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 => { @@ -44,51 +43,17 @@ const resolveAssetDimensions = source => { }; }; -const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; -const resolveAssetSource = 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 - }; +const getCacheUrl = e => { + if (e.target) { + return e.target.src; } - 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}`; - } + // Target is not defined at this moment anymore in Chrome and thus we use path + if (e.path && e.path[0]) { + return e.path[0].src; } - return resolvedSource; -}; -const getCacheId = source => { - return JSON.stringify(resolveAssetSource(source)); -}; -const getCacheUrl = e => { - return e.path && e.path[0].src; + return undefined; }; let filterId = 0; @@ -174,19 +139,18 @@ class Image extends Component<*, State> { constructor(props, context) { super(props, context); // If an image has been loaded before, render it immediately - const cacheId = getCacheId(props.source); - const resolvedSource = resolveAssetSource(props.source); - const resolvedDefaultSource = resolveAssetSource(props.defaultSource); - const cachedSource = ImageUriCache.get(cacheId); + 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.uri + ? cachedSource.displayImageUri : resolvedDefaultSource.uri || resolvedSource.uri }; - this._imageState = getImageState(resolvedSource.uri, shouldDisplaySource); + this._imageState = getImageState(props.source, shouldDisplaySource); this._filterId = filterId; filterId++; } @@ -201,15 +165,16 @@ class Image extends Component<*, State> { } componentDidUpdate(prevProps) { - const prevCacheId = getCacheId(prevProps.source); - const cacheId = getCacheId(this.props.source); - const hasDefaultSource = this.props.defaultSource != null; + 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(prevCacheId); - const isPreviouslyLoaded = ImageUriCache.has(cacheId); - isPreviouslyLoaded && ImageUriCache.add(cacheId); - this._updateImageState(getImageState(cacheId, isPreviouslyLoaded), hasDefaultSource); - } else if (hasDefaultSource && prevProps.defaultSource !== this.props.defaultSource) { + 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) { @@ -218,8 +183,7 @@ class Image extends Component<*, State> { } componentWillUnmount() { - const cacheId = getCacheId(this.props.source); - ImageUriCache.remove(cacheId); + ImageUriCache.remove(this.props.source); this._destroyImageLoader(); this._isMounted = false; } @@ -259,7 +223,7 @@ class Image extends Component<*, State> { } } - const selectedSource = resolveAssetSource(shouldDisplaySource ? source : defaultSource); + const selectedSource = ImageLoader.resolveSource(shouldDisplaySource ? source : defaultSource); const imageSizeStyle = resolveAssetDimensions(selectedSource); const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; const flatStyle = { ...StyleSheet.flatten(this.props.style) }; @@ -338,7 +302,7 @@ class Image extends Component<*, State> { const { source } = this.props; this._destroyImageLoader(); this._imageRequestId = ImageLoader.load( - resolveAssetSource(source), + ImageLoader.resolveSource(source), this._onLoad, this._onError ); @@ -384,7 +348,7 @@ class Image extends Component<*, State> { if (onError) { onError({ nativeEvent: { - error: `Failed to load resource ${resolveAssetSource(source).uri} (404)` + error: `Failed to load resource ${ImageLoader.resolveSource(source).uri} (404)` } }); } @@ -394,7 +358,8 @@ class Image extends Component<*, State> { _onLoad = e => { const { onLoad, source } = this.props; const event = { nativeEvent: e }; - ImageUriCache.add(getCacheId(source), getCacheUrl(e)); + + ImageUriCache.add(source, getCacheUrl(e)); this._updateImageState(STATUS_LOADED); if (onLoad) { onLoad(event); @@ -422,15 +387,18 @@ class Image extends Component<*, State> { }; _updateImageState(status: ?string, hasDefaultSource: ?boolean = false) { - const { source } = this.props; + 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 cachedId = getCacheId(source); - const { displayImageUri } = ImageUriCache.has(cachedId) - ? ImageUriCache.get(cachedId) - : this.state; + 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 ( diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index aae98f01e..bdd15dca6 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -7,8 +7,11 @@ * @noflow */ +import { getAssetByID } from '../AssetRegistry'; + let id = 0; const requests = {}; +const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; const dataUriPattern = /^data:/; const ImageLoader = { @@ -57,6 +60,7 @@ const ImageLoader = { image.onload = e => { // avoid blocking the main thread const onDecode = () => onLoad(e); + if (typeof image.decode === 'function') { // Safari currently throws exceptions when decoding svgs. // We want to catch that error and allow the load handler @@ -115,6 +119,45 @@ const ImageLoader = { return new Promise((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; } }; From acd3b335518a362842ecfe006a796fcfd2c1a076 Mon Sep 17 00:00:00 2001 From: jaulz Date: Tue, 5 Nov 2019 10:01:33 +0100 Subject: [PATCH 3/4] feat: support onProgress prop --- .../src/exports/Image/index.js | 12 ++- .../src/modules/ImageLoader/index.js | 5 +- .../1-components/Image/ImageScreen.js | 14 +++ .../Image/examples/NetworkImage.js | 97 ++++++++++++++----- .../Image/examples/PropOnProgress.js | 14 +++ 5 files changed, 117 insertions(+), 25 deletions(-) create mode 100644 packages/website/storybook/1-components/Image/examples/PropOnProgress.js diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 61544ea15..e8a8cbf3a 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -304,7 +304,8 @@ class Image extends Component<*, State> { this._imageRequestId = ImageLoader.load( ImageLoader.resolveSource(source), this._onLoad, - this._onError + this._onError, + this._onProgress ); this._onLoadStart(); } @@ -342,6 +343,15 @@ 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); diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index bdd15dca6..77ee2872a 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -50,7 +50,7 @@ const ImageLoader = { clearInterval(interval); } }, - load(source, onLoad, onError): number { + load(source, onLoad, onError, onProgress): number { const { uri, method, headers, body } = { uri: '', method: 'GET', headers: {}, ...source }; id += 1; @@ -110,6 +110,9 @@ const ImageLoader = { image.src = window.URL.createObjectURL(request.response); }; + // Track progress + request.onprogress = onProgress; + // Send the request request.send(body); 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; From 270c303f98e3f344b5360d6c1c5ed18360604830 Mon Sep 17 00:00:00 2001 From: jaulz Date: Fri, 8 Nov 2019 07:26:10 +0100 Subject: [PATCH 4/4] fix: return blob directly from ImageLoader --- .../src/exports/Image/__tests__/index-test.js | 17 ++++---------- .../src/exports/Image/index.js | 23 ++++--------------- .../src/modules/ImageLoader/index.js | 2 +- 3 files changed, 10 insertions(+), 32 deletions(-) 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 108ee9113..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 @@ -12,15 +12,6 @@ const OriginalImage = window.Image; const findImageSurfaceStyle = wrapper => StyleSheet.flatten(wrapper.childAt(0).prop('style')); -const createLoadEvent = uri => { - const target = new OriginalImage(); - target.src = uri; - const event = new window.Event('load'); - event.path = [target]; - - return event; -}; - describe('components/Image', () => { beforeEach(() => { ImageUriCache._entries = {}; @@ -106,7 +97,7 @@ describe('components/Image', () => { jest.useFakeTimers(); const uri = 'https://test.com/img.jpg'; ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(createLoadEvent(uri)); + onLoad(uri); }); const onLoadStub = jest.fn(); shallow(); @@ -119,7 +110,7 @@ describe('components/Image', () => { jest.useFakeTimers(); const uri = 'https://test.com/img.jpg'; ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(createLoadEvent(uri)); + onLoad(uri); }); const onLoadStub = jest.fn(); ImageUriCache.add(uri); @@ -174,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(createLoadEvent(uri)); + onLoad(uri); }); return Image.prefetch(uri).then(() => { const source = { uri }; @@ -232,7 +223,7 @@ describe('components/Image', () => { }); const component = shallow(); expect(component.find('img').prop('src')).toBe(defaultUri); - loadCallback(createLoadEvent(uri)); + 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 e8a8cbf3a..6e612fbe5 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -43,19 +43,6 @@ const resolveAssetDimensions = source => { }; }; -const getCacheUrl = e => { - if (e.target) { - return e.target.src; - } - - // Target is not defined at this moment anymore in Chrome and thus we use path - if (e.path && e.path[0]) { - return e.path[0].src; - } - - return undefined; -}; - let filterId = 0; const createTintColorSVG = (tintColor, id) => @@ -112,10 +99,10 @@ class Image extends Component<*, State> { } static prefetch(uri) { - return ImageLoader.prefetch(uri).then(e => { + 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, getCacheUrl(e)); + ImageUriCache.add(uri, displayImageUri); ImageUriCache.remove(uri); }); } @@ -160,7 +147,7 @@ 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 }); } } @@ -365,11 +352,11 @@ class Image extends Component<*, State> { this._onLoadEnd(); }; - _onLoad = e => { + _onLoad = (displayImageUri, e) => { const { onLoad, source } = this.props; const event = { nativeEvent: e }; - ImageUriCache.add(source, getCacheUrl(e)); + ImageUriCache.add(source, displayImageUri); this._updateImageState(STATUS_LOADED); if (onLoad) { onLoad(event); diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 77ee2872a..df78ce533 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -59,7 +59,7 @@ const ImageLoader = { 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.