From 39d7cd6e8e921a331781f89a2955ef830ac3f763 Mon Sep 17 00:00:00 2001 From: rick-nice Date: Thu, 10 Dec 2020 17:15:02 +0300 Subject: [PATCH 1/2] added support for image source object --- .../src/components/Image/Image.stories.mdx | 13 +- .../components/Image/examples/NetworkImage.js | 95 ++++++--- .../components/Image/examples/OnProgress.js | 8 + .../src/components/Image/examples/Source.js | 10 + .../src/components/Image/examples/index.js | 1 + .../src/components/Image/sources/index.js | 10 +- .../__snapshots__/index-test.js.snap | 8 +- .../src/exports/Image/__tests__/index-test.js | 17 +- .../src/exports/Image/index.js | 93 +++------ .../src/exports/Image/types.js | 6 +- .../src/modules/ImageLoader/index.js | 186 +++++++++++++++--- 11 files changed, 316 insertions(+), 131 deletions(-) create mode 100644 packages/docs/src/components/Image/examples/OnProgress.js diff --git a/packages/docs/src/components/Image/Image.stories.mdx b/packages/docs/src/components/Image/Image.stories.mdx index 63ab1a113..49acbdb55 100644 --- a/packages/docs/src/components/Image/Image.stories.mdx +++ b/packages/docs/src/components/Image/Image.stories.mdx @@ -19,6 +19,7 @@ An accessible and responsive image component. | onLoad | ?Function | false | | onLoadEnd | ?Function | false | | onLoadStart | ?Function | false | +| onProgress | ?Function | false | | resizeMode | ?ResizeMode | 'cover' | | source | Source | false | | style | ?Style | | @@ -72,6 +73,16 @@ Called when load completes successfully. +### onProgress + +Invoked on download progress. + + + + + + + ### resizeMode Determines how to resize the image when the frame doesn't match the raw image dimensions. @@ -91,7 +102,7 @@ type ResizeMode = 'center' | 'contain' | 'cover' | 'none' | 'repeat' | 'stretch' The source `uri` is a string representing the resource identifier for the image, which could be an http address or a base64 encoded image. ```js -type Source = { uri: string, width: number, height: number } +type Source = { uri: string, width: number, height: number, method?: string, headers?: object } ``` diff --git a/packages/docs/src/components/Image/examples/NetworkImage.js b/packages/docs/src/components/Image/examples/NetworkImage.js index 07a8dd7fa..4ade8c76b 100644 --- a/packages/docs/src/components/Image/examples/NetworkImage.js +++ b/packages/docs/src/components/Image/examples/NetworkImage.js @@ -6,7 +6,8 @@ import { ActivityIndicator, Image, Text, View } from 'react-native'; class NetworkImageExample extends PureComponent { state = { error: false, - loading: false + loading: false, + messages: [] }; static defaultProps = { @@ -29,44 +30,94 @@ class NetworkImageExample extends PureComponent { onLoad={this._handleLoad} onLoadEnd={this._handleLoadEnd} onLoadStart={this._handleLoadStart} + onProgress={this._handleProgress} source={this.props.source} style={helpers.styles.base} /> - {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/docs/src/components/Image/examples/OnProgress.js b/packages/docs/src/components/Image/examples/OnProgress.js new file mode 100644 index 000000000..5aee630c4 --- /dev/null +++ b/packages/docs/src/components/Image/examples/OnProgress.js @@ -0,0 +1,8 @@ +import { createUncachedURI } from '../helpers'; +import NetworkImage from './NetworkImage'; +import React from 'react'; +import sources from '../sources'; + +export default function OnProgress() { + return ; +} diff --git a/packages/docs/src/components/Image/examples/Source.js b/packages/docs/src/components/Image/examples/Source.js index e2aafcdd8..18c8f3f36 100644 --- a/packages/docs/src/components/Image/examples/Source.js +++ b/packages/docs/src/components/Image/examples/Source.js @@ -33,6 +33,16 @@ export default function Source() { + + + WebP + + + + Dynamic (POST) + + + ); } diff --git a/packages/docs/src/components/Image/examples/index.js b/packages/docs/src/components/Image/examples/index.js index cb632f096..b7c2ba39b 100644 --- a/packages/docs/src/components/Image/examples/index.js +++ b/packages/docs/src/components/Image/examples/index.js @@ -4,6 +4,7 @@ export { default as onError } from './OnError'; export { default as onLoad } from './OnLoad'; export { default as onLoadEnd } from './OnLoadEnd'; export { default as onLoadStart } from './OnLoadStart'; +export { default as onProgress } from './OnProgress'; export { default as resizeMode } from './ResizeMode'; export { default as source } from './Source'; export { default as styleBoxShadow } from './StyleBoxShadow'; diff --git a/packages/docs/src/components/Image/sources/index.js b/packages/docs/src/components/Image/sources/index.js index 189575e29..3d889c0a5 100644 --- a/packages/docs/src/components/Image/sources/index.js +++ b/packages/docs/src/components/Image/sources/index.js @@ -42,7 +42,15 @@ 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' + } }; export default sources; 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 fd8dddd8c..80b64bbc3 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 @@ -328,14 +328,14 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`] >
{ beforeEach(() => { @@ -18,7 +18,7 @@ describe('components/Image', () => { }); afterEach(() => { - window.Image = originalImage; + window.Image = OriginalImage; }); test('prop "accessibilityLabel"', () => { @@ -91,8 +91,9 @@ 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 onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); @@ -102,7 +103,7 @@ describe('components/Image', () => { onLoad={onLoadStub} onLoadEnd={onLoadEndStub} onLoadStart={onLoadStartStub} - source="https://test.com/img.jpg" + source={uri} /> ); jest.runOnlyPendingTimers(); @@ -111,13 +112,13 @@ 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 onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); const onLoadEndStub = jest.fn(); - const uri = 'https://test.com/img.jpg'; ImageUriCache.add(uri); render( { 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 }; @@ -285,7 +286,7 @@ describe('components/Image', () => { const { container } = render(); expect(container.firstChild).toMatchSnapshot(); act(() => { - loadCallback(); + loadCallback(uri); }); expect(container.firstChild).toMatchSnapshot(); }); diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 303174c07..4725977d6 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -12,10 +12,8 @@ import type { ImageProps } from './types'; 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 PixelRatio from '../PixelRatio'; +import ImageLoader, { ImageUriCache } from '../../modules/ImageLoader'; import StyleSheet from '../StyleSheet'; import TextAncestorContext from '../Text/TextAncestorContext'; import View from '../View'; @@ -29,7 +27,6 @@ const LOADING = 'LOADING'; const IDLE = 'IDLE'; let _filterId = 0; -const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; function createTintColorSVG(tintColor, id) { return tintColor && id != null ? ( @@ -88,50 +85,6 @@ function getFlatStyle(style, blurRadius, filterId) { return [flatStyle, resizeMode, _filter, tintColor]; } -function resolveAssetDimensions(source) { - if (typeof source === 'number') { - const { height, width } = getAssetByID(source); - return { height, width }; - } else if (source != null && !Array.isArray(source) && typeof source === 'object') { - const { height, width } = source; - return { height, width }; - } -} - -function resolveAssetUri(source): ?string { - let uri = null; - if (typeof source === 'number') { - // get the URI from the packager - const asset = getAssetByID(source); - let scale = asset.scales[0]; - if (asset.scales.length > 1) { - const preferredScale = PixelRatio.get(); - // Get the scale which is closest to the preferred scale - scale = asset.scales.reduce((prev, curr) => - Math.abs(curr - preferredScale) < Math.abs(prev - preferredScale) ? curr : prev - ); - } - 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; -} - const Image = forwardRef((props, ref) => { const { accessibilityLabel, @@ -143,6 +96,7 @@ const Image = forwardRef((props, ref) => { onLoad, onLoadEnd, onLoadStart, + onProgress, pointerEvents, source, style, @@ -156,18 +110,20 @@ const Image = forwardRef((props, ref) => { ); } } - + const cachedSource = ImageUriCache.get(source); + const resolvedSource = ImageLoader.resolveSource(source); + const resolvedDefaultSource = ImageLoader.resolveSource(defaultSource); const [state, updateState] = useState(() => { - const uri = resolveAssetUri(source); - if (uri != null) { - const isLoaded = ImageLoader.has(uri); + if (source != null) { + const isLoaded = ImageUriCache.has(source); + if (isLoaded) { return LOADED; } } + return IDLE; }); - const [layout, updateLayout] = useState({}); const hasTextAncestor = useContext(TextAncestorContext); const hiddenImageRef = useRef(null); @@ -180,13 +136,16 @@ const Image = forwardRef((props, ref) => { filterRef.current ); const resizeMode = props.resizeMode || _resizeMode || 'cover'; - const selectedSource = shouldDisplaySource ? source : defaultSource; - const displayImageUri = resolveAssetUri(selectedSource); - const imageSizeStyle = resolveAssetDimensions(selectedSource); + const selectedSource = shouldDisplaySource ? resolvedSource : resolvedDefaultSource; + const displayImageUri = (cachedSource && cachedSource.displayImageUri) || selectedSource.uri; + const imageSizeStyle = { + height: selectedSource.height, + width: selectedSource.width + }; + const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; const backgroundSize = getBackgroundSize(); - // Accessibility image allows users to trigger the browser's image context menu const hiddenImage = displayImageUri ? createElement('img', { alt: accessibilityLabel || '', @@ -219,19 +178,21 @@ const Image = forwardRef((props, ref) => { } // Image loading - const uri = resolveAssetUri(source); + const { uri } = resolvedSource; useEffect(() => { abortPendingRequest(); - if (uri != null) { + if (uri) { updateState(LOADING); if (onLoadStart) { onLoadStart(); } requestRef.current = ImageLoader.load( - uri, - function load(e) { + source, + function load(e, imageUri) { + imageUri && ImageUriCache.add(source, imageUri); + updateState(LOADED); if (onLoad) { onLoad(e); @@ -252,6 +213,13 @@ const Image = forwardRef((props, ref) => { if (onLoadEnd) { onLoadEnd(); } + }, + function progress(event) { + if (onProgress) { + onProgress({ + nativeEvent: event + }); + } } ); } @@ -264,7 +232,8 @@ const Image = forwardRef((props, ref) => { } return abortPendingRequest; - }, [uri, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [uri, onError, onLoad, onProgress, onLoadEnd, onLoadStart]); return ( ` component dimensions. */ - height?: number, - width?: number + height?: ?number, + width?: ?number }; export type ResizeMode = 'center' | 'contain' | 'cover' | 'none' | 'repeat' | 'stretch'; diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 85723fae5..10b680a94 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -7,36 +7,57 @@ * @flow */ +import { getAssetByID } from '../AssetRegistry'; +import type { Source } from '../../exports/Image/types'; +import PixelRatio from '../../exports/PixelRatio'; const dataUriPattern = /^data:/; +const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; export class ImageUriCache { static _maximumEntries: number = 256; static _entries = {}; - static has(uri: string) { + static createCacheId(source: Source): string { + return JSON.stringify(ImageLoader.resolveSource(source)); + } + + static has(source: Source): boolean { const entries = ImageUriCache._entries; - const isDataUri = dataUriPattern.test(uri); - return isDataUri || Boolean(entries[uri]); + const cacheId = ImageUriCache.createCacheId(source); + + return Boolean(entries[cacheId]); } - static add(uri: string) { + static get(source: Source): Object { + const entries = ImageUriCache._entries; + const cacheId = ImageUriCache.createCacheId(source); + + return entries[cacheId]; + } + + static add(source: Source, displayImageUri?: string): void { 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: Source) { 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 +65,21 @@ export 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; } }); @@ -82,15 +104,17 @@ const ImageLoader = { delete requests[`${requestId}`]; } }, - getSize(uri: string, success: Function, failure: Function) { + getSize(source: Source, success: Function, failure: Function) { let complete = false; const interval = setInterval(callback, 16); - const requestId = ImageLoader.load(uri, callback, errorCallback); + const requestId = ImageLoader.load(source, callback, errorCallback); function callback() { const image = requests[`${requestId}`]; + if (image) { const { naturalHeight, naturalWidth } = image; + if (naturalHeight && naturalWidth) { success(naturalWidth, naturalHeight); complete = true; @@ -110,16 +134,16 @@ const ImageLoader = { clearInterval(interval); } }, - has(uri: string) { - return ImageUriCache.has(uri); - }, - load(uri: string, onLoad: Function, onError: Function): number { - id += 1; + load(source: Source, onLoad: Function, onError: Function, onProgress: Function): number { + const { uri, method, headers, body } = ImageLoader.resolveSource(source); const image = new window.Image(); + let shouldCache = false; + id += 1; image.onerror = onError; image.onload = e => { // avoid blocking the main thread - const onDecode = () => onLoad({ nativeEvent: e }); + + const onDecode = () => onLoad({ nativeEvent: e }, shouldCache ? image.src : null); if (typeof image.decode === 'function') { // Safari currently throws exceptions when decoding svgs. // We want to catch that error and allow the load handler @@ -129,25 +153,127 @@ 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' && !Object.keys(headers).length) { + 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 + // eslint-disable-next-line no-restricted-syntax + 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 = () => { + shouldCache = true; + image.src = window.URL.createObjectURL(request.response); + }; + + // Track progress + request.onprogress = onProgress; + + // Send the request + request.send(body); + return id; }, - prefetch(uri: string): Promise<*> { + prefetch(source: Source): Promise { return new Promise((resolve, reject) => { + const resolvedSource = ImageLoader.resolveSource(source); ImageLoader.load( - uri, + resolvedSource, () => { // 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); + ImageUriCache.add(resolvedSource); + ImageUriCache.remove(resolvedSource); resolve(); }, reject ); }); }, + resolveSource(source?: Source): Object { + let resolvedSource = { + method: 'GET', + uri: '', + headers: {}, + width: undefined, + height: undefined + }; + + if (typeof source === 'number') { + // get the URI from the packager + const asset = getAssetByID(source); + let scale = asset.scales[0]; + if (asset.scales.length > 1) { + const preferredScale = PixelRatio.get(); + // Get the scale which is closest to the preferred scale + scale = asset.scales.reduce((prev, curr) => + Math.abs(curr - preferredScale) < Math.abs(prev - preferredScale) ? curr : prev + ); + } + const scaleSuffix = scale !== 1 ? `@${scale}x` : ''; + resolvedSource.uri = asset + ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` + : ''; + } else if (typeof source === 'string') { + resolvedSource.uri = source; + } else if (Array.isArray(source)) { + resolvedSource = { + ...resolvedSource, + ...source[0] + }; + } 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; + }, queryCache(uris: Array): Object { const result = {}; uris.forEach(u => { From 1960b49d6e8b154e97e1f95c0b78cdfbd563d623 Mon Sep 17 00:00:00 2001 From: rick-nice Date: Tue, 22 Dec 2020 15:22:08 +0300 Subject: [PATCH 2/2] fixed Image useEffect deps --- packages/react-native-web/src/exports/Image/index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 4725977d6..4b6dec9b2 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -233,7 +233,7 @@ const Image = forwardRef((props, ref) => { return abortPendingRequest; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [uri, onError, onLoad, onProgress, onLoadEnd, onLoadStart]); + }, [uri]); return (