From d4ac175b69c3a9a9231131e899a78c7af0886226 Mon Sep 17 00:00:00 2001 From: Nathan Nguyen <146415969+NathanDrake2406@users.noreply.github.com> Date: Sat, 16 May 2026 22:32:31 +1000 Subject: [PATCH] fix(image): emit preload hints for priority images Priority images currently only render eager loading and high fetch priority. That misses the resource hint Next.js emits, so the browser cannot discover the optimized image until it reaches the img element. The image shim now calls ReactDOM.preload with the final optimized image URL, srcset, sizes, and fetch priority metadata for priority and preload images. Focused image component coverage asserts the SSR preload hint and the modern preload prop behaviour. --- packages/vinext/src/shims/image.tsx | 70 +++++++++++++++++++++++++---- tests/image-component.test.ts | 27 ++++++++++- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/packages/vinext/src/shims/image.tsx b/packages/vinext/src/shims/image.tsx index 3b842e905..86f55c4dc 100644 --- a/packages/vinext/src/shims/image.tsx +++ b/packages/vinext/src/shims/image.tsx @@ -13,6 +13,7 @@ * in production and warn in development, matching Next.js behavior. */ import React, { forwardRef, useEffect, useLayoutEffect, useRef } from "react"; +import * as ReactDOM from "react-dom"; import { Image as UnpicImage } from "@unpic/react"; import { hasRemoteMatch, isPrivateIp, type RemotePattern } from "./image-config.js"; import { useMergedRef } from "./use-merged-ref.js"; @@ -167,6 +168,7 @@ type ImageProps = { width?: number; height?: number; fill?: boolean; + preload?: boolean; priority?: boolean; quality?: number; placeholder?: "blur" | "empty"; @@ -257,6 +259,23 @@ export function imageOptimizationUrl(src: string, width: number, quality: number return `/_vinext/image?url=${encodeURIComponent(src)}&w=${width}&q=${quality}`; } +function preloadImageResource(input: { + shouldPreload: boolean; + src: string; + srcSet?: string; + sizes?: string; + fetchPriority?: ReactDOM.PreloadOptions["fetchPriority"]; +}): void { + if (!input.shouldPreload) return; + if (typeof ReactDOM.preload !== "function") return; + ReactDOM.preload(input.src, { + as: "image", + imageSrcSet: input.srcSet, + imageSizes: input.sizes, + fetchPriority: input.fetchPriority, + }); +} + /** * Generate a srcSet string for responsive images. * @@ -278,6 +297,7 @@ const Image = forwardRef(function Image( width, height, fill, + preload, priority, quality, placeholder, @@ -343,6 +363,9 @@ const Image = forwardRef(function Image( height: imgHeight, blurDataURL: imgBlurDataURL, } = resolveImageSource({ src: srcProp, width, height, blurDataURL }); + const shouldPreload = preload === true || priority === true; + const priorityFetchPriority = priority ? "high" : undefined; + const imageLoading = priority ? "eager" : shouldPreload ? loading : (loading ?? "lazy"); useNonWarningLayoutEffect(() => { if (!didInsertRef.current && imgElementRef.current !== null) { @@ -408,6 +431,12 @@ const Image = forwardRef(function Image( // If a custom loader is provided, use basic img with loader URL if (loader) { const resolvedSrc = loader({ src, width: imgWidth ?? 0, quality: quality ?? 75 }); + preloadImageResource({ + shouldPreload, + src: resolvedSrc, + sizes, + fetchPriority: priorityFetchPriority, + }); return ( (function Image( alt={alt} width={fill ? undefined : imgWidth} height={fill ? undefined : imgHeight} - loading={priority ? "eager" : (loading ?? "lazy")} + loading={imageLoading} decoding="async" sizes={sizes} className={className} @@ -456,6 +485,12 @@ const Image = forwardRef(function Image( const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined; if (fill) { + preloadImageResource({ + shouldPreload, + src, + sizes, + fetchPriority: priorityFetchPriority, + }); return ( (function Image( // it is never forwarded to the DOM as a non-boolean attribute, which // would trigger React's "Received `true` for a non-boolean attribute" // warning. - loading={priority ? "eager" : (loading ?? "lazy")} - fetchPriority={priority ? "high" : undefined} + loading={imageLoading} + fetchPriority={priorityFetchPriority} sizes={sizes} className={className} background={bg} @@ -478,6 +513,12 @@ const Image = forwardRef(function Image( } // constrained layout requires width+height or aspectRatio if (imgWidth && imgHeight) { + preloadImageResource({ + shouldPreload, + src, + sizes, + fetchPriority: priorityFetchPriority, + }); return ( (function Image( height={imgHeight} layout="constrained" // Same translation as above — never pass `priority` to the DOM. - loading={priority ? "eager" : (loading ?? "lazy")} - fetchPriority={priority ? "high" : undefined} + loading={imageLoading} + fetchPriority={priorityFetchPriority} sizes={sizes} className={className} background={bg} @@ -543,6 +584,15 @@ const Image = forwardRef(function Image( } : undefined; + const imageSizes = sizes ?? (fill ? "100vw" : undefined); + preloadImageResource({ + shouldPreload, + src: optimizedSrc, + srcSet, + sizes: imageSizes, + fetchPriority: priorityFetchPriority, + }); + // For local images, render a standard tag with srcSet and blur support. // The src and srcSet point to the /_vinext/image optimization endpoint. return ( @@ -552,11 +602,11 @@ const Image = forwardRef(function Image( alt={alt} width={fill ? undefined : imgWidth} height={fill ? undefined : imgHeight} - loading={priority ? "eager" : (loading ?? "lazy")} - fetchPriority={priority ? "high" : undefined} + loading={imageLoading} + fetchPriority={priorityFetchPriority} decoding="async" srcSet={srcSet} - sizes={sizes ?? (fill ? "100vw" : undefined)} + sizes={imageSizes} className={className} data-nimg={fill ? "fill" : "1"} onLoad={handleLoad} @@ -592,6 +642,7 @@ export function getImageProps(props: ImageProps): { width, height, fill, + preload: _preload, priority, quality: _quality, placeholder, @@ -614,6 +665,7 @@ export function getImageProps(props: ImageProps): { height: imgHeight, blurDataURL: imgBlurDataURL, } = resolveImageSource({ src: srcProp, width, height, blurDataURL: blurDataURLProp }); + const shouldPreload = _preload === true || priority === true; // Validate remote URLs against configured patterns let blockedInProd = false; @@ -677,7 +729,7 @@ export function getImageProps(props: ImageProps): { alt, width: fill ? undefined : imgWidth, height: fill ? undefined : imgHeight, - loading: priority ? "eager" : (loading ?? "lazy"), + loading: priority ? "eager" : shouldPreload ? loading : (loading ?? "lazy"), fetchPriority: priority ? ("high" as const) : undefined, decoding: "async" as const, srcSet, diff --git a/tests/image-component.test.ts b/tests/image-component.test.ts index a9c963bd5..c8cde898a 100644 --- a/tests/image-component.test.ts +++ b/tests/image-component.test.ts @@ -44,7 +44,7 @@ describe("Image SSR rendering", () => { expect(html).toContain('data-nimg="1"'); }); - it("renders with priority (eager loading + fetchpriority)", () => { + it("renders with priority (preload + eager loading + fetchpriority)", () => { const html = ReactDOMServer.renderToString( React.createElement(Image, { alt: "priority image", @@ -54,11 +54,36 @@ describe("Image SSR rendering", () => { priority: true, }), ); + // Ported from Next.js: + // .nextjs-ref/test/e2e/next-image-new/app-dir/app-dir-static.test.ts + // .nextjs-ref/packages/next/src/client/image-component.tsx + expect(html).toContain(' { + const html = ReactDOMServer.renderToString( + React.createElement(Image, { + alt: "preloaded image", + src: "/hero-preload.png", + width: 800, + height: 600, + preload: true, + }), + ); + expect(html).toContain(' { const html = ReactDOMServer.renderToString( React.createElement(Image, {