Skip to content

Commit e7ff673

Browse files
authored
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
1 parent d92468a commit e7ff673

File tree

8 files changed

+98
-193
lines changed

8 files changed

+98
-193
lines changed

.changeset/small-kids-walk.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nextui-org/react-utils": patch
3+
---
4+
5+
add useIsHydrated

.changeset/tame-glasses-press.md

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@nextui-org/use-image": patch
3+
---
4+
5+
fix cached image flickering issue (#4271)

packages/hooks/use-image/__tests__/use-image.test.tsx

+7
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,11 @@ describe("use-image hook", () => {
3434
expect(result.current).toEqual("loading");
3535
await waitFor(() => expect(result.current).toBe("failed"));
3636
});
37+
38+
it("can handle cached image", async () => {
39+
mockImage.simulate("loaded");
40+
const {result} = renderHook(() => useImage({src: "/test.png"}));
41+
42+
await waitFor(() => expect(result.current).toBe("loaded"));
43+
});
3744
});

packages/hooks/use-image/package.json

+2-1
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,8 @@
3434
"postpack": "clean-package restore"
3535
},
3636
"dependencies": {
37-
"@nextui-org/use-safe-layout-effect": "workspace:*"
37+
"@nextui-org/use-safe-layout-effect": "workspace:*",
38+
"@nextui-org/react-utils": "workspace:*"
3839
},
3940
"peerDependencies": {
4041
"react": ">=18 || >=19.0.0-rc.0"

packages/hooks/use-image/src/index.ts

+44-192
Original file line numberDiff line numberDiff line change
@@ -1,161 +1,11 @@
1-
// /**
2-
// * Part of this code is taken from @chakra-ui/react package ❤️
3-
// */
4-
// import type {ImgHTMLAttributes, MutableRefObject, SyntheticEvent} from "react";
5-
6-
// import {useEffect, useRef, useState} from "react";
7-
// import {useSafeLayoutEffect} from "@nextui-org/use-safe-layout-effect";
8-
9-
// type NativeImageProps = ImgHTMLAttributes<HTMLImageElement>;
10-
11-
// export interface UseImageProps {
12-
// /**
13-
// * The image `src` attribute
14-
// */
15-
// src?: string;
16-
// /**
17-
// * The image `srcset` attribute
18-
// */
19-
// srcSet?: string;
20-
// /**
21-
// * The image `sizes` attribute
22-
// */
23-
// sizes?: string;
24-
// /**
25-
// * A callback for when the image `src` has been loaded
26-
// */
27-
// onLoad?: NativeImageProps["onLoad"];
28-
// /**
29-
// * A callback for when there was an error loading the image `src`
30-
// */
31-
// onError?: NativeImageProps["onError"];
32-
// /**
33-
// * If `true`, opt out of the `fallbackSrc` logic and use as `img`
34-
// */
35-
// ignoreFallback?: boolean;
36-
// /**
37-
// * The key used to set the crossOrigin on the HTMLImageElement into which the image will be loaded.
38-
// * This tells the browser to request cross-origin access when trying to download the image data.
39-
// */
40-
// crossOrigin?: NativeImageProps["crossOrigin"];
41-
// loading?: NativeImageProps["loading"];
42-
// }
43-
44-
// type Status = "loading" | "failed" | "pending" | "loaded";
45-
46-
// export type FallbackStrategy = "onError" | "beforeLoadOrError";
47-
48-
// type ImageEvent = SyntheticEvent<HTMLImageElement, Event>;
49-
50-
// /**
51-
// * React hook that loads an image in the browser,
52-
// * and lets us know the `status` so we can show image
53-
// * fallback if it is still `pending`
54-
// *
55-
// * @returns the status of the image loading progress
56-
// *
57-
// * @example
58-
// *
59-
// * ```jsx
60-
// * function App(){
61-
// * const status = useImage({ src: "image.png" })
62-
// * return status === "loaded" ? <img src="image.png" /> : <Placeholder />
63-
// * }
64-
// * ```
65-
// */
66-
// export function useImage(props: UseImageProps = {}) {
67-
// const {loading, src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback} = props;
68-
69-
// const imageRef = useRef<HTMLImageElement | null>();
70-
// const firstMount = useRef<boolean>(true);
71-
// const [status, setStatus] = useState<Status>(() => setImageAndGetInitialStatus(props, imageRef));
72-
73-
// useSafeLayoutEffect(() => {
74-
// if (firstMount.current) {
75-
// firstMount.current = false;
76-
77-
// return;
78-
// }
79-
80-
// setStatus(setImageAndGetInitialStatus(props, imageRef));
81-
82-
// return () => {
83-
// flush();
84-
// };
85-
// }, [src, crossOrigin, srcSet, sizes, loading]);
86-
87-
// useEffect(() => {
88-
// if (!imageRef.current) return;
89-
// imageRef.current.onload = (event) => {
90-
// flush();
91-
// setStatus("loaded");
92-
// onLoad?.(event as unknown as ImageEvent);
93-
// };
94-
// imageRef.current.onerror = (error) => {
95-
// flush();
96-
// setStatus("failed");
97-
// onError?.(error as any);
98-
// };
99-
// }, [imageRef.current]);
100-
101-
// const flush = () => {
102-
// if (imageRef.current) {
103-
// imageRef.current.onload = null;
104-
// imageRef.current.onerror = null;
105-
// imageRef.current = null;
106-
// }
107-
// };
108-
109-
// /**
110-
// * If user opts out of the fallback/placeholder
111-
// * logic, let's just return 'loaded'
112-
// */
113-
// return ignoreFallback ? "loaded" : status;
114-
// }
115-
116-
// function setImageAndGetInitialStatus(
117-
// props: UseImageProps,
118-
// imageRef: MutableRefObject<HTMLImageElement | null | undefined>,
119-
// ): Status {
120-
// const {loading, src, srcSet, crossOrigin, sizes, ignoreFallback} = props;
121-
122-
// if (!src) return "pending";
123-
// if (ignoreFallback) return "loaded";
124-
125-
// try {
126-
// const img = new Image();
127-
128-
// img.src = src;
129-
// if (crossOrigin) img.crossOrigin = crossOrigin;
130-
// if (srcSet) img.srcset = srcSet;
131-
// if (sizes) img.sizes = sizes;
132-
// if (loading) img.loading = loading;
133-
134-
// imageRef.current = img;
135-
// if (img.complete && img.naturalWidth) {
136-
// return "loaded";
137-
// }
138-
139-
// return "loading";
140-
// } catch (error) {
141-
// return "loading";
142-
// }
143-
// }
144-
145-
// export const shouldShowFallbackImage = (status: Status, fallbackStrategy: FallbackStrategy) =>
146-
// (status !== "loaded" && fallbackStrategy === "beforeLoadOrError") ||
147-
// (status === "failed" && fallbackStrategy === "onError");
148-
149-
// export type UseImageReturn = ReturnType<typeof useImage>;
150-
1511
/**
1522
* Part of this code is taken from @chakra-ui/react package ❤️
1533
*/
1544

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

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

16010
type NativeImageProps = ImgHTMLAttributes<HTMLImageElement>;
16111

@@ -215,42 +65,29 @@ type ImageEvent = SyntheticEvent<HTMLImageElement, Event>;
21565
*/
21666

21767
export function useImage(props: UseImageProps = {}) {
218-
const {loading, src, srcSet, onLoad, onError, crossOrigin, sizes, ignoreFallback} = props;
219-
220-
const [status, setStatus] = useState<Status>("pending");
221-
222-
useEffect(() => {
223-
setStatus(src ? "loading" : "pending");
224-
}, [src]);
225-
226-
const imageRef = useRef<HTMLImageElement | null>();
227-
228-
const load = useCallback(() => {
229-
if (!src) return;
68+
const {onLoad, onError, ignoreFallback} = props;
23069

231-
flush();
70+
const isHydrated = useIsHydrated();
23271

233-
const img = new Image();
72+
const imageRef = useRef<HTMLImageElement | null>(isHydrated ? new Image() : null);
23473

235-
img.src = src;
236-
if (crossOrigin) img.crossOrigin = crossOrigin;
237-
if (srcSet) img.srcset = srcSet;
238-
if (sizes) img.sizes = sizes;
239-
if (loading) img.loading = loading;
74+
const [status, setStatus] = useState<Status>(() =>
75+
isHydrated ? setImageAndGetInitialStatus(props, imageRef) : "pending",
76+
);
24077

241-
img.onload = (event) => {
78+
useEffect(() => {
79+
if (!imageRef.current) return;
80+
imageRef.current.onload = (event) => {
24281
flush();
24382
setStatus("loaded");
24483
onLoad?.(event as unknown as ImageEvent);
24584
};
246-
img.onerror = (error) => {
85+
imageRef.current.onerror = (error) => {
24786
flush();
24887
setStatus("failed");
24988
onError?.(error as any);
25089
};
251-
252-
imageRef.current = img;
253-
}, [src, crossOrigin, srcSet, sizes, onLoad, onError, loading]);
90+
}, [imageRef.current]);
25491

25592
const flush = () => {
25693
if (imageRef.current) {
@@ -260,25 +97,40 @@ export function useImage(props: UseImageProps = {}) {
26097
}
26198
};
26299

263-
useSafeLayoutEffect(() => {
264-
/**
265-
* If user opts out of the fallback/placeholder
266-
* logic, let's bail out.
267-
*/
268-
if (ignoreFallback) return undefined;
269-
270-
if (status === "loading") {
271-
load();
272-
}
273-
274-
return () => {
275-
flush();
276-
};
277-
}, [status, load, ignoreFallback]);
278-
279100
/**
280101
* If user opts out of the fallback/placeholder
281102
* logic, let's just return 'loaded'
282103
*/
283104
return ignoreFallback ? "loaded" : status;
284105
}
106+
107+
function setImageAndGetInitialStatus(
108+
props: UseImageProps,
109+
imageRef: MutableRefObject<HTMLImageElement | null | undefined>,
110+
): Status {
111+
const {loading, src, srcSet, crossOrigin, sizes, ignoreFallback} = props;
112+
113+
if (!src) return "pending";
114+
if (ignoreFallback) return "loaded";
115+
116+
const img = new Image();
117+
118+
img.src = src;
119+
if (crossOrigin) img.crossOrigin = crossOrigin;
120+
if (srcSet) img.srcset = srcSet;
121+
if (sizes) img.sizes = sizes;
122+
if (loading) img.loading = loading;
123+
124+
imageRef.current = img;
125+
if (img.complete && img.naturalWidth) {
126+
return "loaded";
127+
}
128+
129+
return "loading";
130+
}
131+
132+
export const shouldShowFallbackImage = (status: Status, fallbackStrategy: FallbackStrategy) =>
133+
(status !== "loaded" && fallbackStrategy === "beforeLoadOrError") ||
134+
(status === "failed" && fallbackStrategy === "onError");
135+
136+
export type UseImageReturn = ReturnType<typeof useImage>;

packages/utilities/react-utils/src/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,5 @@ export {
3232
renderFn,
3333
filterDOMProps,
3434
} from "@nextui-org/react-rsc-utils";
35+
36+
export {useIsHydrated} from "./use-is-hydrated";
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import * as React from "react";
2+
3+
/**
4+
* A hook that returns true if the component is mounted on the client (hydrated)
5+
* and false when rendering on the server.
6+
*
7+
* @example
8+
* ```jsx
9+
* function Component() {
10+
* const isHydrated = useIsHydrated()
11+
*
12+
* if (!isHydrated) {
13+
* return <div>Loading...</div>
14+
* }
15+
*
16+
* return <div>Client rendered content</div>
17+
* }
18+
* ```
19+
* @returns boolean indicating if the component is hydrated
20+
*/
21+
22+
export function useIsHydrated() {
23+
const subscribe = () => () => {};
24+
25+
return React.useSyncExternalStore(
26+
subscribe,
27+
() => true,
28+
() => false,
29+
);
30+
}

pnpm-lock.yaml

+3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)