diff --git a/packages/react-native-web-examples/pages/image/index.js b/packages/react-native-web-examples/pages/image/index.js index 5cc756bf4..7d95ae1b7 100644 --- a/packages/react-native-web-examples/pages/image/index.js +++ b/packages/react-native-web-examples/pages/image/index.js @@ -15,6 +15,18 @@ const dataBase64Svg = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjAwJyBoZWlnaHQ9JzIwMCcgZmlsbD0iIzAwMDAwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEwMCAxMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxnPjxwYXRoIGQ9Ik0yNS44NjcsNDguODUzQzMyLjgwNiw1MC4xNzYsNDYuNDYsNTIuNSw2MS4yMTUsNTIuNWgwLjAwNWM5LjcxLDAsMTguNDAxLTEuMDU3LDI1LjkzOC0yLjkxMyAgIGMwLjE1OS0wLjA0NiwwLjM1LTAuMTM1LDAuNTY1LTAuMTg3YzAuMjgyLTAuMDcyLDAuNTY1LTAuMTY0LDAuODQ0LTAuMjM4YzMuMTg0LTAuOTY0LDIuNTc3LTMuMDUxLDIuMTk5LTMuODUyICAgYy00LjE2Ni03LjcxOS0xNS4wODYtMjMuNDE1LTM1LjAyOC0yMy40MTVjLTIyLjE2OSwwLTMwLjI2MiwxMC42MzUtMzMuMTQsMTkuNTg5QzIyLjU0NSw0Mi4zMzMsMjIuNDA3LDQ3LjEzNSwyNS44NjcsNDguODUzeiAgICBNMjguNjc2LDM4LjAzMmMwLjAxMy0wLjAzNiwwLjYxNC0xLjYyNiwxLjkyMy0xLjAwOGMxLjEzMywwLjUzNSwwLjk2MSwxLjU2MywwLjg4NywxLjg1Yy0wLjAwNywwLjAyNC0wLjAxNCwwLjA0OC0wLjAyMSwwLjA3MyAgIGMwLDAuMDAxLTAuMDAxLDAuMDA0LTAuMDAxLDAuMDA0bDAsMGMtMC4yNDksMC45MjktMC40MDQsMi4wODYtMC4wMTcsMi44NmMwLjE2LDAuMzE5LDAuNDkyLDAuNzY4LDEuNTQyLDAuOTg3bDAuMzY2LDAuMDc3ICAgYzIwLjgxNiw0LjM2LDM2LDIuOTMzLDQ1LjY3OCwwLjYyNmwtMC4wMDQsMC4wMDJjMCwwLDAuMDA1LTAuMDAyLDAuMDA3LTAuMDAzYzAuMjEyLTAuMDUsMC40MjEtMC4xMDEsMC42MjgtMC4xNTIgICBjMC41MDktMC4wNSwxLjE3MywwLjA3OCwxLjM5OSwxYzAuMzUxLDEuNDI0LTAuOTczLDEuODk1LTEuMjE3LDEuOTY5Yy01LjMyNSwxLjI3OS0xMi4yNjYsMi4zMDYtMjAuODM1LDIuMzA3ICAgYy03LjUwNSwwLTE2LjI1NS0wLjc4Ny0yNi4yNTctMi44ODJsLTAuMzY0LTAuMDc3Yy0yLjEyLTAuNDQyLTMuMTExLTEuNjMzLTMuNTY5LTIuNTU1QzI3Ljk4NSw0MS40MjEsMjguMjgxLDM5LjQxNiwyOC42NzYsMzguMDMyICAgeiI+PC9wYXRoPjxjaXJjbGUgY3g9IjEwLjQ5MyIgY3k9IjIzLjQ1NSIgcj0iMC42MTkiPjwvY2lyY2xlPjxwYXRoIGQ9Ik0yLjA4LDI4LjMwOGMwLjY3Ni0wLjE3OCwwLjk4My0wLjM1MiwxLjE3NC0wLjVDNC42OSwyNi42OSw2LjUsMjcuNDgzLDcuNSwyOC4zNTd2MC4wMDJjMCwwLDEuNzExLDEuMjM1LDAuNzM3LDIuMjAyICAgYy0wLjk3NCwwLjk2NS0yLjMxOSwwLjAwNi0yLjMxOSwwLjAwNmwwLjAzNSwwLjAxNmMtMC4zMjctMC4yMDMtMC42LTAuNTYxLTAuNzgtMC41ODRjLTAuMzcsMC4yNi0wLjg3NiwwLjUtMS40NzYsMC41SDMuNyAgIGMwLDAtMS4zNDUsMC43MDksMC4xNzgsMS42NTJjMC4wMDEsMC4wMDEsMC4wMDIsMC4wNzIsMC4wMDQsMC4wNzNjMy45MzksMi4zNDIsOC4yNzEsNS43MDEsOC4yNzEsOC44OCAgIGMwLDAuNjkxLDAuMiwxNy4wNDIsMTcuNjI2LDI0LjczOWwwLjk2NywwLjQ0MmwtMC4xLDEuMDU5Yy0wLjQyMSw0LjM5LDEuMTQ1LDEwLjE5MSwxMC45OTMsMTIuODg4bDAuMTEzLDAuMDM4ICAgYzAuMDY3LDAuMDIzLDYuNzMyLDIuNDI5LDEwLjkwNywyLjQyOWMxLjU4NCwwLDIuMTU1LTAuMzUyLDIuMjQzLTAuNTYxYzAuMDg1LTAuMjAyLDAuNjEyLTIuMTY0LTYuMzMyLTkuMzg3bDAuMDAyLTAuMTgzICAgYzAsMC0yLjQ3Ny0zLjA3LDEuNTMzLTMuMDdjMC4wMSwwLDAuMDE5LDAsMC4wMjksMGMxLjI4NSwwLDIuNjA4LDAuMjE1LDMuOTgsMC4xODRjNC43NzEtMC4xMTcsOS4zMTYtMC40MjUsMTMuNTA2LTEuMDk2ICAgbDAuNDc0LTAuMDI4bDAuNjY4LDAuMTU4YzkuNjUxLDQuOTQ4LDE2LjczOCw3LjcxNiwxOS43MzgsNy43MTZ2MC4wMDZjMCwwLDAuMTY0LDAuMDExLDAuMjMsMC4wMDQgICBjLTAuMTg5LTAuNzIzLTIuMjMtMi44LTcuMjMtOS4wNzl2MC4wMjFjMCwwLTEuNTEyLTEuNjU4LDAuNzk3LTIuNjUzYzAuMDYzLTAuMDI2LDAuMDA4LDAuMDIzLDAuMDYtMC4wMDEgICBjOC42MzktMy41MDksMTMuNTAxLTguMjA0LDE1LjQxMS0xMS43NzVjMS4xNDUtMi4xMjksMC4yMDYtMi43ODQtMC42NTktMi45NzZjLTAuMzE3LTAuMDM4LTAuNjM0LTAuMDYyLTAuOTEyLTAuMDYyICAgYy0wLjIwNSwwLTAuMzc5LDAuMDEtMC41MjgsMC4wMjdsLTMuMTQzLDEuMjE0QzgzLjczMiw1My45MjYsNzMuMjE4LDU1LjUsNjEuMjIsNTUuNWMtMC4wMDIsMC0wLjAwNSwwLTAuMDA1LDAgICBjLTE1LjEyOCwwLTI5LjEwMS0yLjQzMi0zNi4wODMtMy43NzFsLTAuMTczLTAuMTExbC0wLjE2LTAuMTI2Yy01Ljg1OC0yLjY4MS01LjEzNy0xMC4yMDItNS4xMDMtMTAuNTE5bDAuMDYtMC4zICAgYzAuODk1LTIuODM4LDIuNDY3LTYuMzUyLDUuMjEzLTkuNzE5Yy0xLjgwOC0xLjM2OS00LjU5LTQuMTg4LTQuNDMtOC40OTRjMC4wNDYtMS4yNDQtMC40ODYtMi41MDgtMS40OTgtMy41NTkgICBjLTEuNDk4LTEuNTU1LTMuNzg1LTIuNDQ2LTYuMjc0LTIuNDQ2Yy0xLjc3LDAtMy41NTMsMC40NDItNS4yOTMsMS4zMTRjLTQuMDYxLDIuMDM1LTQuODU1LDQuNzM2LTUuNjkyLDcuNTk2ICAgYy0wLjEzNiwwLjQ2OC0wLjI4NCwwLjkzOS0wLjQzOCwxLjQxYy0wLjAwNiwwLjAxOS0wLjAyMiwwLjAzNS0wLjAyOCwwLjA1NkMwLjgzMywyOC40MjMsMS42OTEsMjguMzksMi4wOCwyOC4zMDh6IE0xMC40OTMsMTkuOTA4ICAgYzEuOTU2LDAsMy41NDgsMS41OTEsMy41NDgsMy41NDdjMCwxLjk1Ny0xLjU5MiwzLjU0OC0zLjU0OCwzLjU0OGMtMS45NTcsMC0zLjU0OC0xLjU5Mi0zLjU0OC0zLjU0OCAgIEM2Ljk0NCwyMS40OTksOC41MzYsMTkuOTA4LDEwLjQ5MywxOS45MDh6Ij48L3BhdGg+PC9nPjwvc3ZnPg=='; const dataSvg = 'data:image/svg+xml;utf8,'; +const sourceWithHeaders = { + uri: placeholder, + headers: { + 'x-token': '0012345' + } +}; +const sourceWithHeadersAndRedirect = { + uri: source, + headers: { + 'x-token': '0012345' + } +}; function Divider() { return ; @@ -114,6 +126,17 @@ export default function ImagePage() { /> + + + + With Headers + + + + Headers & Redirect + + + ); } diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index d68898dea..e8fe78c94 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -8,6 +8,7 @@ * @flow */ +import type { ImageSource, LoadRequest } from '../../modules/ImageLoader'; import type { ImageProps } from './types'; import * as React from 'react'; @@ -146,6 +147,23 @@ function resolveAssetUri(source): ?string { return uri; } +function raiseOnErrorEvent(uri, { onError, onLoadEnd }) { + if (onError) { + onError({ + nativeEvent: { + error: `Failed to load resource ${uri} (404)` + } + }); + } + if (onLoadEnd) onLoadEnd(); +} + +function hasSourceDiff(a: ImageSource, b: ImageSource) { + return ( + a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers) + ); +} + interface ImageStatics { getSize: ( uri: string, @@ -158,10 +176,12 @@ interface ImageStatics { ) => Promise<{| [uri: string]: 'disk/memory' |}>; } -const Image: React.AbstractComponent< +type ImageComponent = React.AbstractComponent< ImageProps, React.ElementRef -> = React.forwardRef((props, ref) => { +>; + +const BaseImage: ImageComponent = React.forwardRef((props, ref) => { const { accessibilityLabel, blurRadius, @@ -279,16 +299,7 @@ const Image: React.AbstractComponent< }, function error() { updateState(ERRORED); - if (onError) { - onError({ - nativeEvent: { - error: `Failed to load resource ${uri} (404)` - } - }); - } - if (onLoadEnd) { - onLoadEnd(); - } + raiseOnErrorEvent(uri, { onError, onLoadEnd }); } ); } @@ -332,14 +343,76 @@ const Image: React.AbstractComponent< ); }); -Image.displayName = 'Image'; +BaseImage.displayName = 'Image'; + +/** + * This component handles specifically loading an image source with headers + * default source is never loaded using headers + */ +const ImageWithHeaders: ImageComponent = React.forwardRef((props, ref) => { + // $FlowIgnore: This component would only be rendered when `source` matches `ImageSource` + const nextSource: ImageSource = props.source; + const [blobUri, setBlobUri] = React.useState(''); + const request = React.useRef({ + cancel: () => {}, + source: { uri: '', headers: {} }, + promise: Promise.resolve('') + }); + + const { onError, onLoadStart, onLoadEnd } = props; + + React.useEffect(() => { + if (!hasSourceDiff(nextSource, request.current.source)) { + return; + } + + // When source changes we want to clean up any old/running requests + request.current.cancel(); + + if (onLoadStart) { + onLoadStart(); + } + + // Store a ref for the current load request so we know what's the last loaded source, + // and so we can cancel it if a different source is passed through props + request.current = ImageLoader.loadWithHeaders(nextSource); + + request.current.promise + .then((uri) => setBlobUri(uri)) + .catch(() => + raiseOnErrorEvent(request.current.source.uri, { onError, onLoadEnd }) + ); + }, [nextSource, onLoadStart, onError, onLoadEnd]); + + // Cancel any request on unmount + React.useEffect(() => request.current.cancel, []); + + const propsToPass = { + ...props, + + // `onLoadStart` is called from the current component + // We skip passing it down to prevent BaseImage raising it a 2nd time + onLoadStart: undefined, + + // Until the current component resolves the request (using headers) + // we skip forwarding the source so the base component doesn't attempt + // to load the original source + source: blobUri ? { ...nextSource, uri: blobUri } : undefined + }; + + return ; +}); // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet -const ImageWithStatics = (Image: React.AbstractComponent< - ImageProps, - React.ElementRef -> & - ImageStatics); +const ImageWithStatics: ImageComponent & ImageStatics = React.forwardRef( + (props, ref) => { + if (props.source && props.source.headers) { + return ; + } + + return ; + } +); ImageWithStatics.getSize = function (uri, success, failure) { ImageLoader.getSize(uri, success, failure); diff --git a/packages/react-native-web/src/exports/Image/types.js b/packages/react-native-web/src/exports/Image/types.js index 55ad3cb9f..ab9fee2e6 100644 --- a/packages/react-native-web/src/exports/Image/types.js +++ b/packages/react-native-web/src/exports/Image/types.js @@ -102,8 +102,8 @@ export type ImageStyle = { tintColor?: ColorValue }; -export type ImageProps = { - ...ViewProps, +export type ImageProps = {| + ...$Exact, blurRadius?: number, defaultSource?: Source, draggable?: boolean, @@ -116,4 +116,4 @@ export type ImageProps = { resizeMode?: ResizeMode, source?: Source, style?: GenericStyleProp -}; +|}; diff --git a/packages/react-native-web/src/exports/ImageBackground/index.js b/packages/react-native-web/src/exports/ImageBackground/index.js index 561dd33d1..a86111839 100644 --- a/packages/react-native-web/src/exports/ImageBackground/index.js +++ b/packages/react-native-web/src/exports/ImageBackground/index.js @@ -16,12 +16,12 @@ import Image from '../Image'; import StyleSheet from '../StyleSheet'; import View from '../View'; -type ImageBackgroundProps = { +type ImageBackgroundProps = {| ...ImageProps, imageRef?: any, imageStyle?: $PropertyType, style?: $PropertyType -}; +|}; const emptyObject = {}; diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 892db9929..0d7ceda8f 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -122,9 +122,18 @@ const ImageLoader = { id += 1; const image = new window.Image(); image.onerror = onError; - image.onload = (e) => { + image.onload = (nativeEvent) => { // avoid blocking the main thread - const onDecode = () => onLoad({ nativeEvent: e }); + const onDecode = () => { + // Append `source` to match RN's ImageLoadEvent interface + nativeEvent.source = { + uri: image.src, + width: image.naturalWidth, + height: image.naturalHeight + }; + + onLoad({ nativeEvent }); + }; if (typeof image.decode === 'function') { // Safari currently throws exceptions when decoding svgs. // We want to catch that error and allow the load handler @@ -136,8 +145,41 @@ const ImageLoader = { }; image.src = uri; requests[`${id}`] = image; + return id; }, + loadWithHeaders(source: ImageSource): LoadRequest { + let uri: string; + const abortController = new AbortController(); + const request = new Request(source.uri, { + headers: source.headers, + signal: abortController.signal + }); + request.headers.append('accept', 'image/*'); + + const promise = fetch(request) + .then((response) => response.blob()) + .then((blob) => { + uri = URL.createObjectURL(blob); + return uri; + }) + .catch((error) => { + if (error.name === 'AbortError') { + return ''; + } + + throw error; + }); + + return { + promise, + source, + cancel: () => { + abortController.abort(); + URL.revokeObjectURL(uri); + } + }; + }, prefetch(uri: string): Promise { return new Promise((resolve, reject) => { ImageLoader.load( @@ -164,4 +206,15 @@ const ImageLoader = { } }; +export type LoadRequest = {| + cancel: Function, + source: ImageSource, + promise: Promise +|}; + +export type ImageSource = { + uri: string, + headers: { [key: string]: string } +}; + export default ImageLoader;