Skip to content

Commit

Permalink
fix(use-image): cached image flickering issue (#4442)
Browse files Browse the repository at this point in the history
* feat(use-image): add test case

* feat(use-image): include `@nextui-org/react-utils`

* feat(react-utils): add useIsHydrated

* fix(use-image): cached image flickering issue

* fix(use-image): ensure status is set after hydrated

* chore(use-image): remove unneccessary code
  • Loading branch information
wingkwong authored Jan 2, 2025
1 parent d92468a commit e7ff673
Show file tree
Hide file tree
Showing 8 changed files with 98 additions and 193 deletions.
5 changes: 5 additions & 0 deletions .changeset/small-kids-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nextui-org/react-utils": patch
---

add useIsHydrated
5 changes: 5 additions & 0 deletions .changeset/tame-glasses-press.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@nextui-org/use-image": patch
---

fix cached image flickering issue (#4271)
7 changes: 7 additions & 0 deletions packages/hooks/use-image/__tests__/use-image.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,11 @@ describe("use-image hook", () => {
expect(result.current).toEqual("loading");
await waitFor(() => expect(result.current).toBe("failed"));
});

it("can handle cached image", async () => {
mockImage.simulate("loaded");
const {result} = renderHook(() => useImage({src: "/test.png"}));

await waitFor(() => expect(result.current).toBe("loaded"));
});
});
3 changes: 2 additions & 1 deletion packages/hooks/use-image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@
"postpack": "clean-package restore"
},
"dependencies": {
"@nextui-org/use-safe-layout-effect": "workspace:*"
"@nextui-org/use-safe-layout-effect": "workspace:*",
"@nextui-org/react-utils": "workspace:*"
},
"peerDependencies": {
"react": ">=18 || >=19.0.0-rc.0"
Expand Down
236 changes: 44 additions & 192 deletions packages/hooks/use-image/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,161 +1,11 @@
// /**
// * Part of this code is taken from @chakra-ui/react package ❤️
// */
// import type {ImgHTMLAttributes, MutableRefObject, SyntheticEvent} from "react";

// import {useEffect, useRef, useState} from "react";
// import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect";

// type NativeImageProps = ImgHTMLAttributes<HTMLImageElement>;

// export interface UseImageProps {
// /**
// * The image `src` attribute
// */
// src?: string;
// /**
// * The image `srcset` attribute
// */
// srcSet?: string;
// /**
// * The image `sizes` attribute
// */
// sizes?: string;
// /**
// * A callback for when the image `src` has been loaded
// */
// onLoad?: NativeImageProps["onLoad"];
// /**
// * A callback for when there was an error loading the image `src`
// */
// onError?: NativeImageProps["onError"];
// /**
// * If `true`, opt out of the `fallbackSrc` logic and use as `img`
// */
// ignoreFallback?: boolean;
// /**
// * The key used to set the crossOrigin on the HTMLImageElement into which the image will be loaded.
// * This tells the browser to request cross-origin access when trying to download the image data.
// */
// crossOrigin?: NativeImageProps["crossOrigin"];
// loading?: NativeImageProps["loading"];
// }

// type Status = "loading" | "failed" | "pending" | "loaded";

// export type FallbackStrategy = "onError" | "beforeLoadOrError";

// type ImageEvent = SyntheticEvent<HTMLImageElement, Event>;

// /**
// * React hook that loads an image in the browser,
// * and lets us know the `status` so we can show image
// * fallback if it is still `pending`
// *
// * @returns the status of the image loading progress
// *
// * @example
// *
// * ```jsx
// * function App(){
// * const status = useImage({ src: "image.png" })
// * return status === "loaded" ? <img src="image.png" /> : <Placeholder />
// * }
// * ```
// */
// export function useImage(props: UseImageProps = {}) {
// const {loading, src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback} = props;

// const imageRef = useRef<HTMLImageElement | null>();
// const firstMount = useRef<boolean>(true);
// const [status, setStatus] = useState<Status>(() => setImageAndGetInitialStatus(props, imageRef));

// useSafeLayoutEffect(() => {
// if (firstMount.current) {
// firstMount.current = false;

// return;
// }

// setStatus(setImageAndGetInitialStatus(props, imageRef));

// return () => {
// flush();
// };
// }, [src, crossOrigin, srcSet, sizes, loading]);

// useEffect(() => {
// if (!imageRef.current) return;
// imageRef.current.onload = (event) => {
// flush();
// setStatus("loaded");
// onLoad?.(event as unknown as ImageEvent);
// };
// imageRef.current.onerror = (error) => {
// flush();
// setStatus("failed");
// onError?.(error as any);
// };
// }, [imageRef.current]);

// const flush = () => {
// if (imageRef.current) {
// imageRef.current.onload = null;
// imageRef.current.onerror = null;
// imageRef.current = null;
// }
// };

// /**
// * If user opts out of the fallback/placeholder
// * logic, let's just return 'loaded'
// */
// return ignoreFallback ? "loaded" : status;
// }

// function setImageAndGetInitialStatus(
// props: UseImageProps,
// imageRef: MutableRefObject<HTMLImageElement | null | undefined>,
// ): Status {
// const {loading, src, srcSet, crossOrigin, sizes, ignoreFallback} = props;

// if (!src) return "pending";
// if (ignoreFallback) return "loaded";

// try {
// const img = new Image();

// img.src = src;
// if (crossOrigin) img.crossOrigin = crossOrigin;
// if (srcSet) img.srcset = srcSet;
// if (sizes) img.sizes = sizes;
// if (loading) img.loading = loading;

// imageRef.current = img;
// if (img.complete && img.naturalWidth) {
// return "loaded";
// }

// return "loading";
// } catch (error) {
// return "loading";
// }
// }

// export const shouldShowFallbackImage = (status: Status, fallbackStrategy: FallbackStrategy) =>
// (status !== "loaded" && fallbackStrategy === "beforeLoadOrError") ||
// (status === "failed" && fallbackStrategy === "onError");

// export type UseImageReturn = ReturnType<typeof useImage>;

/**
* Part of this code is taken from @chakra-ui/react package ❤️
*/

import type {ImgHTMLAttributes, SyntheticEvent} from "react";

import {useCallback, useEffect, useRef, useState} from "react";
import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect";
import {useRef, useState, useEffect, MutableRefObject} from "react";
import {useIsHydrated} from "@nextui-org/react-utils";

type NativeImageProps = ImgHTMLAttributes<HTMLImageElement>;

Expand Down Expand Up @@ -215,42 +65,29 @@ type ImageEvent = SyntheticEvent<HTMLImageElement, Event>;
*/

export function useImage(props: UseImageProps = {}) {
const {loading, src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback} = props;

const [status, setStatus] = useState<Status>("pending");

useEffect(() => {
setStatus(src ? "loading" : "pending");
}, [src]);

const imageRef = useRef<HTMLImageElement | null>();

const load = useCallback(() => {
if (!src) return;
const {onLoad, onError, ignoreFallback} = props;

flush();
const isHydrated = useIsHydrated();

const img = new Image();
const imageRef = useRef<HTMLImageElement | null>(isHydrated ? new Image() : null);

img.src = src;
if (crossOrigin) img.crossOrigin = crossOrigin;
if (srcSet) img.srcset = srcSet;
if (sizes) img.sizes = sizes;
if (loading) img.loading = loading;
const [status, setStatus] = useState<Status>(() =>
isHydrated ? setImageAndGetInitialStatus(props, imageRef) : "pending",
);

img.onload = (event) => {
useEffect(() => {
if (!imageRef.current) return;
imageRef.current.onload = (event) => {
flush();
setStatus("loaded");
onLoad?.(event as unknown as ImageEvent);
};
img.onerror = (error) => {
imageRef.current.onerror = (error) => {
flush();
setStatus("failed");
onError?.(error as any);
};

imageRef.current = img;
}, [src, crossOrigin, srcSet, sizes, onLoad, onError, loading]);
}, [imageRef.current]);

const flush = () => {
if (imageRef.current) {
Expand All @@ -260,25 +97,40 @@ export function useImage(props: UseImageProps = {}) {
}
};

useSafeLayoutEffect(() => {
/**
* If user opts out of the fallback/placeholder
* logic, let's bail out.
*/
if (ignoreFallback) return undefined;

if (status === "loading") {
load();
}

return () => {
flush();
};
}, [status, load, ignoreFallback]);

/**
* If user opts out of the fallback/placeholder
* logic, let's just return 'loaded'
*/
return ignoreFallback ? "loaded" : status;
}

function setImageAndGetInitialStatus(
props: UseImageProps,
imageRef: MutableRefObject<HTMLImageElement | null | undefined>,
): Status {
const {loading, src, srcSet, crossOrigin, sizes, ignoreFallback} = props;

if (!src) return "pending";
if (ignoreFallback) return "loaded";

const img = new Image();

img.src = src;
if (crossOrigin) img.crossOrigin = crossOrigin;
if (srcSet) img.srcset = srcSet;
if (sizes) img.sizes = sizes;
if (loading) img.loading = loading;

imageRef.current = img;
if (img.complete && img.naturalWidth) {
return "loaded";
}

return "loading";
}

export const shouldShowFallbackImage = (status: Status, fallbackStrategy: FallbackStrategy) =>
(status !== "loaded" && fallbackStrategy === "beforeLoadOrError") ||
(status === "failed" && fallbackStrategy === "onError");

export type UseImageReturn = ReturnType<typeof useImage>;
2 changes: 2 additions & 0 deletions packages/utilities/react-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ export {
renderFn,
filterDOMProps,
} from "@nextui-org/react-rsc-utils";

export {useIsHydrated} from "./use-is-hydrated";
30 changes: 30 additions & 0 deletions packages/utilities/react-utils/src/use-is-hydrated.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import * as React from "react";

/**
* A hook that returns true if the component is mounted on the client (hydrated)
* and false when rendering on the server.
*
* @example
* ```jsx
* function Component() {
* const isHydrated = useIsHydrated()
*
* if (!isHydrated) {
* return <div>Loading...</div>
* }
*
* return <div>Client rendered content</div>
* }
* ```
* @returns boolean indicating if the component is hydrated
*/

export function useIsHydrated() {
const subscribe = () => () => {};

return React.useSyncExternalStore(
subscribe,
() => true,
() => false,
);
}
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit e7ff673

Please sign in to comment.