facebook / react

The library for web and native user interfaces.
https://react.dev
MIT License
228.05k stars 46.54k forks source link

Events before Client Side Hydration #15446

Open aralroca opened 5 years ago

aralroca commented 5 years ago

Recently I detected a problem using React in SSR. Maybe is quite obvious, but all the JavaScript events that are fired before the JavaScript is loaded, are lost...

This is something normal. The JS is not there yet... Ok. However, I expect to have some utility to do some exceptions.

I'm going to try to explain my case:

I'm using an image, and in the event onError, I want to change the src to render a fallback image.

function Img(props) {
  return (
    <img {...props} onError={e => { e.target.src = fallbackSrc } } />
  )
}

Nevertheless, this code loaded from SSR, is working "sometimes"... I guess that this "sometimes" is because if the event is fired before the client side hydration. Is not catched by my JS. And the e => e.target.src = fallbackSrc is never executed. However, if the JS is loaded faster than the onError event, is catched, and is rendering the fallback image as I expected.

I want to propose some utility to do sometimes some exceptions, and render the JS inline on the first render. Perhaps, adding some extra config in ReactDOM.hydrate? I dunno...

Or maybe someone can help me providing any tip in order to fix this?

aralroca commented 5 years ago

Maybe a similar alternative as dangerouslySetInnerHTML but for attachIsolatedEvents...?

<img
 {...props} 
 attachIsolatedEvents={{ 
   onError: e => { e.target.src = fallbackSrc },
 }}
/>
zjffun commented 5 years ago

Is it helpful to use lazy load images?

zjffun commented 5 years ago

Check the image then set it to src.

aralroca commented 5 years ago

I don't have any problem with the lazy images. I'm using lazy images almost everywhere. However, in some main parts I want to load these images early as possible (in the SSR), to be rendered at the first moment.

zjffun commented 5 years ago

Uh... I don't know much about SRR, can the server side check the correctness of the image URL?

aralroca commented 5 years ago

@1010543618 I thought this as an option. But I dunno if It is possible.

kunukn commented 5 years ago

I believe you are right about what happens.

Nevertheless, this code loaded from SSR, is working "sometimes"... I guess that this "sometimes" is because if the event is fired before the client side hydration. Is not catched by my JS

From this issue https://github.com/facebook/react/issues/12641

If that content finishes loading before ReactDOM hydrates, then the native onload event fires before React has connected any listeners to the element

There seems to be a racing condition between when hydration happens and when the browser throws the error event. If the browser already has thrown the error, I am not sure how a hydration config can help. Seems complicated, but I could be wrong.

Or maybe someone can help me providing any tip in order to fix this?

You could use ReactDOM.render instead of ReactDOM.hydrate. (I am not SSR expert, I might be wrong).

Or you could avoid onError event and make your own validation

Here is a quick rough prototype, (might need adjustments, I am not a hooks expert either )


function isImageValid(src) {
  let promise = new Promise(resolve => {
    let img = document.createElement("img");
    img.onerror = () => resolve(false);
    img.onload = () => resolve(true);
    img.src = src;
  });

  return promise;
}

function Img({ src, fallbackSrc, ...rest }) {
  const imgEl = React.useRef(null);
  React.useEffect(
    () => {
      isImageValid(src).then(isValid => {
        if (!isValid) {
          imgEl.current.src = fallbackSrc;
        }
      });
    },
    [src]
  );

  return <img {...rest} ref={imgEl} src={src} />;
}

// Render usage

<Img
  alt='some image'
  src="https://dummyimage.com/600x400/000/fff"
  fallbackSrc="https://dummyimage.com/600x400/000/f00"
/>

Try removing the src property and the fallback should be applied.

aralroca commented 5 years ago

@kunukn thanks for your answer.

Currently I did a workaround to fix these two scenarios:

  1. When the image fails after hydration (onError event). Adding the fallback image after hydration.
  2. When image fails before hydration (useEffect). Adding the fallback image after hydration.

However, the second scenario for me is not ideal because is adding the fallback image after hydration, and not before. So if the image is broken, first is loading the broken status of the browser, and then the fallback image.

This is my code:

Custom hook:

export default function useFallbackImageInSSR(fallbackSrc) {
  const ref = useRef(null)

  /**
   * Error happened / catched after hydration
   */
  const onError = useCallback(
    e => { e.target.src = fallbackSrc }, [fallbackSrc],
  )

  /**
   * Error happened before hydration, but catched after hydration
   */
  useEffect(() => {
    if (ref && ref.current) {
      const { complete, naturalHeight } = ref.current
      const errorLoadingImgBeforeHydration = complete && naturalHeight === 0

      if (errorLoadingImgBeforeHydration) {
        ref.current.src = fallbackSrc
      }
    }
  }, [fallbackSrc])

  return {
    ref,
    onError,
  }
}

Usage:

const fallbackImageProps = useFallbackImageInSSR('/images/no-image.jpg')

return (
  <img 
    alt="Example"
    src="/images/non-existent-image.jpg"
    {...fallbackImageProps}
  />
)

But again. This works, but is not 100% ideal.

So I guess, that the only way to do it ideal (adding the fallback image before the hydration), is attaching the event before hydration (as exception)... Maybe I'm wrong, but is the only way that I see to fix this 😕

kunukn commented 5 years ago

Here's is one more approach/strategy.

When I see this code

function Img(props) {
  return (
    <img {...props} onError={e => { e.target.src = fallbackSrc } } />
  )
}

It looks like we want to render the fallback image as fast as possible. As the browser parses the server-side rendered html, downloads the img, founds out it can't be rendered, applies the onerror event and sets the src to a fallback image. I believe this is faster than parsing the JS, then apply the same logic.

. Here is a html version example with inline script.

<img src="imagenotfound.gif" alt="Image not found" 
  onerror="this.onerror=null;this.src='imagefound.gif';" />

This is from https://stackoverflow.com/a/9891041/815507

But you can't use the native onerror on the img element in React. The closest thing I could get to similar result, where the fallback is executed from server-side html markup is this. I used an extra div markup.

function ImageSSR({ src, fallbackSrc, alt }) {
  return (
    <div
      className="image"
      dangerouslySetInnerHTML={{
        __html: `
<img alt=${alt} src="${src}"
data-fallback=${fallbackSrc} 
onerror="this.onerror=null;this.src=this.dataset.fallback;" 
/>
  `
      }}
    />
  );
}

// Render

<ImageSSR
  src="https://dummyimage.com/200x100/000/fff&amptext=SSR"
  fallbackSrc="https://dummyimage.com/200x100/000/f00&text=fallback"
  alt="dummy image"
/>

If the src is invalid, then the fallbackSrc should kick in. I tested this with Chrome, Safari and Firefox and it worked for those.

Here's the codesandbox demo https://codesandbox.io/s/54xr4k8w4

I tried this with NextJS and confirmed the server-side rendered result.

<div class="image">
  <img alt=dummy image src="https://dummyimage.com/200x100/000/fff&text=SSR"
  data-fallback=https://dummyimage.com/200x100/000/f00&text=fallback
  onerror="this.onerror=null;this.src=this.dataset.fallback;"
  />
</div>
aralroca commented 5 years ago

@kunukn Thanks again for your answer.

Definitely I'm going to change my current workaround for your proposal. At least the fallback image is going to work as expected 🙂

Nevertheless, I miss a cleaner way to manage this. With your solution, it's hard to manage new props of ImageSSR Component, adding some extra complexity for each one. It also adds an extra div node on top.

As a workaround to fix my problem, it works for me. However, I want to propose an improvement. I think a nice solution would be something like:

<img 
 {...props}
 data-fallback={fallbackSrc}
 attachHTMLProps={{
   onerror: 'this.onerror=null;this.src=this.dataset.fallback;'
 }}
/>

My particular problem is related to the onerror event of image, but with the feature attachHTMLProps it's also going to be possible to fix similar problems without adding a node wrapper and with less complexity.

What do you think about this?

jurajkocan commented 5 years ago

that would be relly nice solution

michaelyuen commented 4 years ago

Hi @kunukn and @aralroca, we have encountered the same initial issue at my job. The discussion here is quite helpful, but I have a few clarifying questions:

  1. The demo found here: https://codesandbox.io/s/54xr4k8w4

    Question: Is the demo intended to exemplify a solution where two components (ImageSSR and ImageClientSide) would need to be used? I feel I'm lacking some context regarding the demo's context - either just demoing what a SSR vs Client Side component may look like, or, implying both are part of a final solution.


Our solution: (and question) For clarity, this solution uses one component rendered server-side and client-side.

return (
  <div dangerouslySetInnerHTML={{__html: `
    <img
      alt="${alt}"
      src="${src}"
      srcset="${srcSet}"
      onerror="this.onerror=null;this.src='${fallback}';this.srcset='${fallback}';"
    />
  `}} />
);

An aside: the notable difference is the addition of srcset, and resetting srcset in the onerror. This is necessary because of the behavior when both src and srcset are present. In our case, srcset defines a 1x so that takes precedence. Reference: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/img#attr-src

The server renders to a markup like this:

<img
  alt="Description of image"
  src="https://example.com/expected-image.png"
  srcset="[long srcset here]"
  onerror="this.onerror=null;this.src='https://example.com/fallback-image.png';this.srcset='https://example.com/fallback-image.png';"

/>

Great! Now the fallback is executed from server-side html markup. But there's one more thing...

When the fallback occurs, this code results in a console warning:

Warning: Prop dangerouslySetInnerHTML did not match. Server: [server markup] Client: [client markup]

This tripped me up, but after analysis I believe it is expected due to the scenario and can be safely ignored. The reason this warning occurs (I believe) is because the server-rendered markup's onerror is invoked before hydration (client calling hydrate), so once hydrate is invoked, the now-mutated markup (due to fallback) doesn't match the original server-rendered markup that the hydrate thinks it should expect. Thus, the warning indicates the server is different than the client, and not the other way around. Again, our conclusion is to ignore the warning, but still... Question(s):

  1. Does anyone else have the same occur? If so, how have you handled it?
  2. Does the reasoning and conclusion make sense, or is something amiss?
kunukn commented 4 years ago

Does the reasoning and conclusion make sense, or is something amiss?

Sounds correct to me.


This is the warning I saw when fiddling with NextJS. Something like this.

const fallback = 'https://source.unsplash.com/random';
const src = '';
const srcSet = '';

index.js:1 Warning: Prop dangerouslySetInnerHTML did not match.

Server: <img alt=\"random\" src=\"https://source.unsplash.com/random\" srcset=\"https://source.unsplash.com/random\" onerror=\"this.onerror=null;this.src="https://source.unsplash.com/random&quot;;this.srcset=&quot;https://source.unsplash.com/random&quot;;\">

Client: <img alt=\"random\" src=\"\" srcset=\"\" onerror=\"this.onerror=null;this.src="https://source.unsplash.com/random&quot;;this.srcset=&quot;https://source.unsplash.com/random&quot;;\">

The markup generation (HTML view source) was this:


<img
  alt='random'
  src=''
  srcset=''
  onerror='this.onerror=null;this.src="https://source.unsplash.com/random";this.srcset="https://source.unsplash.com/random";'
/>

To get around the warning, I tried to add setTimeout 100ms and the warning went away. This is probably happening because the hydration steps in before the DOM is mutated.

// Not to be used.
<div dangerouslySetInnerHTML={{
        __html: `
      <img
        alt='random'
        src='${src}'
        srcset='${srcSet}'
        onerror='this.onerror=null;setTimeout((function(){this.src="${fallback}";this.srcset="${fallback}"}).bind(this),100);'
      />
    `}} />

I would ignore the warning.

stale[bot] commented 4 years ago

This issue has been automatically marked as stale because it has not had recent activity. It will be closed if no further activity occurs. Thank you for your contribution.

andreapiras commented 4 years ago

Hi, I'm testing a solution on NextJs SSR and seems to be working well, but it is to be very different from the ones above so I was curious to know your opinions about.

const Image = ({ id, src, alt, onLoad, onError, className, withLazy }) => {
  const imageRef = useRef();
  const [isVisible] = useIsVisible(imageRef);
  const [imageSrc, setImageSrc] = useState(null);

  useEffect(() => {
    if (!withLazy) {
      setImageSrc(src);
    } else {
      if (isVisible) {
        setImageSrc(src);
      }
    }
  }, [isVisible]);

  const handleError = e => onError && onError(e);

  const handleLoad = () => onLoad && onLoad();

  return (
    <img
      id={id}
      alt={alt}
      src={imageSrc}
      className={className}
      ref={imageRef}
      onLoad={() => handleLoad()}
      onError={e => handleError(e)}
    />
  );
};

Thanks

aralroca commented 4 years ago

Hi, I'm testing a solution on NextJs SSR and seems to be working well, but it is to be very different from the ones above so I was curious to know your opinions about.

const Image = ({ id, src, alt, onLoad, onError, className, withLazy }) => {
  const imageRef = useRef();
  const [isVisible] = useIsVisible(imageRef);
  const [imageSrc, setImageSrc] = useState(null);

  useEffect(() => {
    if (!withLazy) {
      setImageSrc(src);
    } else {
      if (isVisible) {
        setImageSrc(src);
      }
    }
  }, [isVisible]);

  const handleError = e => onError && onError(e);

  const handleLoad = () => onLoad && onLoad();

  return (
    <img
      id={id}
      alt={alt}
      src={imageSrc}
      className={className}
      ref={imageRef}
      onLoad={() => handleLoad()}
      onError={e => handleError(e)}
    />
  );
};

Thanks

@andreapiras The problem that I see is that the image always is loaded after the hydration (after the useEffect). Can be a workaround, but it's interesting to find a solution to load the image earlier.

michaelyuen commented 4 years ago

@andreapiras I agree with the previous comment. To state it differently, your code appears to avoid the issue discussed here because the img src is not set until the client has kicked in anyways. It's not server-side rendering to the full extent you probably want.

Is there somewhere you're calling renderToString or renderToStaticMarkup? If you log the result of that, I'm guessing your Image renders out to something like <img alt="i am alt" /> because as previously mentioned, src won't be set until after hydration, on the client.

andreapiras commented 4 years ago

@andreapiras I agree with the previous comment. To state it differently, your code appears to avoid the issue discussed here because the img src is not set until the client has kicked in anyways. It's not server-side rendering to the full extent you probably want.

Is there somewhere you're calling renderToString or renderToStaticMarkup? If you log the result of that, I'm guessing your Image renders out to something like <img alt="i am alt" /> because as previously mentioned, src won't be set until after hydration, on the client.

Yes, you are right, I've checked the generated source and the src was empty so the solution is not valid. Thanks for the feedback guys

hkjpotato commented 4 years ago

this issue is particularly annoying when we need to fire onload metric for SSR image. The only work around for us is to write JS outside React to attach the event listener faster enough. We should provide support rendering for onload attribute in SSR.

gaearon commented 4 years ago

The only work around for us is to write JS outside React to attach the event listener faster enough.

FWIW that sounds like a reasonable solution to me. Specifically, a capture phase document-level listener for the load event should work?

document.addEventListener('load', (e) => {
 // ...
}, { capture: true })

as long as this runs before all other JS you should be able to record which ones have fired.

hkjpotato commented 4 years ago

FWIW that sounds like a reasonable solution to me. Specifically, a capture phase document-level listener for the load event should work?

document.addEventListener('load', (e) => {
 // ...
}, { capture: true })

as long as this runs before all other JS you should be able to record which ones have fired.

Are you suggesting some kind of event delegation in that single global event listener? In our case, we are not looking at a document level load event, but the first load event fired by each image element above the fold. I don't think it is very easy to do it outside element level.

Besides, our img could be SSR or CSR, and I dont want to create a separate imgSSR. Here is my current idea:

In component level, I still attach the react event listener, but I will check a flag set on the native event e.defaultPrevented (load event cannot be cancelled:disappointed_relieved:)

function Image() {
 const onLoad = (e) => {
      if (!e.nativeEvent.hasFired) fire();
 }
 return <img onload={onLoad} />
}

And only for SSR rendered element, attach an inline script before hydration

 <img  />  // result of SSR
 <script>
      root.querySelector('img').addEventListener('load', function(e) {fire(); e.hasFired = true; })
</script>

BTW, for SyntheticEvent, can you tell me the difference among e.isDefaultPrevented() vs e.defaultPrevented vs e.nativeEvent.defaultPrevented

todorpr commented 4 years ago

I had similar issue, but solved my problem a bit differently.

Waring: it won't work for you if you need the onLoad/onError event, but if you just wan't to validate that the image is loaded it's just fine:

const PerformanceItem = () => {
    const imageRef = useRef(null)
    const isSSR = useIsSSR()
    const [isPageReady, setPageReady] = useState(false)

    const maybeInformPageReady = () => () => {
      if (!isSSR && !isPageReady) {
        triggerSomeAnalytics()
        setPageReady(true)
      }
    }

    const onMainImageLoad = maybeInformPageReady()

    useEffect(() => {
      const img = imageRef.current
      if (!img || isPageReady) return

      // This is needed on first page load after SSR, because onLoad doesn't get fired,
      // but the img is supposed to be loaded on SSR, so we just validate it.
      if (img.complete) {
        onMainImageLoad()
      }
      // NOTE img.complete will be true for failed images as well,
      // so if you want to check for img failure you can do:
      if (img.complete && img.naturalWidth === 0) {
          img.src = 'some fallback url'
      }
    }, [imageRef, isPageReady, onMainImageLoad])

    return (
      <img
        src={someUrl}
        onLoad={onMainImageLoad} // won't fire after page refresh due to SSR
        onError={onMainImageLoad} // won't fire after page refresh due to SSR
        onAbort={onMainImageLoad} // won't fire after page refresh due to SSR
        ref={imageRef}
      />
    )
  }
taco-tues-on-a-fri commented 3 years ago

I solved this issue on a Next.js project using these two functions during the server side rendering stage. Live codesandbox example.

const checkImageUrl = async (imageUrl: string): Promise<boolean> => {
  try {
    const response = await axios.get(imageUrl)
    if (response.status === 200) return true
    else return false
  } 
  catch (error) {
    return false
  }
}

export const checkAndFormatImageUrlWithFallback = async (imageUrl: string, fallbackUrl: string): Promise<string> => {

  const isValidImageUrl = await checkImageUrl(imageUrl)

  return isValidImageUrl ? imageUrl : fallbackUrl
}
Emiliano-Bucci commented 3 years ago

Hi! In case this could be useful to anyone, I fix this issues with the following code:

export const Img = ({
  src = '',
  onImageLoaded,
  title,
  alt,
  ...rest
}: Props) => {
  const imageRef = useRef<HTMLImageElement | null>(null)

  function handleOnLoad() {
    if (imageRef.current?.currentSrc) {
      onImageLoaded && onImageLoaded()
    }
  }

  useLayoutEffect(() => {
    if (imageRef.current?.currentSrc) {
      preloadImageWithCallback({
        imageUrl: imageRef.current.currentSrc,
        callback: onImageLoaded,
      })
    }
  }, [onImageLoaded])

  return (
    <img
      src={src}
      onLoad={handleOnLoad}
      ref={imageRef}
      title={title}
      alt={alt}
      decoding="async"
      css={css`
        width: 100%;
        height: 100%;
        object-fit: cover;
      `}
      {...rest}
    />
  )
}

and

export async function preloadImageWithCallback({
  imageUrl,
  callback,
}: {
  imageUrl: string
  callback?(): void
}) {
  function handleCallback() {
    callback && callback()
  }

  const _image = new Image()
  _image.src = imageUrl

  if (_image.decode) {
    await _image.decode()
    handleCallback()
  } else {
    _image.onload = handleCallback
  }
}

With the use of useLayoutEffect, I handle the first render case by manually preloading the image, since the onLoad will not be triggered. The onLoad method is only used in those cases when the image is lazy loaded.

You can see a real example here -> https://wildtrek.it The images are all lazy loaded, and when the image is fully loaded and ready to be appended to the DOM, I show them.

The only thing is that I keep getting the following warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded into the server renderer's output format.

Does anyone know how to silence it?

Thanks, and hope this could be useful to anyone!

EDIT: About yow to silence it, I'm following this guide -> https://medium.com/@alexandereardon/uselayouteffect-and-ssr-192986cdcf7a

power-f-GOD commented 3 years ago

So, here's a workaround that works (for me)!

const Img: FC<
  DetailedHTMLProps<ImgHTMLAttributes<HTMLImageElement>, HTMLImageElement>
> = ({ src, ...props }): JSX.Element => {
  const [hasRendered, setHasRendered] = useState(false);
  const imgRef = useRef<HTMLImageElement | null>(null);

  useEffect(() => {
    if (imgRef.current && hasRendered) {
      imgRef.current!.src = src || '';
    }
  }, [src, hasRendered]);

  useEffect(() => {
    setHasRendered(true);
  }, []);

  return (
    <img
      {...props}
      ref={imgRef as any}
      alt={props.alt || 'image'}
      aria-hidden={true}
      onError={...}
      onLoad={...}
    />
  );
};

So, the magic happens in the two useEffect hooks. (Using just one didn't work). Basically, the second useEffect ensures the first hook is triggered (or component re-renders) a second time (after initial render), due to the hasRendered dep, which then forces the image src to be set in that hook which then triggers the events on the client!

eps1lon commented 2 years ago

There was back and forth what events were being replayed when hydrating in React 18. @gaearon is there still work planned or should we close with a summary?

komlevv commented 1 year ago

@eps1lon @gaearon any updates on this?

Freytag commented 1 year ago

There was back and forth what events were being replayed when hydrating in React 18. @gaearon is there still work planned or should we close with a summary?

@gaearon & @eps1lon. Do you know if there has been any progress on how this should be handled? I'm seeing what i believe is this issue when using React 18 components in a NextJS project that uses SSR.

The-Code-Monkey commented 6 months ago

Any updates on this im currently using an image to tell whether the client has loaded an image somewhere down the page if so it will load more data in.

MinghongGao commented 4 months ago

Any updates on this issue ?

jkaleshi14 commented 3 months ago

Any updates on this issue?