diff --git a/.changeset/small-kids-walk.md b/.changeset/small-kids-walk.md new file mode 100644 index 0000000000..dc5af16148 --- /dev/null +++ b/.changeset/small-kids-walk.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/react-utils": patch +--- + +add useIsHydrated diff --git a/.changeset/tame-glasses-press.md b/.changeset/tame-glasses-press.md new file mode 100644 index 0000000000..cc073f100f --- /dev/null +++ b/.changeset/tame-glasses-press.md @@ -0,0 +1,5 @@ +--- +"@nextui-org/use-image": patch +--- + +fix cached image flickering issue (#4271) diff --git a/packages/hooks/use-image/__tests__/use-image.test.tsx b/packages/hooks/use-image/__tests__/use-image.test.tsx index f69de371f6..043fbd2507 100644 --- a/packages/hooks/use-image/__tests__/use-image.test.tsx +++ b/packages/hooks/use-image/__tests__/use-image.test.tsx @@ -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")); + }); }); diff --git a/packages/hooks/use-image/package.json b/packages/hooks/use-image/package.json index f881cad622..4d1d5b8945 100644 --- a/packages/hooks/use-image/package.json +++ b/packages/hooks/use-image/package.json @@ -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" diff --git a/packages/hooks/use-image/src/index.ts b/packages/hooks/use-image/src/index.ts index a935d00ab3..860bc1b7f5 100644 --- a/packages/hooks/use-image/src/index.ts +++ b/packages/hooks/use-image/src/index.ts @@ -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; - -// 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; - -// /** -// * 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" ? : -// * } -// * ``` -// */ -// export function useImage(props: UseImageProps = {}) { -// const {loading, src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback} = props; - -// const imageRef = useRef(); -// const firstMount = useRef(true); -// const [status, setStatus] = useState(() => 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, -// ): 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; - /** * 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; @@ -215,42 +65,29 @@ type ImageEvent = SyntheticEvent; */ export function useImage(props: UseImageProps = {}) { - const {loading, src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback} = props; - - const [status, setStatus] = useState("pending"); - - useEffect(() => { - setStatus(src ? "loading" : "pending"); - }, [src]); - - const imageRef = useRef(); - - const load = useCallback(() => { - if (!src) return; + const {onLoad, onError, ignoreFallback} = props; - flush(); + const isHydrated = useIsHydrated(); - const img = new Image(); + const imageRef = useRef(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(() => + 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) { @@ -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, +): 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; diff --git a/packages/utilities/react-utils/src/index.ts b/packages/utilities/react-utils/src/index.ts index d866bfaa40..9a64ef1821 100644 --- a/packages/utilities/react-utils/src/index.ts +++ b/packages/utilities/react-utils/src/index.ts @@ -32,3 +32,5 @@ export { renderFn, filterDOMProps, } from "@nextui-org/react-rsc-utils"; + +export {useIsHydrated} from "./use-is-hydrated"; diff --git a/packages/utilities/react-utils/src/use-is-hydrated.ts b/packages/utilities/react-utils/src/use-is-hydrated.ts new file mode 100644 index 0000000000..86a38cafd2 --- /dev/null +++ b/packages/utilities/react-utils/src/use-is-hydrated.ts @@ -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
Loading...
+ * } + * + * return
Client rendered content
+ * } + * ``` + * @returns boolean indicating if the component is hydrated + */ + +export function useIsHydrated() { + const subscribe = () => () => {}; + + return React.useSyncExternalStore( + subscribe, + () => true, + () => false, + ); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0d04cfe078..abd3c087f5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3621,6 +3621,9 @@ importers: packages/hooks/use-image: dependencies: + '@nextui-org/react-utils': + specifier: workspace:* + version: link:../../utilities/react-utils '@nextui-org/use-safe-layout-effect': specifier: workspace:* version: link:../use-safe-layout-effect