Skip to content
Open
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
77 changes: 67 additions & 10 deletions packages/vinext/src/shims/image.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -167,6 +168,7 @@ type ImageProps = {
width?: number;
height?: number;
fill?: boolean;
preload?: boolean;
priority?: boolean;
quality?: number;
placeholder?: "blur" | "empty";
Expand Down Expand Up @@ -272,6 +274,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;
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: The typeof ReactDOM.preload !== 'function' guard is good, but in Next.js the ImagePreload component also passes crossOrigin and referrerPolicy to the preload options (since those affect CORS and the browser's preload matching). vinext's Image component doesn't accept these props today, so nothing to fix here, but worth noting as a follow-up gap — if crossOrigin or referrerPolicy support is added to the Image component later, the preload call should be updated to pass them too.

ReactDOM.preload(input.src, {
as: "image",
imageSrcSet: input.srcSet,
imageSizes: input.sizes,
fetchPriority: input.fetchPriority,
});
}

/**
* Generate a srcSet string for responsive images.
*
Expand All @@ -293,6 +312,7 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
width,
height,
fill,
preload,
priority,
quality,
placeholder,
Expand Down Expand Up @@ -358,6 +378,9 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(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) {
Expand Down Expand Up @@ -423,14 +446,20 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(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 (
<img
ref={mergedRef}
src={resolvedSrc}
alt={alt}
width={fill ? undefined : imgWidth}
height={fill ? undefined : imgHeight}
loading={priority ? "eager" : (loading ?? "lazy")}
loading={imageLoading}
decoding="async"
sizes={sizes}
className={className}
Expand Down Expand Up @@ -471,15 +500,26 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined;

if (fill) {
const fillSizes = sizes ?? "100vw";
preloadImageResource({
shouldPreload,
src,
sizes: fillSizes,
fetchPriority: priorityFetchPriority,
});
return (
<img
ref={mergedRef}
src={src}
alt={alt}
loading={priority ? "eager" : (loading ?? "lazy")}
fetchPriority={priority ? "high" : undefined}
// `priority` is a Next.js concept — translate it to HTML attributes so
// 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={imageLoading}
fetchPriority={priorityFetchPriority}
decoding="async"
sizes={sizes ?? "100vw"}
sizes={fillSizes}
className={className}
data-nimg="fill"
onLoad={handleLoad}
Expand All @@ -491,6 +531,12 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
}
// constrained layout requires width+height or aspectRatio
if (imgWidth && imgHeight) {
preloadImageResource({
shouldPreload,
src,
sizes,
fetchPriority: priorityFetchPriority,
});
return (
<UnpicImage
src={src}
Expand All @@ -499,8 +545,8 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(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}
Expand Down Expand Up @@ -556,6 +602,15 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(function Image(
}
: undefined;

const imageSizes = sizes ?? (fill ? "100vw" : undefined);
preloadImageResource({
shouldPreload,
src: optimizedSrc,
srcSet,
sizes: imageSizes,
fetchPriority: priorityFetchPriority,
});

// For local images, render a standard <img> tag with srcSet and blur support.
// The src and srcSet point to the /_vinext/image optimization endpoint.
return (
Expand All @@ -565,11 +620,11 @@ const Image = forwardRef<HTMLImageElement, ImageProps>(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}
Expand All @@ -593,6 +648,7 @@ export function getImageProps(props: ImageProps): {
width,
height,
fill,
preload: _preload,
priority,
quality: _quality,
placeholder,
Expand All @@ -615,6 +671,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;
Expand Down Expand Up @@ -678,7 +735,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,
Expand Down
27 changes: 26 additions & 1 deletion tests/image-component.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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('<link rel="preload"');
expect(html).toContain('as="image"');
expect(html).toContain('fetchPriority="high"');
expect(html).toContain(`imageSrcSet="${optUrlHtml("/hero.png", 640)} 640w`);
expect(html).not.toContain(`href="${optUrlHtml("/hero.png", 800)}"`);
expect(html).toContain('loading="eager"');
expect(html).toContain('fetchPriority="high"');
expect(html).not.toContain('loading="lazy"');
});

it("renders an image preload for the modern preload prop", () => {
const html = ReactDOMServer.renderToString(
React.createElement(Image, {
alt: "preloaded image",
src: "/hero-preload.png",
width: 800,
height: 600,
preload: true,
}),
);
expect(html).toContain('<link rel="preload"');
expect(html).toContain('as="image"');
expect(html).toContain(`imageSrcSet="${optUrlHtml("/hero-preload.png", 640)} 640w`);
expect(html).not.toContain('loading="lazy"');
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: This assertion confirms loading="lazy" is absent, which is correct (when preload=true without explicit loading, both Next.js and vinext omit the loading attribute entirely). Consider also asserting the positive case — that loading is not rendered at all — to make the intent clearer and catch regressions where someone might accidentally default to loading="eager" for preload-only images:

Suggested change
expect(html).not.toContain('loading="lazy"');
expect(html).not.toContain('loading="lazy"');
expect(html).not.toContain('loading="eager"');

expect(html).not.toContain('fetchPriority="high"');
});

it("renders fill mode with absolute positioning", () => {
const html = ReactDOMServer.renderToString(
React.createElement(Image, {
Expand Down