diff --git a/packages/vinext/src/shims/image.tsx b/packages/vinext/src/shims/image.tsx index ee6ac5352..73b4cb5bb 100644 --- a/packages/vinext/src/shims/image.tsx +++ b/packages/vinext/src/shims/image.tsx @@ -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"; @@ -358,6 +358,13 @@ const Image = forwardRef(function Image( height: imgHeight, blurDataURL: imgBlurDataURL, } = resolveImageSource({ src: srcProp, width, height, blurDataURL }); + const [completedBlurSrc, setCompletedBlurSrc] = useState(undefined); + const blurComplete = completedBlurSrc === src; + + const markBlurComplete = () => { + if (placeholder !== "blur") return; + setCompletedBlurSrc((current) => (current === src ? current : src)); + }; useNonWarningLayoutEffect(() => { if (!didInsertRef.current && imgElementRef.current !== null) { @@ -377,6 +384,7 @@ const Image = forwardRef(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) { @@ -401,6 +409,7 @@ const Image = forwardRef(function Image( ? (e: React.SyntheticEvent) => { if (lastLoadedSrcRef.current === src) return; lastLoadedSrcRef.current = src; + markBlurComplete(); onLoad?.(e); onLoadingComplete(e.currentTarget); } @@ -408,17 +417,31 @@ const Image = forwardRef(function Image( ? (e: React.SyntheticEvent) => { 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) => { 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) { @@ -459,16 +482,16 @@ const Image = forwardRef(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 ( @@ -547,7 +570,7 @@ const Image = forwardRef(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", diff --git a/tests/e2e/app-router/nextjs-compat/image.spec.ts b/tests/e2e/app-router/nextjs-compat/image.spec.ts new file mode 100644 index 000000000..40c71d1c8 --- /dev/null +++ b/tests/e2e/app-router/nextjs-compat/image.spec.ts @@ -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; + }); +}); diff --git a/tests/fixtures/app-basic/app/nextjs-compat/image-blur-placeholder/page.tsx b/tests/fixtures/app-basic/app/nextjs-compat/image-blur-placeholder/page.tsx new file mode 100644 index 000000000..26de9e674 --- /dev/null +++ b/tests/fixtures/app-basic/app/nextjs-compat/image-blur-placeholder/page.tsx @@ -0,0 +1,22 @@ +import Image from "next/image"; + +const blurDataURL = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lw0K5QAAAABJRU5ErkJggg=="; + +export default function ImageBlurPlaceholderPage() { + return ( +
+

Image Blur Placeholder

+ transparent image +
+ ); +} diff --git a/tests/fixtures/app-basic/public/transparent-image.svg b/tests/fixtures/app-basic/public/transparent-image.svg new file mode 100644 index 000000000..4c552c20c --- /dev/null +++ b/tests/fixtures/app-basic/public/transparent-image.svg @@ -0,0 +1,3 @@ + + +