From 66801c6efe3e621d806f39f86fe6778ffe5f5542 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 22:35:01 +1000 Subject: [PATCH 1/2] fix(image): clear blur placeholder after image load Blur placeholders currently stay on the img background after the high resolution image finishes loading. That is wrong for transparent image formats because the low quality placeholder remains visible through the rendered pixels. The shim treated placeholder styling as static props instead of load lifecycle state. Track blur completion per src, clear local and remote placeholder backgrounds after successful load or error, and cover the transparent-image regression in an app-router browser test. --- packages/vinext/src/shims/image.tsx | 36 ++++++++++++++++--- .../app-router/nextjs-compat/image.spec.ts | 32 +++++++++++++++++ .../image-blur-placeholder/page.tsx | 22 ++++++++++++ .../app-basic/public/transparent-image.svg | 3 ++ 4 files changed, 88 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/app-router/nextjs-compat/image.spec.ts create mode 100644 tests/fixtures/app-basic/app/nextjs-compat/image-blur-placeholder/page.tsx create mode 100644 tests/fixtures/app-basic/public/transparent-image.svg diff --git a/packages/vinext/src/shims/image.tsx b/packages/vinext/src/shims/image.tsx index 3b842e905..030aa9095 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"; @@ -343,6 +343,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) { @@ -362,6 +369,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) { @@ -386,6 +394,7 @@ const Image = forwardRef(function Image( ? (e: React.SyntheticEvent) => { if (lastLoadedSrcRef.current === src) return; lastLoadedSrcRef.current = src; + markBlurComplete(); onLoad?.(e); onLoadingComplete(e.currentTarget); } @@ -393,17 +402,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) { @@ -453,7 +476,10 @@ const Image = forwardRef(function Image( } const sanitizedBlur = imgBlurDataURL ? sanitizeBlurDataURL(imgBlurDataURL) : undefined; - const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined; + const bg = + !blurComplete && placeholder === "blur" && sanitizedBlur + ? `url(${sanitizedBlur})` + : undefined; if (fill) { return ( @@ -534,7 +560,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 @@ + + + From 99e8aacb9469a1ebf1f42555d7cbd9d0dd8fa867 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 22:42:53 +1000 Subject: [PATCH 2/2] chore: rerun ci