From 059ab040a8429d16a3a954e977e8576bfe6cc9f3 Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Fri, 6 Jan 2023 22:51:39 +0200 Subject: [PATCH 1/6] [add] Image source headers handling --- .../pages/image/index.js | 23 ++++ .../src/exports/Image/index.js | 101 +++++++++++++++--- .../src/exports/Image/types.js | 8 +- .../src/exports/ImageBackground/index.js | 4 +- 4 files changed, 117 insertions(+), 19 deletions(-) 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..288929324 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -8,7 +8,7 @@ * @flow */ -import type { ImageProps } from './types'; +import type { ImageProps, SourceObject } from './types'; import * as React from 'react'; import createElement from '../createElement'; @@ -146,6 +146,12 @@ function resolveAssetUri(source): ?string { return uri; } +function hasSourceDiff(a: SourceObject, b: SourceObject) { + return ( + a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers) + ); +} + interface ImageStatics { getSize: ( uri: string, @@ -158,10 +164,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, @@ -332,24 +340,91 @@ const Image: React.AbstractComponent< ); }); -Image.displayName = 'Image'; +/** + * This component handles specifically loading an image source with header + */ +const ImageWithHeaders: ImageComponent = React.forwardRef((props, ref) => { + // $FlowIgnore + const nextSource: SourceObject = props.source; + const prevSource = React.useRef({}); + const cleanup = React.useRef(() => {}); + const [blobUri, setBlobUri] = React.useState(''); + + const { onError, onLoadStart } = props; + + React.useEffect(() => { + if (!hasSourceDiff(nextSource, prevSource.current)) return; + + // When source changes we want to clean up any old/running requests + cleanup.current(); + + prevSource.current = nextSource; + + let uri; + const abortCtrl = new AbortController(); + const request = new Request(nextSource.uri, { + headers: nextSource.headers, + signal: abortCtrl.signal + }); + request.headers.append('accept', 'image/*'); + + if (onLoadStart) onLoadStart(); + + fetch(request) + .then((response) => response.blob()) + .then((blob) => { + uri = URL.createObjectURL(blob); + setBlobUri(uri); + }) + .catch((error) => { + if (error.name !== 'AbortError' && onError) { + onError({ nativeEvent: error.message }); + } + }); + + // Capture a cleanup function for the current request + // The reason for using a Ref is to avoid making this function a dependency + // Because the change of a dependency would otherwise would re-trigger a hook + cleanup.current = () => { + abortCtrl.abort(); + setBlobUri(''); + URL.revokeObjectURL(uri); + }; + }, [nextSource, onLoadStart, onError]); + + // Run the cleanup function on unmount + React.useEffect(() => cleanup.current, []); + + const propsToPass = { + ...props, + // Omit load start because we already triggered it here + onLoadStart: undefined, + source: { ...nextSource, uri: blobUri } + }; + + 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 Image: ImageComponent & ImageStatics = React.forwardRef((props, ref) => { + if (props.source && props.source.headers) { + return ; + } + + return ; +}); + +Image.displayName = 'Image'; -ImageWithStatics.getSize = function (uri, success, failure) { +Image.getSize = function (uri, success, failure) { ImageLoader.getSize(uri, success, failure); }; -ImageWithStatics.prefetch = function (uri) { +Image.prefetch = function (uri) { return ImageLoader.prefetch(uri); }; -ImageWithStatics.queryCache = function (uris) { +Image.queryCache = function (uris) { return ImageLoader.queryCache(uris); }; @@ -405,4 +480,4 @@ const resizeModeStyles = StyleSheet.create({ } }); -export default ImageWithStatics; +export default Image; diff --git a/packages/react-native-web/src/exports/Image/types.js b/packages/react-native-web/src/exports/Image/types.js index 55ad3cb9f..0233c0dfc 100644 --- a/packages/react-native-web/src/exports/Image/types.js +++ b/packages/react-native-web/src/exports/Image/types.js @@ -20,7 +20,7 @@ import type { TransformStyles } from '../../types/styles'; -type SourceObject = { +export type SourceObject = { /** * `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 @@ -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 = {}; From 4e6dacabd40445a14799939a74805e3dfabf0cdb Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Fri, 6 Jan 2023 23:57:54 +0200 Subject: [PATCH 2/6] [add] Image: Append source to `nativeEvent` --- .../src/modules/ImageLoader/index.js | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 892db9929..b8650b48f 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -122,9 +122,17 @@ 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 = () => { + 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 From 8f4d952b7966fd6e10831d299bdf76929dac5bc5 Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Sun, 15 Jan 2023 22:28:23 +0200 Subject: [PATCH 3/6] [change] Image code comments --- packages/react-native-web/src/exports/Image/index.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 288929324..5371306d6 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -397,9 +397,12 @@ const ImageWithHeaders: ImageComponent = React.forwardRef((props, ref) => { const propsToPass = { ...props, - // Omit load start because we already triggered it here + // Omit `onLoadStart` because we trigger it in the current scope onLoadStart: undefined, - source: { ...nextSource, uri: blobUri } + // 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 ; From 65b45fd3efdb762af2e6686b5b396f046e8b143f Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Thu, 19 Jan 2023 22:11:50 +0200 Subject: [PATCH 4/6] [add] ImageLoader.loadUsingHeaders --- .../src/modules/ImageLoader/index.js | 66 +++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index b8650b48f..9c1534a1f 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -90,7 +90,7 @@ 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}`]; @@ -118,7 +118,11 @@ const ImageLoader = { has(uri: string): boolean { return ImageUriCache.has(uri); }, - load(uri: string, onLoad: Function, onError: Function): number { + load( + source: { uri: string }, + onLoad: Function, + onError: Function + ): LoadRequest { id += 1; const image = new window.Image(); image.onerror = onError; @@ -142,14 +146,56 @@ const ImageLoader = { setTimeout(onDecode, 0); } }; - image.src = uri; + image.src = source.uri; requests[`${id}`] = image; - return id; + + return { + cancel: () => ImageLoader.abort(id), + requestId: id + }; + }, + loadWithHeaders( + source: ImageSource, + onLoad: Function, + onError: Function + ): LoadRequest { + let loadRequest: LoadRequest; + let uri: string; + const abortCtrl = new AbortController(); + const request = new Request(source.uri, { + headers: source.headers, + signal: abortCtrl.signal + }); + request.headers.append('accept', 'image/*'); + + fetch(request) + .then((response) => response.blob()) + .then((blob) => { + uri = URL.createObjectURL(blob); + loadRequest = ImageLoader.load({ uri }, onLoad, onError); + }) + .catch((error) => { + if (error.name !== 'AbortError' && onError) { + onError({ nativeEvent: error.message }); + } + }); + + return { + get requestId() { + if (loadRequest) return loadRequest.requestId; + return -1; + }, + cancel: () => { + abortCtrl.abort(); + if (loadRequest) loadRequest.cancel(); + URL.revokeObjectURL(uri); + } + }; }, 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 @@ -172,4 +218,14 @@ const ImageLoader = { } }; +export type LoadRequest = { + cancel: Function, + requestId: number +}; + +type ImageSource = { + uri: string, + headers: { [key: string]: string } +}; + export default ImageLoader; From 16984f8632909439671950b04bcbdde2977377af Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Thu, 19 Jan 2023 22:13:44 +0200 Subject: [PATCH 5/6] [change] Image: revert changes and use one component --- .../src/exports/Image/__tests__/index-test.js | 16 +- .../src/exports/Image/index.js | 138 +++++------------- .../src/exports/Image/types.js | 8 +- .../src/exports/ImageBackground/index.js | 4 +- 4 files changed, 49 insertions(+), 117 deletions(-) diff --git a/packages/react-native-web/src/exports/Image/__tests__/index-test.js b/packages/react-native-web/src/exports/Image/__tests__/index-test.js index 3be0434e2..8b78927a7 100644 --- a/packages/react-native-web/src/exports/Image/__tests__/index-test.js +++ b/packages/react-native-web/src/exports/Image/__tests__/index-test.js @@ -20,6 +20,10 @@ const originalImage = window.Image; describe('components/Image', () => { beforeEach(() => { ImageUriCache._entries = {}; + ImageLoader.load = jest.fn((_, onLoad, onError) => { + onLoad(); + return { cancel: jest.fn() }; + }); window.Image = jest.fn(() => ({})); }); @@ -107,9 +111,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 +128,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(); @@ -261,11 +259,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(, { @@ -313,6 +306,7 @@ describe('components/Image', () => { .fn() .mockImplementationOnce((_, onLoad, onError) => { loadCallback = onLoad; + return { cancel: jest.fn() }; }); const { container } = render( diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 5371306d6..3e26577a9 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, SourceObject } from './types'; +import type { LoadRequest } from '../../modules/ImageLoader'; +import type { ImageProps } from './types'; import * as React from 'react'; import createElement from '../createElement'; @@ -146,7 +147,7 @@ function resolveAssetUri(source): ?string { return uri; } -function hasSourceDiff(a: SourceObject, b: SourceObject) { +function hasSourceDiff(a, b) { return ( a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers) ); @@ -164,12 +165,10 @@ interface ImageStatics { ) => Promise<{| [uri: string]: 'disk/memory' |}>; } -type ImageComponent = React.AbstractComponent< +const Image: React.AbstractComponent< ImageProps, React.ElementRef ->; - -const BaseImage: ImageComponent = React.forwardRef((props, ref) => { +> = React.forwardRef((props, ref) => { const { accessibilityLabel, blurRadius, @@ -194,6 +193,7 @@ const BaseImage: ImageComponent = React.forwardRef((props, ref) => { } } + const lastLoadedSource = React.useRef({}); const [state, updateState] = React.useState(() => { const uri = resolveAssetUri(source); if (uri != null) { @@ -209,7 +209,10 @@ const BaseImage: ImageComponent = React.forwardRef((props, ref) => { const hasTextAncestor = React.useContext(TextAncestorContext); const hiddenImageRef = React.useRef(null); const filterRef = React.useRef(_filterId++); - const requestRef = React.useRef(null); + const requestRef = React.useRef({ + cancel: () => {}, + requestId: -1 + }); const shouldDisplaySource = state === LOADED || (state === LOADING && defaultSource == null); const [flatStyle, _resizeMode, filter, tintColor] = getFlatStyle( @@ -266,16 +269,27 @@ const BaseImage: ImageComponent = React.forwardRef((props, ref) => { // Image loading const uri = resolveAssetUri(source); React.useEffect(() => { - abortPendingRequest(); - if (uri != null) { + const nextSource = { + // $FlowFixMe + headers: source.headers, + uri + }; + if (!hasSourceDiff(nextSource, lastLoadedSource.current)) return; + + requestRef.current.cancel(); updateState(LOADING); if (onLoadStart) { onLoadStart(); } - requestRef.current = ImageLoader.load( - uri, + const makeRequest = nextSource.headers + ? ImageLoader.loadWithHeaders + : ImageLoader.load; + + requestRef.current = makeRequest( + // $FlowFixMe + nextSource, function load(e) { updateState(LOADED); if (onLoad) { @@ -300,17 +314,11 @@ const BaseImage: ImageComponent = React.forwardRef((props, ref) => { } ); } - - function abortPendingRequest() { - if (requestRef.current != null) { - ImageLoader.abort(requestRef.current); - requestRef.current = null; - } - } - - return abortPendingRequest; }, [uri, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]); + // Run the cleanup function on unmount + React.useEffect(() => requestRef.current.cancel, []); + return ( { ); }); -/** - * This component handles specifically loading an image source with header - */ -const ImageWithHeaders: ImageComponent = React.forwardRef((props, ref) => { - // $FlowIgnore - const nextSource: SourceObject = props.source; - const prevSource = React.useRef({}); - const cleanup = React.useRef(() => {}); - const [blobUri, setBlobUri] = React.useState(''); - - const { onError, onLoadStart } = props; - - React.useEffect(() => { - if (!hasSourceDiff(nextSource, prevSource.current)) return; - - // When source changes we want to clean up any old/running requests - cleanup.current(); - - prevSource.current = nextSource; - - let uri; - const abortCtrl = new AbortController(); - const request = new Request(nextSource.uri, { - headers: nextSource.headers, - signal: abortCtrl.signal - }); - request.headers.append('accept', 'image/*'); - - if (onLoadStart) onLoadStart(); - - fetch(request) - .then((response) => response.blob()) - .then((blob) => { - uri = URL.createObjectURL(blob); - setBlobUri(uri); - }) - .catch((error) => { - if (error.name !== 'AbortError' && onError) { - onError({ nativeEvent: error.message }); - } - }); - - // Capture a cleanup function for the current request - // The reason for using a Ref is to avoid making this function a dependency - // Because the change of a dependency would otherwise would re-trigger a hook - cleanup.current = () => { - abortCtrl.abort(); - setBlobUri(''); - URL.revokeObjectURL(uri); - }; - }, [nextSource, onLoadStart, onError]); - - // Run the cleanup function on unmount - React.useEffect(() => cleanup.current, []); - - const propsToPass = { - ...props, - // Omit `onLoadStart` because we trigger it in the current scope - 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 ; -}); +Image.displayName = 'Image'; // $FlowIgnore: This is the correct type, but casting makes it unhappy since the variables aren't defined yet -const Image: ImageComponent & ImageStatics = React.forwardRef((props, ref) => { - if (props.source && props.source.headers) { - return ; - } - - return ; -}); - -Image.displayName = 'Image'; +const ImageWithStatics = (Image: React.AbstractComponent< + ImageProps, + React.ElementRef +> & + ImageStatics); -Image.getSize = function (uri, success, failure) { +ImageWithStatics.getSize = function (uri, success, failure) { ImageLoader.getSize(uri, success, failure); }; -Image.prefetch = function (uri) { +ImageWithStatics.prefetch = function (uri) { return ImageLoader.prefetch(uri); }; -Image.queryCache = function (uris) { +ImageWithStatics.queryCache = function (uris) { return ImageLoader.queryCache(uris); }; @@ -483,4 +421,4 @@ const resizeModeStyles = StyleSheet.create({ } }); -export default Image; +export default ImageWithStatics; diff --git a/packages/react-native-web/src/exports/Image/types.js b/packages/react-native-web/src/exports/Image/types.js index 0233c0dfc..55ad3cb9f 100644 --- a/packages/react-native-web/src/exports/Image/types.js +++ b/packages/react-native-web/src/exports/Image/types.js @@ -20,7 +20,7 @@ import type { TransformStyles } from '../../types/styles'; -export type SourceObject = { +type SourceObject = { /** * `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 @@ -102,8 +102,8 @@ export type ImageStyle = { tintColor?: ColorValue }; -export type ImageProps = {| - ...$Exact, +export type ImageProps = { + ...ViewProps, 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 a86111839..561dd33d1 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 = {}; From 2054e0deb4e0b3b54709a75cf310159ea7e15deb Mon Sep 17 00:00:00 2001 From: Peter Velkov Date: Tue, 24 Jan 2023 12:18:48 +0200 Subject: [PATCH 6/6] Cleanup --- .../__snapshots__/index-test.js.snap | 8 +++--- .../src/exports/Image/__tests__/index-test.js | 8 +++--- .../src/exports/Image/index.js | 28 +++++++++++-------- .../src/modules/ImageLoader/index.js | 14 ++++++---- 4 files changed, 34 insertions(+), 24 deletions(-) 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..f46206ac4 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 = {}; - ImageLoader.load = jest.fn((_, onLoad, onError) => { + ImageLoader.load = jest.fn((source, onLoad, onError) => { onLoad(); - return { cancel: jest.fn() }; + return { source, cancel: jest.fn() }; }); window.Image = jest.fn(() => ({})); }); @@ -304,9 +304,9 @@ describe('components/Image', () => { let loadCallback; ImageLoader.load = jest .fn() - .mockImplementationOnce((_, onLoad, onError) => { + .mockImplementationOnce((source, onLoad, onError) => { loadCallback = onLoad; - return { cancel: jest.fn() }; + return { source, cancel: jest.fn() }; }); const { container } = render( diff --git a/packages/react-native-web/src/exports/Image/index.js b/packages/react-native-web/src/exports/Image/index.js index 3e26577a9..fa28788fa 100644 --- a/packages/react-native-web/src/exports/Image/index.js +++ b/packages/react-native-web/src/exports/Image/index.js @@ -148,9 +148,12 @@ function resolveAssetUri(source): ?string { } function hasSourceDiff(a, b) { - return ( - a.uri !== b.uri || JSON.stringify(a.headers) !== JSON.stringify(b.headers) - ); + if (a.uri !== b.uri) return true; + + const headersA = a.headers || {}; + const headersB = b.headers || {}; + + return JSON.stringify(headersA) !== JSON.stringify(headersB); } interface ImageStatics { @@ -193,7 +196,6 @@ const Image: React.AbstractComponent< } } - const lastLoadedSource = React.useRef({}); const [state, updateState] = React.useState(() => { const uri = resolveAssetUri(source); if (uri != null) { @@ -210,6 +212,7 @@ const Image: React.AbstractComponent< const hiddenImageRef = React.useRef(null); const filterRef = React.useRef(_filterId++); const requestRef = React.useRef({ + source: { uri: '' }, cancel: () => {}, requestId: -1 }); @@ -270,12 +273,12 @@ const Image: React.AbstractComponent< const uri = resolveAssetUri(source); React.useEffect(() => { if (uri != null) { - const nextSource = { - // $FlowFixMe - headers: source.headers, - uri - }; - if (!hasSourceDiff(nextSource, lastLoadedSource.current)) return; + const nextSource = { uri, headers: undefined }; + if (source && source.headers != null) { + nextSource.headers = source.headers; + } + + if (!hasSourceDiff(nextSource, requestRef.current.source)) return; requestRef.current.cancel(); updateState(LOADING); @@ -313,8 +316,11 @@ const Image: React.AbstractComponent< } } ); + } else { + requestRef.current.source = { uri: '' }; + requestRef.current.cancel(); } - }, [uri, requestRef, updateState, onError, onLoad, onLoadEnd, onLoadStart]); + }, [uri, updateState, onError, onLoad, onLoadEnd, onLoadStart, source]); // Run the cleanup function on unmount React.useEffect(() => requestRef.current.cancel, []); diff --git a/packages/react-native-web/src/modules/ImageLoader/index.js b/packages/react-native-web/src/modules/ImageLoader/index.js index 9c1534a1f..c4534c94a 100644 --- a/packages/react-native-web/src/modules/ImageLoader/index.js +++ b/packages/react-native-web/src/modules/ImageLoader/index.js @@ -129,6 +129,7 @@ const ImageLoader = { image.onload = (nativeEvent) => { // avoid blocking the main thread const onDecode = () => { + // Append `source` to match RN's ImageLoadEvent interface nativeEvent.source = { uri: image.src, width: image.naturalWidth, @@ -150,6 +151,7 @@ const ImageLoader = { requests[`${id}`] = image; return { + source, cancel: () => ImageLoader.abort(id), requestId: id }; @@ -176,11 +178,12 @@ const ImageLoader = { }) .catch((error) => { if (error.name !== 'AbortError' && onError) { - onError({ nativeEvent: error.message }); + onError(error); } }); return { + source, get requestId() { if (loadRequest) return loadRequest.requestId; return -1; @@ -218,10 +221,11 @@ const ImageLoader = { } }; -export type LoadRequest = { - cancel: Function, - requestId: number -}; +export type LoadRequest = {| + requestId: number, + source: ImageSource | { uri: string }, + cancel: Function +|}; type ImageSource = { uri: string,