mirror of
https://github.com/nextui-org/nextui.git
synced 2025-12-08 19:26:11 +00:00
fix(use-image): cached image flickering issue (#4442)
* 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
This commit is contained in:
parent
d92468aa1e
commit
e7ff6730d7
5
.changeset/small-kids-walk.md
Normal file
5
.changeset/small-kids-walk.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@nextui-org/react-utils": patch
|
||||
---
|
||||
|
||||
add useIsHydrated
|
||||
5
.changeset/tame-glasses-press.md
Normal file
5
.changeset/tame-glasses-press.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@nextui-org/use-image": patch
|
||||
---
|
||||
|
||||
fix cached image flickering issue (#4271)
|
||||
@ -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"));
|
||||
});
|
||||
});
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>;
|
||||
|
||||
@ -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 {onLoad, onError, ignoreFallback} = props;
|
||||
|
||||
const [status, setStatus] = useState<Status>("pending");
|
||||
const isHydrated = useIsHydrated();
|
||||
|
||||
const imageRef = useRef<HTMLImageElement | null>(isHydrated ? new Image() : null);
|
||||
|
||||
const [status, setStatus] = useState<Status>(() =>
|
||||
isHydrated ? setImageAndGetInitialStatus(props, imageRef) : "pending",
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setStatus(src ? "loading" : "pending");
|
||||
}, [src]);
|
||||
|
||||
const imageRef = useRef<HTMLImageElement | null>();
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!src) return;
|
||||
|
||||
flush();
|
||||
|
||||
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;
|
||||
|
||||
img.onload = (event) => {
|
||||
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<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>;
|
||||
|
||||
@ -32,3 +32,5 @@ export {
|
||||
renderFn,
|
||||
filterDOMProps,
|
||||
} from "@nextui-org/react-rsc-utils";
|
||||
|
||||
export {useIsHydrated} from "./use-is-hydrated";
|
||||
|
||||
30
packages/utilities/react-utils/src/use-is-hydrated.ts
Normal file
30
packages/utilities/react-utils/src/use-is-hydrated.ts
Normal file
@ -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
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user