[add] Image source headers handling

Extend ImageLoader functionality to be able to work with image sources
containing headers

We preserve the existing strategy that works with image.src for cases
where source is just an uri with no headers

When sources contain headers we make a fetch request and then render a
local url for the downloaded blob (URL.createObjectURL)

Fix #1019
Fix #2268
Close #2442
This commit is contained in:
Peter Velkov
2023-01-06 22:51:39 +02:00
committed by Nicolas Gallagher
parent 39b94b1945
commit 36f46b3510
5 changed files with 253 additions and 42 deletions
+23
View File
@@ -15,6 +15,18 @@ const dataBase64Svg =
'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0nMjAwJyBoZWlnaHQ9JzIwMCcgZmlsbD0iIzAwMDAwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIiB4bWxuczp4bGluaz0iaHR0cDovL3d3dy53My5vcmcvMTk5OS94bGluayIgdmVyc2lvbj0iMS4xIiB4PSIwcHgiIHk9IjBweCIgdmlld0JveD0iMCAwIDEwMCAxMDAiIGVuYWJsZS1iYWNrZ3JvdW5kPSJuZXcgMCAwIDEwMCAxMDAiIHhtbDpzcGFjZT0icHJlc2VydmUiPjxnPjxwYXRoIGQ9Ik0yNS44NjcsNDguODUzQzMyLjgwNiw1MC4xNzYsNDYuNDYsNTIuNSw2MS4yMTUsNTIuNWgwLjAwNWM5LjcxLDAsMTguNDAxLTEuMDU3LDI1LjkzOC0yLjkxMyAgIGMwLjE1OS0wLjA0NiwwLjM1LTAuMTM1LDAuNTY1LTAuMTg3YzAuMjgyLTAuMDcyLDAuNTY1LTAuMTY0LDAuODQ0LTAuMjM4YzMuMTg0LTAuOTY0LDIuNTc3LTMuMDUxLDIuMTk5LTMuODUyICAgYy00LjE2Ni03LjcxOS0xNS4wODYtMjMuNDE1LTM1LjAyOC0yMy40MTVjLTIyLjE2OSwwLTMwLjI2MiwxMC42MzUtMzMuMTQsMTkuNTg5QzIyLjU0NSw0Mi4zMzMsMjIuNDA3LDQ3LjEzNSwyNS44NjcsNDguODUzeiAgICBNMjguNjc2LDM4LjAzMmMwLjAxMy0wLjAzNiwwLjYxNC0xLjYyNiwxLjkyMy0xLjAwOGMxLjEzMywwLjUzNSwwLjk2MSwxLjU2MywwLjg4NywxLjg1Yy0wLjAwNywwLjAyNC0wLjAxNCwwLjA0OC0wLjAyMSwwLjA3MyAgIGMwLDAuMDAxLTAuMDAxLDAuMDA0LTAuMDAxLDAuMDA0bDAsMGMtMC4yNDksMC45MjktMC40MDQsMi4wODYtMC4wMTcsMi44NmMwLjE2LDAuMzE5LDAuNDkyLDAuNzY4LDEuNTQyLDAuOTg3bDAuMzY2LDAuMDc3ICAgYzIwLjgxNiw0LjM2LDM2LDIuOTMzLDQ1LjY3OCwwLjYyNmwtMC4wMDQsMC4wMDJjMCwwLDAuMDA1LTAuMDAyLDAuMDA3LTAuMDAzYzAuMjEyLTAuMDUsMC40MjEtMC4xMDEsMC42MjgtMC4xNTIgICBjMC41MDktMC4wNSwxLjE3MywwLjA3OCwxLjM5OSwxYzAuMzUxLDEuNDI0LTAuOTczLDEuODk1LTEuMjE3LDEuOTY5Yy01LjMyNSwxLjI3OS0xMi4yNjYsMi4zMDYtMjAuODM1LDIuMzA3ICAgYy03LjUwNSwwLTE2LjI1NS0wLjc4Ny0yNi4yNTctMi44ODJsLTAuMzY0LTAuMDc3Yy0yLjEyLTAuNDQyLTMuMTExLTEuNjMzLTMuNTY5LTIuNTU1QzI3Ljk4NSw0MS40MjEsMjguMjgxLDM5LjQxNiwyOC42NzYsMzguMDMyICAgeiI+PC9wYXRoPjxjaXJjbGUgY3g9IjEwLjQ5MyIgY3k9IjIzLjQ1NSIgcj0iMC42MTkiPjwvY2lyY2xlPjxwYXRoIGQ9Ik0yLjA4LDI4LjMwOGMwLjY3Ni0wLjE3OCwwLjk4My0wLjM1MiwxLjE3NC0wLjVDNC42OSwyNi42OSw2LjUsMjcuNDgzLDcuNSwyOC4zNTd2MC4wMDJjMCwwLDEuNzExLDEuMjM1LDAuNzM3LDIuMjAyICAgYy0wLjk3NCwwLjk2NS0yLjMxOSwwLjAwNi0yLjMxOSwwLjAwNmwwLjAzNSwwLjAxNmMtMC4zMjctMC4yMDMtMC42LTAuNTYxLTAuNzgtMC41ODRjLTAuMzcsMC4yNi0wLjg3NiwwLjUtMS40NzYsMC41SDMuNyAgIGMwLDAtMS4zNDUsMC43MDksMC4xNzgsMS42NTJjMC4wMDEsMC4wMDEsMC4wMDIsMC4wNzIsMC4wMDQsMC4wNzNjMy45MzksMi4zNDIsOC4yNzEsNS43MDEsOC4yNzEsOC44OCAgIGMwLDAuNjkxLDAuMiwxNy4wNDIsMTcuNjI2LDI0LjczOWwwLjk2NywwLjQ0MmwtMC4xLDEuMDU5Yy0wLjQyMSw0LjM5LDEuMTQ1LDEwLjE5MSwxMC45OTMsMTIuODg4bDAuMTEzLDAuMDM4ICAgYzAuMDY3LDAuMDIzLDYuNzMyLDIuNDI5LDEwLjkwNywyLjQyOWMxLjU4NCwwLDIuMTU1LTAuMzUyLDIuMjQzLTAuNTYxYzAuMDg1LTAuMjAyLDAuNjEyLTIuMTY0LTYuMzMyLTkuMzg3bDAuMDAyLTAuMTgzICAgYzAsMC0yLjQ3Ny0zLjA3LDEuNTMzLTMuMDdjMC4wMSwwLDAuMDE5LDAsMC4wMjksMGMxLjI4NSwwLDIuNjA4LDAuMjE1LDMuOTgsMC4xODRjNC43NzEtMC4xMTcsOS4zMTYtMC40MjUsMTMuNTA2LTEuMDk2ICAgbDAuNDc0LTAuMDI4bDAuNjY4LDAuMTU4YzkuNjUxLDQuOTQ4LDE2LjczOCw3LjcxNiwxOS43MzgsNy43MTZ2MC4wMDZjMCwwLDAuMTY0LDAuMDExLDAuMjMsMC4wMDQgICBjLTAuMTg5LTAuNzIzLTIuMjMtMi44LTcuMjMtOS4wNzl2MC4wMjFjMCwwLTEuNTEyLTEuNjU4LDAuNzk3LTIuNjUzYzAuMDYzLTAuMDI2LDAuMDA4LDAuMDIzLDAuMDYtMC4wMDEgICBjOC42MzktMy41MDksMTMuNTAxLTguMjA0LDE1LjQxMS0xMS43NzVjMS4xNDUtMi4xMjksMC4yMDYtMi43ODQtMC42NTktMi45NzZjLTAuMzE3LTAuMDM4LTAuNjM0LTAuMDYyLTAuOTEyLTAuMDYyICAgYy0wLjIwNSwwLTAuMzc5LDAuMDEtMC41MjgsMC4wMjdsLTMuMTQzLDEuMjE0QzgzLjczMiw1My45MjYsNzMuMjE4LDU1LjUsNjEuMjIsNTUuNWMtMC4wMDIsMC0wLjAwNSwwLTAuMDA1LDAgICBjLTE1LjEyOCwwLTI5LjEwMS0yLjQzMi0zNi4wODMtMy43NzFsLTAuMTczLTAuMTExbC0wLjE2LTAuMTI2Yy01Ljg1OC0yLjY4MS01LjEzNy0xMC4yMDItNS4xMDMtMTAuNTE5bDAuMDYtMC4zICAgYzAuODk1LTIuODM4LDIuNDY3LTYuMzUyLDUuMjEzLTkuNzE5Yy0xLjgwOC0xLjM2OS00LjU5LTQuMTg4LTQuNDMtOC40OTRjMC4wNDYtMS4yNDQtMC40ODYtMi41MDgtMS40OTgtMy41NTkgICBjLTEuNDk4LTEuNTU1LTMuNzg1LTIuNDQ2LTYuMjc0LTIuNDQ2Yy0xLjc3LDAtMy41NTMsMC40NDItNS4yOTMsMS4zMTRjLTQuMDYxLDIuMDM1LTQuODU1LDQuNzM2LTUuNjkyLDcuNTk2ICAgYy0wLjEzNiwwLjQ2OC0wLjI4NCwwLjkzOS0wLjQzOCwxLjQxYy0wLjAwNiwwLjAxOS0wLjAyMiwwLjAzNS0wLjAyOCwwLjA1NkMwLjgzMywyOC40MjMsMS42OTEsMjguMzksMi4wOCwyOC4zMDh6IE0xMC40OTMsMTkuOTA4ICAgYzEuOTU2LDAsMy41NDgsMS41OTEsMy41NDgsMy41NDdjMCwxLjk1Ny0xLjU5MiwzLjU0OC0zLjU0OCwzLjU0OGMtMS45NTcsMC0zLjU0OC0xLjU5Mi0zLjU0OC0zLjU0OCAgIEM2Ljk0NCwyMS40OTksOC41MzYsMTkuOTA4LDEwLjQ5MywxOS45MDh6Ij48L3BhdGg+PC9nPjwvc3ZnPg==';
const dataSvg =
'data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3"><g fill="#61DAFB"><path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/><circle cx="420.9" cy="296.5" r="45.7"/><path d="M520.5 78.1z"/></g></svg>';
const sourceWithHeaders = {
uri: placeholder,
headers: {
'x-token': '0012345'
}
};
const sourceWithHeadersAndRedirect = {
uri: source,
headers: {
'x-token': '0012345'
}
};
function Divider() {
return <View style={styles.divider} />;
@@ -118,6 +130,17 @@ export default function ImagePage() {
/>
</View>
</View>
<Divider />
<View style={styles.row}>
<View style={styles.column}>
<Text style={[styles.text]}>With Headers</Text>
<Image source={sourceWithHeaders} style={styles.image} />
</View>
<View style={styles.column}>
<Text style={[styles.text]}>Headers & Redirect</Text>
<Image source={sourceWithHeadersAndRedirect} style={styles.image} />
</View>
</View>
</Example>
);
}
@@ -329,14 +329,14 @@ exports[`components/Image prop "style" removes other unsupported View styles 1`]
>
<div
class="css-view-175oi2r r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw r-backgroundSize-4gszlv"
style="filter: url(#tint-55);"
style="filter: url(#tint-66);"
/>
<svg
style="position: absolute; height: 0px; visibility: hidden; width: 0px;"
>
<defs>
<filter
id="tint-55"
id="tint-66"
>
<feflood
flood-color="blue"
@@ -379,7 +379,7 @@ exports[`components/Image prop "tintColor" convert to filter 1`] = `
>
<div
class="css-view-175oi2r r-backgroundColor-1niwhzg r-backgroundPosition-vvn4in r-backgroundRepeat-u6sd8q r-bottom-1p0dtai r-height-1pi2tsx r-left-1d2f490 r-position-u8s1d r-right-zchlnj r-top-ipm5af r-width-13qz1uu r-zIndex-1wyyakw r-backgroundSize-4gszlv"
style="background-image: url(https://google.com/favicon.ico); filter: url(#tint-56);"
style="background-image: url(https://google.com/favicon.ico); filter: url(#tint-67);"
/>
<img
alt=""
@@ -392,7 +392,7 @@ exports[`components/Image prop "tintColor" convert to filter 1`] = `
>
<defs>
<filter
id="tint-56"
id="tint-67"
>
<feflood
flood-color="red"
@@ -12,7 +12,7 @@ import Image from '../';
import ImageLoader, { ImageUriCache } from '../../../modules/ImageLoader';
import PixelRatio from '../../PixelRatio';
import React from 'react';
import { act, render } from '@testing-library/react';
import { act, render, waitFor } from '@testing-library/react';
const originalImage = window.Image;
@@ -20,6 +20,16 @@ describe('components/Image', () => {
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(
<Image
onLoadStart={onLoadStartStub}
source={{
uri: 'https://test.com/img.jpg',
headers: { 'x-custom-header': 'abc123' }
}}
/>
);
act(() => {
rerender(
<Image
onLoadStart={onLoadStartStub}
source={{
uri: 'https://test.com/img.jpg',
headers: { 'x-custom-header': '123abc' }
}}
/>
);
});
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(<Image source={source} />)).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(<Image source={source} />, {
@@ -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(<Image source={source} />);
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(<Image source={{ uri, headers }} />);
rerender(<Image source={{ uri, headers }} />);
// 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"', () => {
+85 -19
View File
@@ -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<typeof View>
> = 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,69 @@ const Image: React.AbstractComponent<
);
});
Image.displayName = 'Image';
BaseImage.displayName = 'Image';
// $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<typeof View>
> &
ImageStatics);
/**
* 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<LoadRequest>({
cancel: () => {},
source: { uri: '', headers: {} },
promise: Promise.resolve('')
});
const { onLoadStart, ...forwardedProps } = props;
const { onError, onLoadEnd } = forwardedProps;
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, []);
// 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
const source = blobUri ? { ...nextSource, uri: blobUri } : undefined;
return <BaseImage {...forwardedProps} ref={ref} source={source} />;
});
// $FlowFixMe
const ImageWithStatics: ImageComponent & ImageStatics = React.forwardRef(
(props, ref) => {
if (props.source && props.source.headers) {
return <ImageWithHeaders {...props} ref={ref} />;
}
return <BaseImage {...props} ref={ref} />;
}
);
ImageWithStatics.getSize = function (uri, success, failure) {
ImageLoader.getSize(uri, success, failure);
+55 -2
View File
@@ -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<void> {
return new Promise((resolve, reject) => {
ImageLoader.load(
@@ -164,4 +206,15 @@ const ImageLoader = {
}
};
export type LoadRequest = {|
cancel: Function,
source: ImageSource,
promise: Promise<string>
|};
export type ImageSource = {
uri: string,
headers: { [key: string]: string }
};
export default ImageLoader;