Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[add] Image source headers handling #11

Merged
merged 8 commits into from
Jan 27, 2023

Conversation

kidroca
Copy link

@kidroca kidroca commented Jan 6, 2023

Upstream PR: necolas#2442

Details

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 source contain headers we make a fetch request and then render a local url for the
downloaded blob (URL.createObjectURL)

Fixed Issues

$ Expensify/App#12603

Test Strategy

  1. Verify existing Image functionality has no regressions

    • build web and examples: npm run dev -w react-native-web and npm run dev -w react-native-web-examples
    • open the examples page and go to Image: http://localhost:3000/image
    • see images are loading
    • take a screenshot and do the same from the master branch. You can switch back and forth and verify the image are loading the same way
  2. Verify Images with headers can be loaded

    • build web and examples: npm run dev -w react-native-web and npm run dev -w react-native-web-examples
    • open the examples page and go to Image: http://localhost:3000/image
    • modify sourceWithHeaders here packages/react-native-web-examples/pages/image/index.js and try to load images from a server that expects a GET request with a header
    • verify the image is loading on the examples page (near the bottom, labeled: "With Headers")

@kidroca kidroca marked this pull request as ready for review January 15, 2023 21:12
Comment on lines 363 to 383
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 });
}
});
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried to write some unit tests covering the new functionality, but because of this logic a few mocks need to be setup and make the PR bigger than it has to be

I think it might be better to move this logic to ImageLoader.loadUsingHeaders so in unit tests we can stub that method and ensure it gets called when we intent to

It's easy to manually verify the fetch logic works and downloads content
Once that's clear it's easy to just verify loadUsingHeaders is called correctly with expected input


I've made a similar comment on the main repo: https://github.com/necolas/react-native-web/pull/2442/files#r1072080652


What do you think, should we refactor / write some tests now or let the mainstream repo request those changes?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm since this logic is the real meat & potatoes of the ImageWithHeaders component, if we move it to ImageLoader.loadUsingHeaders I assume we could get rid of this ImageWithHeaders component, and basically check if there's headers passed in the props - if yes, we call ImageLoader.loadUsingHeaders instead of ImageLoader.load here?

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();
}
}
);
}

If that's what you're thinking, I honestly do think that logic is cleaner, even without needing to write tests for Expensify's case so I'd say the refactor sounds like a nice idea

Copy link
Author

@kidroca kidroca Jan 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, the idea is purely to ease mocking but I'll give your suggestion a try

Last PR tried to solve everything inside the same component but that resulted in more logic

What a component like ImageWithHeaders gives us is a guarantee that source would always be an object with headers

BTW there's feedback on the mainstream PR: necolas#2442 (comment) that we should write tests and thumbs up for extracting ImageLoader.loadUsingHeaders

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've tried to remove the ImageWithHeaders component and use either loadUsingHeaders or load functions here: https://github.com/Expensify/react-native-web/compare/master...kidroca:react-native-web:kidroca/feat/image-loader-headers-alt-2?diff=unified

But it results in a similar amount of changes and modifies some of the original logic (and seems harder to review)

  • because now the source loading useEffect needs to account for objects
  • load and loadWithHeaders need to work in a similar way in order to be interchangeable (so they were modified to return a request.cancel function)

We might merge load and loadUsingHeaders instead of testing which one to run, though this would be similar to the first PR were load handled loading images with and without headers

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for doing that refactor! Personally I prefer the new changes because it keeps all the image loading logic in ImageLoader and there's minimal changes to Image/index.js - I don't see those new changes too difficult to review (though I am not sure where lastLoadedSource gets updated).

I'd say let's move forward with this last refactor, as I think it's pretty straightforward compared to passing / not passing specific props to the BaseImage component required in this PR

Copy link
Author

@kidroca kidroca Jan 20, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the refactor is more of a proof that there are more "gymnastics" necessary in order to make this work by changing the original logic inside Image component

Original logic is changed, the cleanup logic is different, ImageLoader.load is changed, people, including the mainstream maintainer would have to inspect how these used to work and whether the change is suitable

IMO the mainstream maintainer already saw the update and is fine with just moving the fetch call to ImageLoader.loadUsingHeaders. I'm still trying different things, but the most straightforward PR so far seems to be the current one

There's also a big rework planned for the Image and the original loading logic would change, it would probably be best to not write stuff that depend on it (in the alt branch loadUsingHeaders depends on load)

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me update the current PR with loadUsingHeaders extracted to ImageLoader and then we can make one final decision which set of changes would work best for us

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IMO the mainstream maintainer already saw the update and is fine with just moving the fetch call to ImageLoader.loadUsingHeaders. I'm still trying different things, but the most straightforward PR so far seems to be the current one

Yeah this is one additional reason I really like this approach, even though there may be some additional changes in the upstream repo needed that we don't need at the moment in this fork 👍

Let me update the current PR with loadUsingHeaders extracted to ImageLoader and then we can make one final decision which set of changes would work best for us

That sounds perfect, thanks so much @kidroca 👍

@kidroca
Copy link
Author

kidroca commented Jan 17, 2023

In need of some Review feedback here

cc @Beamanator @marcaaron @tgolen

@Beamanator
Copy link

Sorry I didn't have a chance to get to this today, @marcaaron do you mind giving this a first pass if you have time today? I will definitely review tomorrow no matter what 👍

@marcaaron
Copy link

Chatted 1:1 with @Beamanator. I'd love to see a good faith effort on this one before chiming in. It sounds like there are some higher priority things on Alex's plate that kept him from getting to this today. Taking that information... I've determined that my review is not urgently needed here and Alex has agreed to give a first review (I think).

Copy link

@Beamanator Beamanator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a whole I'd say these changes were much easy to follow, thanks for this PR @kidroca 👍

As I responded to your comment, I do think the refactor (moving ImageWithHeaders's loading logic into ImageLoader) could make this even more understandable, as long as that eliminates the need for the ImageWithHeaders component. Let me know what you think about my other comments below 👍

packages/react-native-web/src/exports/Image/index.js Outdated Show resolved Hide resolved
packages/react-native-web/src/exports/Image/index.js Outdated Show resolved Hide resolved
packages/react-native-web/src/exports/Image/index.js Outdated Show resolved Hide resolved
Comment on lines 363 to 383
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 });
}
});

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm since this logic is the real meat & potatoes of the ImageWithHeaders component, if we move it to ImageLoader.loadUsingHeaders I assume we could get rid of this ImageWithHeaders component, and basically check if there's headers passed in the props - if yes, we call ImageLoader.loadUsingHeaders instead of ImageLoader.load here?

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();
}
}
);
}

If that's what you're thinking, I honestly do think that logic is cleaner, even without needing to write tests for Expensify's case so I'd say the refactor sounds like a nice idea

Move header loading logic here
@kidroca kidroca force-pushed the image-loader-headers-alt branch 2 times, most recently from 51f7e9a to 739c02b Compare January 24, 2023 08:40
Copy link
Author

@kidroca kidroca left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Moved logic to ImageLoader.loadUsingHeaders

✅ PR ready for review

And here's an alternative version where we try to use a single Image component instead of creating BaseImage and ImageWithHeaders: https://github.com/Expensify/react-native-web/pull/13/files

cc @roryabraham

Comment on lines +356 to +360
const request = React.useRef<LoadRequest>({
cancel: () => {},
source: { uri: '', headers: {} },
promise: Promise.resolve('')
});
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ImageLoader.loadUsingHeaders returns a request with reference to the last loaded source, and a cleanup function. We no longer need to capture lastLoadedSource and cleanup refs

Beamanator
Beamanator previously approved these changes Jan 24, 2023
Copy link

@Beamanator Beamanator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kidroca thanks for much for keeping this PR around while also showing what #13 would look like - to be honest I am now thinking the ImageLoader changes look much simpler in this PR, and even I like how the logic for the ImageWithHeaders is separated out, so I'm actually go back on my previous thought, and I'd say this is good to merge 👍

@Beamanator
Copy link

Beamanator commented Jan 24, 2023

@marcaaron I'll leave this unmerged for today in case you want to check out this one vs #13 - if you don't have any concerns (or if you're too busy today) I'm pretty confident we can move forward here so I'll merge tomorrow

@marcaaron
Copy link

Sounds good - I will take a look now!

Also, just wanted to clear the air on this comment. Chatted 1:1 with Alex and it's clear we had different expectations about how this PR should be led and who should do the bulk of the reviewing.

In retrospect, I should have clarified that stuff before leaving that comment. And it was wrong to imply that there were any bad intentions (definitely did not mean for it to come across that way). It also didn't need to be shared in public. Sorry Alex!

Copy link

@marcaaron marcaaron left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code looks great. Love this new direction. I only have a few style notes and requests to improve the comments in the code.

packages/react-native-web/src/exports/Image/index.js Outdated Show resolved Hide resolved
packages/react-native-web/src/exports/Image/index.js Outdated Show resolved Hide resolved

const propsToPass = {
...props,
// Omit `onLoadStart` because we trigger it in the current scope

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too sure what this comment means. Is there a different way to say this?

I think it's something like - the BaseImage onLoadStart event is not exposed to the parent. We are only interested in when the source with headers starts loading and not when the BaseImage loading starts?

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's more like: ImageWithHeaders already calls onLoadStart when it starts loading the image, so we don't want the BaseImage to trigger that function a second time

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that I look at it I see it's confusing - it's like Alex said - loading starts inside ImageWithHeaders, to prevent BaseImage to raise onLoadStart a second time we filter it out

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok cool I think we are all saying the same thing - BaseImage will not use an onLoadStart callback when we have headers.

packages/react-native-web/src/exports/Image/index.js Outdated Show resolved Hide resolved
packages/react-native-web/src/modules/ImageLoader/index.js Outdated Show resolved Hide resolved
packages/react-native-web/src/modules/ImageLoader/index.js Outdated Show resolved Hide resolved
packages/react-native-web/src/modules/ImageLoader/index.js Outdated Show resolved Hide resolved
@kidroca
Copy link
Author

kidroca commented Jan 25, 2023

I'll apply the suggestions to the PR and post back in a minute

kidroca and others added 2 commits January 25, 2023 12:15
Co-authored-by: Marc Glasser <marc.aaron.glasser@gmail.com>
@kidroca
Copy link
Author

kidroca commented Jan 25, 2023

@Beamanator @marcaaron
I've applied the suggested changes and updated / added code comments

I've also updated the PR in App Expensify/App#13036 to use the latest changes made here
I think it's looking good

Copy link

@Beamanator Beamanator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two more tiny questions on comments

@Beamanator
Copy link

Oh and one more question / possible point of confusion:

ImageWithHeaders loads an image with headers....

  • While it's loading, it passes source: undefined to BaseImage (since the blobUri hasn't been set yet and so BaseImage doesn't try to load any image yet)
  • After it loads, it passes source: { nextSource: { headers: {...} }, uri: blobUri} to BaseImage
    • From here, BaseImage treats blobUri as a normal uri and loads it like a normal image (via ImageLoader.load), firing onError and / or onLoadEnd if that errors out, right?
    • So we need to handle errors / loading ending in both BaseImage and in ImageWithHeaders because ImageWithHeaders will technically have 2 types of loading stages, each of which "can" error out and then need to call onLoadEnd?

Just making sure my understanding is correct 😅

Co-authored-by: Alex Beaman <dabeamanator@gmail.com>
@kidroca
Copy link
Author

kidroca commented Jan 26, 2023

@Beamanator

Oh and one more question / possible point of confusion:

ImageWithHeaders loads an image with headers....

  • While it's loading, it passes source: undefined to BaseImage (since the blobUri hasn't been set yet and so BaseImage doesn't try to load any image yet)

  • After it loads, it passes source: { nextSource: { headers: {...} }, uri: blobUri} to BaseImage

    • From here, BaseImage treats blobUri as a normal uri and loads it like a normal image (via ImageLoader.load), firing onError and / or onLoadEnd if that errors out, right?
    • So we need to handle errors / loading ending in both BaseImage and in ImageWithHeaders because ImageWithHeaders will technically have 2 types of loading stages, each of which "can" error out and then need to call onLoadEnd?

Just making sure my understanding is correct 😅

I think you've got it right

When we load image with headers the fetch request might fail - we call onError and onLoadEnd there
Otherwise (if fetch succeeds) onLoadEnd would be called from BaseImage
BaseImage might still raise onError if the fetched file is not an image

We only need to take care of the Promise rejection in ImageWithHeaders and raise error / loadEnd, BaseImage would take care of the rest like it already does

Copy link

@Beamanator Beamanator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for all the work on this @kidroca 👍 👍

@Beamanator Beamanator merged commit f8e6824 into Expensify:master Jan 27, 2023
@Beamanator
Copy link

Published in v0.18.11 👍

@roryabraham
Copy link

@Beamanator @kidroca can you please include a link to the upstream PR in the description to make it easier to track this change in the upstream?

@Beamanator
Copy link

@roryabraham sure thing - the upstream PR is: necolas#2442 (I added to the OP too!)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants