Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 37 additions & 14 deletions packages/vinext/src/shims/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
* `images.domains` from next.config.js. Unmatched URLs are blocked
* in production and warn in development, matching Next.js behavior.
*/
import React, { forwardRef, useEffect, useLayoutEffect, useRef } from "react";
import React, { forwardRef, useEffect, useLayoutEffect, useRef, useState } from "react";
import { Image as UnpicImage } from "@unpic/react";
import { hasRemoteMatch, isPrivateIp, type RemotePattern } from "./image-config.js";
import { useMergedRef } from "./use-merged-ref.js";
Expand Down Expand Up @@ -358,6 +358,13 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
height: imgHeight,
blurDataURL: imgBlurDataURL,
} = resolveImageSource({ src: srcProp, width, height, blurDataURL });
const [completedBlurSrc, setCompletedBlurSrc] = useState<string | undefined>(undefined);
const blurComplete = completedBlurSrc === src;

const markBlurComplete = () => {
if (placeholder !== "blur") return;
setCompletedBlurSrc((current) => (current === src ? current : src));
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: markBlurComplete captures placeholder and src from the render closure. If placeholder switches from "blur" to "empty" on the same src, the early return in markBlurComplete will prevent setCompletedBlurSrc from being called, but blurComplete remains true from the previous render (since completedBlurSrc === src still holds). This is fine because the blur background conditions also check placeholder === "blur", so it's a no-op either way. Just noting the invariant holds.

};

useNonWarningLayoutEffect(() => {
if (!didInsertRef.current && imgElementRef.current !== null) {
Expand All @@ -377,6 +384,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
// distinguish success from error — a failed image has naturalWidth === 0.
// Ported from Next.js: https://github.com/vercel/next.js/pull/93209
if (img.complete && img.naturalWidth > 0) {
markBlurComplete();
const currentOnLoad = onLoadRef.current;
const currentOnLoadingComplete = onLoadingCompleteRef.current;
if (currentOnLoad || currentOnLoadingComplete) {
Expand All @@ -401,24 +409,39 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
? (e: React.SyntheticEvent<HTMLImageElement>) => {
if (lastLoadedSrcRef.current === src) return;
lastLoadedSrcRef.current = src;
markBlurComplete();
onLoad?.(e);
onLoadingComplete(e.currentTarget);
}
: onLoad
? (e: React.SyntheticEvent<HTMLImageElement>) => {
if (lastLoadedSrcRef.current === src) return;
lastLoadedSrcRef.current = src;
markBlurComplete();
onLoad(e);
}
: undefined;
: placeholder === "blur"
? () => {
if (lastLoadedSrcRef.current === src) return;
lastLoadedSrcRef.current = src;
markBlurComplete();
}
: undefined;

const handleError = onError
? (e: React.SyntheticEvent<HTMLImageElement>) => {
if (lastErrorSrcRef.current === src) return;
lastErrorSrcRef.current = src;
markBlurComplete();
onError(e);
}
: undefined;
: placeholder === "blur"
? () => {
if (lastErrorSrcRef.current === src) return;
lastErrorSrcRef.current = src;
markBlurComplete();
}
: undefined;

// If a custom loader is provided, use basic img with loader URL
if (loader) {
Expand Down Expand Up @@ -459,16 +482,16 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
}

const sanitizedBlur = imgBlurDataURL ? sanitizeBlurDataURL(imgBlurDataURL) : undefined;
const blurStyle =
placeholder === "blur" && sanitizedBlur
? {
backgroundImage: `url(${sanitizedBlur})`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
}
: undefined;
const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined;
const showBlur = !blurComplete && placeholder === "blur" && sanitizedBlur;
const blurStyle = showBlur
? {
backgroundImage: `url(${sanitizedBlur})`,
backgroundSize: "cover",
backgroundRepeat: "no-repeat",
backgroundPosition: "center",
}
: undefined;
const bg = showBlur ? `url(${sanitizedBlur})` : undefined;

if (fill) {
return (
Expand Down Expand Up @@ -547,7 +570,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
// Sanitize blurDataURL to prevent CSS injection via crafted data URLs.
const sanitizedLocalBlur = imgBlurDataURL ? sanitizeBlurDataURL(imgBlurDataURL) : undefined;
const blurStyle =
placeholder === "blur" && sanitizedLocalBlur
!blurComplete && placeholder === "blur" && sanitizedLocalBlur
? {
backgroundImage: `url(${sanitizedLocalBlur})`,
backgroundSize: "cover",
Expand Down
32 changes: 32 additions & 0 deletions tests/e2e/app-router/nextjs-compat/image.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { test, expect } from "../../fixtures";
import { waitForAppRouterHydration } from "../../helpers";

test.describe("next/image", () => {
test("removes blur placeholder after a transparent image loads", async ({
page,
consoleErrors,
}) => {
// Ported from Next.js:
// test/e2e/next-image-new/app-dir/app-dir.test.ts
// https://github.com/vercel/next.js/blob/canary/test/e2e/next-image-new/app-dir/app-dir.test.ts
await page.goto("/nextjs-compat/image-blur-placeholder");
await waitForAppRouterHydration(page);

const image = page.locator("#transparent-image");

await expect
.poll(async () =>
image.evaluate(
(element) =>
element instanceof HTMLImageElement && element.complete && element.naturalWidth > 0,
),
)
.toBe(true);

await expect
.poll(async () => image.evaluate((element) => getComputedStyle(element).backgroundImage))
.toBe("none");

void consoleErrors;
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import Image from "next/image";

const blurDataURL =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lw0K5QAAAABJRU5ErkJggg==";

export default function ImageBlurPlaceholderPage() {
return (
<main>
<h1>Image Blur Placeholder</h1>
<Image
id="transparent-image"
alt="transparent image"
src="/transparent-image.svg"
width={64}
height={64}
placeholder="blur"
blurDataURL={blurDataURL}
priority
/>
</main>
);
}
3 changes: 3 additions & 0 deletions tests/fixtures/app-basic/public/transparent-image.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading