Skip to content

Commit

Permalink
Fix/avatar flashing (#3987)
Browse files Browse the repository at this point in the history
* fix(use-image): cached image flashing

* chore: merged with canary

---------

Co-authored-by: Rakha Kanz Kautsar <rkkautsar@gmail.com>
  • Loading branch information
2 people authored and ryo-manba committed Nov 6, 2024
1 parent b740e3f commit d4dc7e1
Show file tree
Hide file tree
Showing 6 changed files with 282 additions and 245 deletions.
6 changes: 6 additions & 0 deletions .changeset/pink-beans-sit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@nextui-org/use-image": patch
"@nextui-org/test-utils": patch
---

fix cached image flashing due to use-image always returning pending initially. The fix was to check if the image is loaded instantly through HTMLImageElement.complete attribute and use that to initialize the state.
44 changes: 44 additions & 0 deletions packages/hooks/use-image/__tests__/use-image.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import {renderHook} from "@testing-library/react-hooks";
import {mocks} from "@nextui-org/test-utils";

import {useImage} from "../src";

describe("use-image hook", () => {
let mockImage: {restore: any; simulate: (value: "loaded" | "error") => void};

beforeEach(() => {
mockImage = mocks.image();
});
afterEach(() => {
mockImage.restore();
});

it("can handle missing src", () => {
const rendered = renderHook(() => useImage({}));

expect(rendered.result.current).toEqual("pending");
});

it("can handle loading image", async () => {
const rendered = renderHook(() => useImage({src: "/test.png"}));

expect(rendered.result.current).toEqual("loading");
mockImage.simulate("loaded");
await rendered.waitForValueToChange(() => rendered.result.current === "loaded");
});

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

expect(rendered.result.current).toEqual("loading");
await rendered.waitForValueToChange(() => rendered.result.current === "failed");
});

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

expect(rendered.result.current).toEqual("loaded");
});
});
5 changes: 3 additions & 2 deletions packages/hooks/use-image/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@
},
"devDependencies": {
"clean-package": "2.2.0",
"react": "^18.0.0"
"react": "^18.0.0",
"@nextui-org/test-utils": "workspace:*"
},
"clean-package": "../../../clean-package.config.json",
"tsup": {
Expand All @@ -52,4 +53,4 @@
"esm"
]
}
}
}
82 changes: 44 additions & 38 deletions packages/hooks/use-image/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
/**
* Part of this code is taken from @chakra-ui/react package ❤️
*/
import type {ImgHTMLAttributes, SyntheticEvent} from "react";
import type {ImgHTMLAttributes, MutableRefObject, SyntheticEvent} from "react";

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

type NativeImageProps = ImgHTMLAttributes<HTMLImageElement>;
Expand Down Expand Up @@ -66,40 +66,37 @@ 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 firstMount = useRef<boolean>(true);
const [status, setStatus] = useState<Status>(() => setImageAndGetInitialStatus(props, imageRef));

const load = useCallback(() => {
if (!src) return;
useSafeLayoutEffect(() => {
if (firstMount.current) {
firstMount.current = false;

flush();
return;
}

const img = new Image();
setStatus(setImageAndGetInitialStatus(props, imageRef));

img.src = src;
if (crossOrigin) img.crossOrigin = crossOrigin;
if (srcSet) img.srcset = srcSet;
if (sizes) img.sizes = sizes;
if (loading) img.loading = loading;
return () => {
flush();
};
}, [src, crossOrigin, srcSet, sizes, loading]);

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 @@ -109,29 +106,38 @@ 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");
Expand Down
4 changes: 4 additions & 0 deletions packages/utilities/test-utils/src/mocks/image.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ export function mockImage() {
onerror: VoidFunction = () => {};
src = "";
alt = "";
naturalWidth = 100;
get complete() {
return status === "loaded";
}
hasAttribute(name: string) {
return name in this;
}
Expand Down
Loading

0 comments on commit d4dc7e1

Please sign in to comment.