diff --git a/packages/react-native-web-examples/pages/image/index.js b/packages/react-native-web-examples/pages/image/index.js index 5cc756bf4..f0791d98a 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 ; @@ -31,8 +43,8 @@ export default function ImagePage() { onError={() => { console.log('error'); }} - onLoad={() => { - console.log('load'); + onLoad={(result) => { + console.log('load', result); }} onLoadEnd={() => { console.log('load-end'); @@ -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 04b41e3ef..7ca0ca2b9 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 @@ -56,7 +56,7 @@ exports[`components/Image prop "defaultSource" does not override "height" and "w exports[`components/Image prop "defaultSource" sets "height" and "width" styles if missing 1`] = `
{ beforeEach(() => { ImageUriCache._entries = {}; window.Image = jest.fn(() => ({})); + ImageLoader.load = jest + .fn() + .mockImplementation((source, onLoad, onError) => { + onLoad({ source }); + }); }); afterEach(() => { @@ -107,9 +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(); @@ -127,9 +129,6 @@ describe('components/Image', () => { 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(); @@ -174,6 +173,35 @@ describe('components/Image', () => { expect(onLoadEndStub.mock.calls.length).toBe(2); }); + test('is called on update if "headers" are modified', () => { + const onLoadStub = jest.fn(); + const onLoadEndStub = jest.fn(); + const { rerender } = render( + + ); + act(() => { + rerender( + + ); + }); + expect(onLoadStub.mock.calls.length).toBe(2); + expect(onLoadEndStub.mock.calls.length).toBe(2); + }); + test('is not called on update if "uri" is the same', () => { const onLoadStartStub = jest.fn(); const onLoadStub = jest.fn(); @@ -225,6 +253,64 @@ describe('components/Image', () => { expect(onLoadStub.mock.calls.length).toBe(1); expect(onLoadEndStub.mock.calls.length).toBe(1); }); + + // This test verifies that when source is declared in-line and the parent component + // re-renders we aren't restarting the load process because the source is structurally equal + test('is not called on update when "headers" and "uri" are not modified', () => { + const onLoadStub = jest.fn(); + const onLoadEndStub = jest.fn(); + const { rerender } = render( + + ); + act(() => { + rerender( + + ); + }); + expect(onLoadStub.mock.calls.length).toBe(1); + expect(onLoadEndStub.mock.calls.length).toBe(1); + }); + + test('is not called for default source', () => { + jest.useFakeTimers(); + const onLoadStub = jest.fn(); + render( + + ); + jest.runOnlyPendingTimers(); + expect(onLoadStub).toHaveBeenCalledTimes(1); + expect(onLoadStub).toHaveBeenCalledWith( + expect.objectContaining({ + nativeEvent: { + source: { + uri: 'https://test.com/img.jpg' + } + } + }) + ); + }); }); describe('prop "resizeMode"', () => { @@ -245,7 +331,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(); @@ -261,11 +348,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(, { @@ -308,19 +390,33 @@ describe('components/Image', () => { test('is correctly updated only when loaded if defaultSource provided', () => { const defaultUri = 'https://testing.com/preview.jpg'; const uri = 'https://testing.com/fullSize.jpg'; - let loadCallback; - ImageLoader.load = jest - .fn() - .mockImplementationOnce((_, onLoad, onError) => { - loadCallback = onLoad; - }); + const calls = []; + + // Capture calls and resolve them after render + ImageLoader.load = jest.fn().mockImplementation((source, onLoad) => { + calls.push({ source, onLoad }); + }); + const { container } = render( ); + + // Both defaultSource and source are loaded at the same time + // Since `defaultSource` is meant to be displayed until `source` loads + // we resolve it first (otherwise it won't be displayed at all) + act(() => { + const call = calls.find(({ source }) => source.uri === defaultUri); + call.onLoad({ source: call.source }); + }); + expect(container.firstChild).toMatchSnapshot(); + + // After a while the main source loads as well act(() => { - loadCallback(); + const call = calls.find(({ source }) => source.uri === uri); + call.onLoad({ source: call.source }); }); + expect(container.firstChild).toMatchSnapshot(); }); @@ -346,6 +442,67 @@ describe('components/Image', () => { 'http://localhost/static/img@2x.png' ); }); + + test('it correctly passes headers to ImageLoader', () => { + const uri = 'https://google.com/favicon.ico'; + const headers = { 'x-custom-header': 'abc123' }; + const source = { uri, headers }; + render(); + + expect(ImageLoader.load).toHaveBeenCalledWith( + expect.objectContaining({ headers }), + expect.any(Function), + expect.any(Function) + ); + }); + + test('it correctly passes uri to ImageLoader', () => { + const uri = 'https://google.com/favicon.ico'; + const source = { uri }; + render(); + + expect(ImageLoader.load).toHaveBeenCalledWith( + expect.objectContaining({ uri }), + expect.any(Function), + expect.any(Function) + ); + }); + + // A common case is `source` declared as an inline object, which creates a + // new object (with the same content) each time parent component renders + test('it still loads the image if source object is changed', () => { + ImageLoader.load.mockImplementation(() => {}); + + const releaseSpy = jest.spyOn(ImageLoader, 'abort'); + + const uri = 'https://google.com/favicon.ico'; + const { rerender } = render(); + rerender(); + + // when the underlying source didn't change we expect the initial request is not cancelled due to re-render + expect(releaseSpy).not.toHaveBeenCalled(); + }); + + test('falls back to default source when source or source.uri is removed', () => { + const source = { uri: 'https://google.com/favicon.ico' }; + const defaultSource = { uri: 'http://localhost/static/img@2x.png' }; + + const { container, rerender } = render( + + ); + + rerender(); + expect(container.querySelector('img').src).toBe(defaultSource.uri); + }); + + test('removes image if source or source.uri is removed and there is no default source', () => { + const source = { uri: 'https://google.com/favicon.ico' }; + + const { container, rerender } = render(); + + rerender(); + expect(container.querySelector('img')).toBe(null); + }); }); 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 d68898dea..19081654c 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -8,7 +8,8 @@ * @flow */ -import type { ImageProps } from './types'; +import type { ImageSource } from '../../modules/ImageLoader'; +import type { ImageProps, ImageLoadingProps, Source } from './types'; import * as React from 'react'; import createElement from '../createElement'; @@ -94,25 +95,17 @@ 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 resolveSource(source: ?Source): ImageSource { + let resolvedSource = { uri: '' }; -function resolveAssetUri(source): ?string { - let uri = null; if (typeof source === 'number') { // get the URI from the packager const asset = getAssetByID(source); + if (asset == null) { + throw new Error( + `Image: asset with ID "${source}" could not be found. Please check the image source or packager.` + ); + } let scale = asset.scales[0]; if (asset.scales.length > 1) { const preferredScale = PixelRatio.get(); @@ -123,27 +116,29 @@ function resolveAssetUri(source): ?string { : prev ); } + const scaleSuffix = scale !== 1 ? `@${scale}x` : ''; - uri = asset - ? `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}` - : ''; + const uri = `${asset.httpServerLocation}/${asset.name}${scaleSuffix}.${asset.type}`; + resolvedSource = { uri, width: asset.width, height: asset.height }; } else if (typeof source === 'string') { - uri = source; + resolvedSource.uri = source; } else if (source && typeof source.uri === 'string') { - uri = source.uri; + // $FlowFixMe + const { uri, width, height, headers } = source; + resolvedSource = { uri, width, height, headers }; } - 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; } interface ImageStatics { @@ -186,33 +181,29 @@ const Image: React.AbstractComponent< } } - const [state, updateState] = React.useState(() => { - const uri = resolveAssetUri(source); - if (uri != null) { - const isLoaded = ImageLoader.has(uri); - if (isLoaded) { - return LOADED; - } - } - return IDLE; - }); - + // Only the main source is supposed to trigger onLoad/start/end events + // It would be ambiguous to trigger the same `onLoad` event when default source loads + // That's why we don't pass `onLoad` props for the fallback source hook + const fallbackSource = useSource({ onError }, defaultSource); + const mainSource = useSource( + { onLoad, onLoadStart, onLoadEnd, onError }, + source + ); const [layout, updateLayout] = React.useState({}); const hasTextAncestor = React.useContext(TextAncestorContext); const hiddenImageRef = React.useRef(null); const filterRef = React.useRef(_filterId++); - const requestRef = React.useRef(null); const shouldDisplaySource = - state === LOADED || (state === LOADING && defaultSource == null); + mainSource.status === LOADED || + (mainSource.status === LOADING && defaultSource == null); const [flatStyle, _resizeMode, filter, tintColor] = getFlatStyle( style, blurRadius, filterRef.current ); const resizeMode = props.resizeMode || _resizeMode || 'cover'; - const selectedSource = shouldDisplaySource ? source : defaultSource; - const displayImageUri = resolveAssetUri(selectedSource); - const imageSizeStyle = resolveAssetDimensions(selectedSource); + const selected = shouldDisplaySource ? mainSource : fallbackSource; + const displayImageUri = selected.source.uri; const backgroundImage = displayImageUri ? `url("${displayImageUri}")` : null; const backgroundSize = getBackgroundSize(); @@ -255,54 +246,6 @@ const Image: React.AbstractComponent< } } - // Image loading - const uri = resolveAssetUri(source); - React.useEffect(() => { - abortPendingRequest(); - - if (uri != null) { - updateState(LOADING); - if (onLoadStart) { - onLoadStart(); - } - - requestRef.current = ImageLoader.load( - uri, - function load(e) { - updateState(LOADED); - if (onLoad) { - onLoad(e); - } - if (onLoadEnd) { - onLoadEnd(); - } - }, - function error() { - updateState(ERRORED); - if (onError) { - onError({ - nativeEvent: { - error: `Failed to load resource ${uri} (404)` - } - }); - } - if (onLoadEnd) { - onLoadEnd(); - } - } - ); - } - - function abortPendingRequest() { - if (requestRef.current != null) { - ImageLoader.abort(requestRef.current); - requestRef.current = null; - } - } - - return abortPendingRequest; - }, [uri, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]); - return ( @@ -353,6 +296,84 @@ ImageWithStatics.queryCache = function (uris) { return ImageLoader.queryCache(uris); }; +/** + * Image loading/state management hook + */ +const useSource = (callbacks: ImageLoadingProps, source: ?Source) => { + const [resolvedSource, setResolvedSource] = React.useState(() => + resolveSource(source) + ); + + const [status, setStatus] = React.useState(() => + ImageLoader.has(resolveSource.uri) ? LOADED : IDLE + ); + + const [result, setResult] = React.useState(resolvedSource); + + // Trigger a resolved source change when necessary + React.useEffect(() => { + const nextSource = resolveSource(source); + setResolvedSource((prevSource) => { + // Prevent triggering a state change if the next is the same value as the last loaded source + if (JSON.stringify(nextSource) === JSON.stringify(prevSource)) { + return prevSource; + } + + return nextSource; + }); + }, [source]); + + // Always use the latest value of any callback passed + // By keeping a ref, we avoid (re)triggering the load effect just because a callback changed + // (E.g. we don't want to trigger a new load because the `onLoad` prop changed) + const callbackRefs = React.useRef(callbacks); + callbackRefs.current = callbacks; + + // Start loading new source on resolved source change + React.useEffect(() => { + if (!resolvedSource.uri) { + setStatus(IDLE); + setResult(resolvedSource); + return; + } + + function handleLoad(nativeEvent) { + const { onLoad, onLoadEnd } = callbackRefs.current; + if (onLoad) onLoad({ nativeEvent }); + if (onLoadEnd) onLoadEnd(); + + setStatus(LOADED); + setResult({ ...resolvedSource, ...nativeEvent.source }); + } + + function handleError() { + const { onLoadEnd, onError } = callbackRefs.current; + if (onError) { + onError({ + nativeEvent: { + error: `Failed to load resource ${resolvedSource.uri} (404)` + } + }); + } + + if (onLoadEnd) onLoadEnd(); + + setStatus(ERRORED); + } + + const { onLoadStart } = callbackRefs.current; + if (onLoadStart) onLoadStart(); + + setStatus(LOADING); + const requestId = ImageLoader.load(resolvedSource, handleLoad, handleError); + + // Release resources on unmount or after starting a new request + return () => ImageLoader.abort(requestId); + }, [resolvedSource]); + + return { status, source: result }; +}; + const styles = StyleSheet.create({ root: { flexBasis: 'auto', diff --git a/packages/react-native-web/src/exports/Image/types.js b/packages/react-native-web/src/exports/Image/types.js index 55ad3cb9f..cd3845d7b 100644 --- a/packages/react-native-web/src/exports/Image/types.js +++ b/packages/react-native-web/src/exports/Image/types.js @@ -102,17 +102,21 @@ export type ImageStyle = { tintColor?: ColorValue }; +export type ImageLoadingProps = {| + onError?: (e: any) => void, + onLoad?: (e: any) => void, + onLoadEnd?: (e: any) => void, + onLoadStart?: (e: any) => void, + onProgress?: (e: any) => void +|}; + export type ImageProps = { - ...ViewProps, + ...$Exact, + ...ImageLoadingProps, blurRadius?: number, defaultSource?: Source, draggable?: boolean, - onError?: (e: any) => void, onLayout?: (e: any) => void, - onLoad?: (e: any) => void, - onLoadEnd?: (e: any) => void, - onLoadStart?: (e: any) => void, - onProgress?: (e: any) => void, resizeMode?: ResizeMode, source?: Source, style?: GenericStyleProp diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 892db9929..b0305322b 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -9,6 +9,13 @@ const dataUriPattern = /^data:/; +export type ImageSource = {| + uri: string, + headers?: { [key: string]: string }, + width?: ?number, + height?: ?number +|}; + export class ImageUriCache { static _maximumEntries: number = 256; static _entries = {}; @@ -75,11 +82,15 @@ const requests = {}; const ImageLoader = { abort(requestId: number) { - let image = requests[`${requestId}`]; - if (image) { + const request = requests[`${requestId}`]; + if (request) { + const { image, cleanup } = request; + if (cleanup) cleanup(); + image.onerror = null; image.onload = null; - image = null; + // Setting image.src to empty string aborts any ongoing image loading + image.src = ''; delete requests[`${requestId}`]; } }, @@ -90,12 +101,12 @@ const ImageLoader = { ) { 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}`]; - if (image) { - const { naturalHeight, naturalWidth } = image; + const request = requests[`${requestId}`]; + if (request) { + const { naturalHeight, naturalWidth } = request.image; if (naturalHeight && naturalWidth) { success(naturalWidth, naturalHeight); complete = true; @@ -118,13 +129,22 @@ const ImageLoader = { has(uri: string): boolean { return ImageUriCache.has(uri); }, - load(uri: string, onLoad: Function, onError: Function): number { + load(source: ImageSource, onLoad: Function, onError: Function): number { 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 = () => { + 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 @@ -134,14 +154,49 @@ const ImageLoader = { setTimeout(onDecode, 0); } }; - image.src = uri; - requests[`${id}`] = image; + + requests[id] = { image, source }; + + // To load an image one of 2 available strategies is selected based on `source` + // When we've got a simple source that can be loaded using the builtin Image element + // we create an Image and use `src` and the `onload` attributes + // this covers many native cases like cross-origin requests, progressive images + // But the built-in Image is not capable of performing requests with headers + // That's why when the source has headers we use another strategy and make a `fetch` request + // Then we create a (local) object URL, so we can render the downloaded file as an Image + if (source.headers) { + const abortCtrl = new AbortController(); + const request = new Request(source.uri, { + headers: source.headers, + signal: abortCtrl.signal + }); + request.headers.append('accept', 'image/*'); + + requests[id].cleanup = () => { + abortCtrl.abort(); + URL.revokeObjectURL(image.src); + }; + + fetch(request) + .then((response) => response.blob()) + .then((blob) => { + image.src = URL.createObjectURL(blob); + }) + .catch((error) => { + if (error.name !== 'AbortError') onError(error); + }); + } else { + // For simple request we load the image through `image.src` because it has wider support + // like better cross-origin support and progressive loading + image.src = source.uri; + } + return id; }, prefetch(uri: string): Promise { return new Promise((resolve, reject) => { ImageLoader.load( - uri, + { uri }, () => { // 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