diff --git a/packages/react-native-web-examples/pages/image/index.js b/packages/react-native-web-examples/pages/image/index.js index 5cc756bf47..7d95ae1b76 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/__tests__/__snapshots__/index-test.js.snap b/packages/react-native-web/src/exports/Image/__tests__/__snapshots__/index-test.js.snap index 55e2d30ac5..b7314426d8 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 @@ -329,14 +329,14 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`] >
{ beforeEach(() => { ImageUriCache._entries = {}; window.Image = jest.fn(() => ({})); + ImageLoader.load = jest + .fn() + .mockImplementation((source, onLoad, onError) => { + act(() => onLoad({ source })); + }); + ImageLoader.loadWithHeaders = jest.fn().mockImplementation((source) => ({ + source, + promise: Promise.resolve(`blob:${Math.random()}`), + cancel: jest.fn() + })); }); afterEach(() => { @@ -102,10 +112,6 @@ describe('components/Image', () => { describe('prop "onLoad"', () => { test('is called after image is loaded from network', () => { - jest.useFakeTimers(); - ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(); - }); const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); const onLoadEndStub = jest.fn(); @@ -117,15 +123,10 @@ describe('components/Image', () => { source="https://test.com/img.jpg" /> ); - jest.runOnlyPendingTimers(); expect(onLoadStub).toBeCalled(); }); test('is called after image is loaded from cache', () => { - jest.useFakeTimers(); - ImageLoader.load = jest.fn().mockImplementation((_, onLoad, onError) => { - onLoad(); - }); const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); const onLoadEndStub = jest.fn(); @@ -139,7 +140,6 @@ describe('components/Image', () => { source={uri} /> ); - jest.runOnlyPendingTimers(); expect(onLoadStub).toBeCalled(); ImageUriCache.remove(uri); }); @@ -223,6 +223,34 @@ describe('components/Image', () => { }); }); + describe('prop "onLoadStart"', () => { + test('is called on update if "headers" are modified', () => { + const onLoadStartStub = jest.fn(); + const { rerender } = render( + + ); + act(() => { + rerender( + + ); + }); + + expect(onLoadStartStub.mock.calls.length).toBe(2); + }); + }); + describe('prop "resizeMode"', () => { ['contain', 'cover', 'none', 'repeat', 'stretch', undefined].forEach( (resizeMode) => { @@ -241,7 +269,8 @@ describe('components/Image', () => { '', {}, { uri: '' }, - { uri: 'https://google.com' } + { uri: 'https://google.com' }, + { uri: 'https://google.com', headers: { 'x-custom-header': 'abc123' } } ]; sources.forEach((source) => { expect(() => render()).not.toThrow(); @@ -257,11 +286,6 @@ 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(); - }); return Image.prefetch(uri).then(() => { const source = { uri }; const { container } = render(, { @@ -342,6 +366,51 @@ describe('components/Image', () => { 'http://localhost/static/img@2x.png' ); }); + + test('it works with headers in 2 stages', async () => { + const uri = 'https://google.com/favicon.ico'; + const headers = { 'x-custom-header': 'abc123' }; + const source = { uri, headers }; + + // Stage 1 + const loadRequest = { + promise: Promise.resolve('blob:123'), + cancel: jest.fn(), + source + }; + + ImageLoader.loadWithHeaders.mockReturnValue(loadRequest); + + render(); + + expect(ImageLoader.loadWithHeaders).toHaveBeenCalledWith( + expect.objectContaining(source) + ); + + // Stage 2 + return waitFor(() => { + expect(ImageLoader.load).toHaveBeenCalledWith( + 'blob:123', + expect.any(Function), + expect.any(Function) + ); + }); + }); + + // A common case is `source` declared as an inline object, which cause is to be a + // new object (with the same content) each time parent component renders + test('it still loads the image if source object is changed', () => { + const uri = 'https://google.com/favicon.ico'; + const headers = { 'x-custom-header': 'abc123' }; + const { rerender } = render(); + rerender(); + + // when the underlying source didn't change we don't expect more than 1 load calls + return waitFor(() => { + expect(ImageLoader.loadWithHeaders).toHaveBeenCalledTimes(1); + expect(ImageLoader.load).toHaveBeenCalledTimes(1); + }); + }); }); describe('prop "style"', () => { diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index bd69e5e844..8ec61ad83e 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'; @@ -165,6 +166,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, @@ -177,10 +195,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 { 'aria-label': ariaLabel, blurRadius, @@ -300,16 +320,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 }); } ); } @@ -353,14 +364,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 fe4984451f..bb856c6cba 100644 --- a/packages/react-native-web/src/exports/Image/types.js +++ b/packages/react-native-web/src/exports/Image/types.js @@ -103,8 +103,8 @@ export type ImageStyle = { tintColor?: ColorValue }; -export type ImageProps = { - ...ViewProps, +export type ImageProps = {| + ...$Exact, blurRadius?: number, defaultSource?: Source, draggable?: boolean, @@ -118,4 +118,4 @@ export type ImageProps = { source?: Source, style?: GenericStyleProp, tintColor?: ColorValue -}; +|}; diff --git a/packages/react-native-web/src/exports/ImageBackground/index.js b/packages/react-native-web/src/exports/ImageBackground/index.js index 561dd33d18..a861118399 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 892db99292..0d7ceda8ff 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;