[fix] Image loading for source={{ uri: '' }}

Avoid an error being thrown from attempting to call `match` on an object
value.

Fix #962
This commit is contained in:
Nicolas Gallagher
2018-05-22 15:13:57 -07:00
parent 48e62fcd64
commit 16b2fb9bd7
3 changed files with 117 additions and 30 deletions

View File

@@ -0,0 +1,86 @@
/**
* Copyright (c) 2015-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
* @format
*/
'use strict';
import PropTypes from 'prop-types';
const ImageURISourcePropType = PropTypes.shape({
/**
* `uri` is a string representing the resource identifier for the image, which
* could be an http address, a local file path, or the name of a static image
* resource (which should be wrapped in the `require('./path/to/image.png')`
* function).
*/
uri: PropTypes.string,
/**
* `bundle` is the iOS asset bundle which the image is included in. This
* will default to [NSBundle mainBundle] if not set.
* @platform ios
*/
bundle: PropTypes.string,
/**
* `method` is the HTTP Method to use. Defaults to GET if not specified.
*/
method: PropTypes.string,
/**
* `headers` is an object representing the HTTP headers to send along with the
* request for a remote image.
*/
headers: PropTypes.objectOf(PropTypes.string),
/**
* `body` is the HTTP body to send with the request. This must be a valid
* UTF-8 string, and will be sent exactly as specified, with no
* additional encoding (e.g. URL-escaping or base64) applied.
*/
body: PropTypes.string,
/**
* `cache` determines how the requests handles potentially cached
* responses.
*
* - `default`: Use the native platforms default strategy. `useProtocolCachePolicy` on iOS.
*
* - `reload`: The data for the URL will be loaded from the originating source.
* No existing cache data should be used to satisfy a URL load request.
*
* - `force-cache`: The existing cached data will be used to satisfy the request,
* regardless of its age or expiration date. If there is no existing data in the cache
* corresponding the request, the data is loaded from the originating source.
*
* - `only-if-cached`: The existing cache data will be used to satisfy a request, regardless of
* its age or expiration date. If there is no existing data in the cache corresponding
* to a URL load request, no attempt is made to load the data from the originating source,
* and the load is considered to have failed.
*
* @platform ios
*/
cache: PropTypes.oneOf(['default', 'reload', 'force-cache', 'only-if-cached']),
/**
* `width` and `height` can be specified if known at build time, in which case
* these will be used to set the default `<Image/>` component dimensions.
*/
width: PropTypes.number,
height: PropTypes.number,
/**
* `scale` is used to indicate the scale factor of the image. Defaults to 1.0 if
* unspecified, meaning that one image pixel equates to one display point / DIP.
*/
scale: PropTypes.number
});
const ImageSourcePropType = PropTypes.oneOfType([
ImageURISourcePropType,
// Opaque type returned by require('./image.jpg')
PropTypes.number,
PropTypes.string,
// Multiple sources
PropTypes.arrayOf(ImageURISourcePropType)
]);
export default ImageSourcePropType;

View File

@@ -147,6 +147,13 @@ describe('components/Image', () => {
}); });
describe('prop "source"', () => { describe('prop "source"', () => {
test('does not throw', () => {
const sources = [null, '', {}, { uri: '' }, { uri: 'https://google.com' }];
sources.forEach(source => {
expect(() => shallow(<Image source={source} />)).not.toThrow();
});
});
test('is not set immediately if the image has not already been loaded', () => { test('is not set immediately if the image has not already been loaded', () => {
const uri = 'https://google.com/favicon.ico'; const uri = 'https://google.com/favicon.ico';
const source = { uri }; const source = { uri };

View File

@@ -13,6 +13,7 @@ import createElement from '../createElement';
import { getAssetByID } from '../../modules/AssetRegistry'; import { getAssetByID } from '../../modules/AssetRegistry';
import ImageLoader from '../../modules/ImageLoader'; import ImageLoader from '../../modules/ImageLoader';
import ImageResizeMode from './ImageResizeMode'; import ImageResizeMode from './ImageResizeMode';
import ImageSourcePropType from './ImageSourcePropType';
import ImageStylePropTypes from './ImageStylePropTypes'; import ImageStylePropTypes from './ImageStylePropTypes';
import ImageUriCache from './ImageUriCache'; import ImageUriCache from './ImageUriCache';
import requestIdleCallback, { cancelIdleCallback } from '../../modules/requestIdleCallback'; import requestIdleCallback, { cancelIdleCallback } from '../../modules/requestIdleCallback';
@@ -20,7 +21,7 @@ import StyleSheet from '../StyleSheet';
import StyleSheetPropType from '../../modules/StyleSheetPropType'; import StyleSheetPropType from '../../modules/StyleSheetPropType';
import View from '../View'; import View from '../View';
import ViewPropTypes from '../ViewPropTypes'; import ViewPropTypes from '../ViewPropTypes';
import { bool, func, number, oneOf, oneOfType, shape, string } from 'prop-types'; import { bool, func, number, oneOf, shape } from 'prop-types';
import React, { Component } from 'react'; import React, { Component } from 'react';
const emptyObject = {}; const emptyObject = {};
@@ -31,16 +32,6 @@ const STATUS_LOADING = 'LOADING';
const STATUS_PENDING = 'PENDING'; const STATUS_PENDING = 'PENDING';
const STATUS_IDLE = 'IDLE'; const STATUS_IDLE = 'IDLE';
const ImageSourcePropType = oneOfType([
number,
shape({
height: number,
uri: string.isRequired,
width: number
}),
string
]);
const getImageState = (uri, shouldDisplaySource) => { const getImageState = (uri, shouldDisplaySource) => {
return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE; return shouldDisplaySource ? STATUS_LOADED : uri ? STATUS_PENDING : STATUS_IDLE;
}; };
@@ -56,26 +47,28 @@ const resolveAssetDimensions = source => {
}; };
const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/; const svgDataUriPattern = /^(data:image\/svg\+xml;utf8,)(.*)/;
const resolveAssetSource = source => { const resolveAssetUri = source => {
let uri; let uri = '';
if (typeof source === 'number') { if (typeof source === 'number') {
// get the URI from the packager // get the URI from the packager
const asset = getAssetByID(source); const asset = getAssetByID(source);
const scale = asset.scales[0]; const scale = asset.scales[0];
const scaleSuffix = scale !== 1 ? `@${scale}x` : ''; const scaleSuffix = scale !== 1 ? `@${scale}x` : '';
uri = asset ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` : ''; uri = asset ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` : '';
} else if (source && source.uri) { } else if (typeof source === 'string') {
uri = source;
} else if (source && typeof source.uri === 'string') {
uri = source.uri; uri = source.uri;
} else {
uri = source || '';
} }
const match = uri.match(svgDataUriPattern); if (uri) {
// inline SVG markup may contain characters (e.g., #, ") that need to be escaped const match = uri.match(svgDataUriPattern);
if (match) { // inline SVG markup may contain characters (e.g., #, ") that need to be escaped
const [, prefix, svg] = match; if (match) {
const encodedSvg = encodeURIComponent(svg); const [, prefix, svg] = match;
return `${prefix}${encodedSvg}`; const encodedSvg = encodeURIComponent(svg);
return `${prefix}${encodedSvg}`;
}
} }
return uri; return uri;
@@ -137,7 +130,7 @@ class Image extends Component<*, State> {
constructor(props, context) { constructor(props, context) {
super(props, context); super(props, context);
// If an image has been loaded before, render it immediately // If an image has been loaded before, render it immediately
const uri = resolveAssetSource(props.source); const uri = resolveAssetUri(props.source);
const shouldDisplaySource = ImageUriCache.has(uri); const shouldDisplaySource = ImageUriCache.has(uri);
this.state = { shouldDisplaySource }; this.state = { shouldDisplaySource };
this._imageState = getImageState(uri, shouldDisplaySource); this._imageState = getImageState(uri, shouldDisplaySource);
@@ -161,8 +154,8 @@ class Image extends Component<*, State> {
} }
componentWillReceiveProps(nextProps) { componentWillReceiveProps(nextProps) {
const uri = resolveAssetSource(this.props.source); const uri = resolveAssetUri(this.props.source);
const nextUri = resolveAssetSource(nextProps.source); const nextUri = resolveAssetUri(nextProps.source);
if (uri !== nextUri) { if (uri !== nextUri) {
ImageUriCache.remove(uri); ImageUriCache.remove(uri);
const isPreviouslyLoaded = ImageUriCache.has(nextUri); const isPreviouslyLoaded = ImageUriCache.has(nextUri);
@@ -172,7 +165,8 @@ class Image extends Component<*, State> {
} }
componentWillUnmount() { componentWillUnmount() {
ImageUriCache.remove(resolveAssetSource(this.props.source)); const uri = resolveAssetUri(this.props.source);
ImageUriCache.remove(uri);
this._destroyImageLoader(); this._destroyImageLoader();
this._isMounted = false; this._isMounted = false;
} }
@@ -200,7 +194,7 @@ class Image extends Component<*, State> {
...other ...other
} = this.props; } = this.props;
const displayImage = resolveAssetSource(shouldDisplaySource ? source : defaultSource); const displayImage = resolveAssetUri(shouldDisplaySource ? source : defaultSource);
const imageSizeStyle = resolveAssetDimensions(shouldDisplaySource ? source : defaultSource); const imageSizeStyle = resolveAssetDimensions(shouldDisplaySource ? source : defaultSource);
const backgroundImage = displayImage ? `url("${displayImage}")` : null; const backgroundImage = displayImage ? `url("${displayImage}")` : null;
const originalStyle = StyleSheet.flatten(this.props.style); const originalStyle = StyleSheet.flatten(this.props.style);
@@ -260,7 +254,7 @@ class Image extends Component<*, State> {
this._destroyImageLoader(); this._destroyImageLoader();
this._loadRequest = requestIdleCallback( this._loadRequest = requestIdleCallback(
() => { () => {
const uri = resolveAssetSource(source); const uri = resolveAssetUri(source);
this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError); this._imageRequestId = ImageLoader.load(uri, this._onLoad, this._onError);
this._onLoadStart(); this._onLoadStart();
}, },
@@ -286,7 +280,7 @@ class Image extends Component<*, State> {
if (onError) { if (onError) {
onError({ onError({
nativeEvent: { nativeEvent: {
error: `Failed to load resource ${resolveAssetSource(source)} (404)` error: `Failed to load resource ${resolveAssetUri(source)} (404)`
} }
}); });
} }
@@ -296,7 +290,7 @@ class Image extends Component<*, State> {
_onLoad = e => { _onLoad = e => {
const { onLoad, source } = this.props; const { onLoad, source } = this.props;
const event = { nativeEvent: e }; const event = { nativeEvent: e };
ImageUriCache.add(resolveAssetSource(source)); ImageUriCache.add(resolveAssetUri(source));
this._updateImageState(STATUS_LOADED); this._updateImageState(STATUS_LOADED);
if (onLoad) { if (onLoad) {
onLoad(event); onLoad(event);