Skip to content

fix(image): preserve fill positioning for remote images#1265

Merged
james-elicx merged 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/remote-image-fill
May 16, 2026
Merged

fix(image): preserve fill positioning for remote images#1265
james-elicx merged 1 commit into
cloudflare:mainfrom
NathanDrake2406:nathan/remote-image-fill

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Field Detail
Goal Preserve Next.js fill layout semantics for remote next/image sources.
Core change Remote fill now renders a plain <img> with the same fill style and data-nimg="fill" contract used by local images and getImageProps().
Main boundary DOM output from the Image component SSR path.
Primary files packages/vinext/src/shims/image.tsx, tests/image-component.test.ts
Expected impact Remote fill images no longer flow as standard block/full-width images when callers expect parent-filling absolute positioning.

Why

For next/image, fill is a layout contract on the rendered <img>, not a remote-source optimization mode. Next.js applies absolute positioning and data-nimg="fill" regardless of whether the source is local or remote, and its docs tell users to position the parent because the image itself is absolute.

Area Principle / invariant What this PR changes
Remote Image rendering fill means the DOM image fills its parent. Removes the remote-only layout="fullWidth" branch for fill and emits the same absolute-positioned <img> shape as local fill.
Shared fill styling Local, loader, remote, and getImageProps() should not drift. Centralizes fill style construction in getFillStyle().
Regression coverage The public DOM output is the compatibility boundary. Adds SSR coverage for remote fill asserting no width/height attrs, absolute positioning, default sizes="100vw", and data-nimg="fill".

What changed

Scenario Before After
<Image src="https://..." fill /> Rendered through Unpic layout="fullWidth", producing style="object-fit:cover;width:100%" without absolute positioning or data-nimg="fill". Renders a plain <img> with position:absolute, inset:0, width:100%, height:100%, sizes="100vw", and data-nimg="fill".
Local fill, loader fill, getImageProps({ fill: true }) Each built fill style separately. They share getFillStyle() to keep the invariant in one place.
Maintainer review path
  1. packages/vinext/src/shims/image.tsx
    • Review getFillStyle() first.
    • Then review the remote URL fill branch and compare it with the local fill branch and getImageProps().
  2. tests/image-component.test.ts
    • Review the new SSR regression next to the existing local fill test.
Validation
  • vp test run tests/image-component.test.ts -t "renders remote fill mode with absolute positioning"
    • Verified red before the fix and green after it.
  • vp test run tests/image-component.test.ts
    • 53 tests passed.
  • vp test run tests/image-component.test.ts tests/image-imports.test.ts tests/image-optimization-parity.test.ts
    • 75 tests passed.
  • vp check
    • Formatting, lint, and type checks passed.
Risk / compatibility
  • Public API: no prop API changes.
  • Runtime behavior: remote fill uses the same plain <img> shape as local fill and getImageProps() instead of Unpic fullWidth transformation.
  • Compatibility: this aligns the fill layout contract with Next.js. Remote non-fill images still use Unpic for CDN URL transforms.
  • Existing-app risk: low. Apps relying on remote fill flowing in document layout were relying on behavior that conflicts with Next.js and the fill docs.
Non-goals
  • This does not change remote non-fill image optimization.
  • This does not add broader Next.js validation for conflicting fill styles such as overriding position, width, or height.
  • This does not change custom-loader fill sizing behavior beyond sharing the fill style object.

References

Reference Why it matters
Next.js get-img-props fill style Shows Next.js constructs absolute-positioned fill styles independently of source type.
Next.js image component data-nimg Shows data-nimg="fill" is attached to the actual <img>.
Next.js unit test for fill Captures the expected fill props shape.
Next.js image docs for fill Documents that fill expands to the parent and the image itself uses absolute positioning by default.

Remote Image with fill=true currently routes through Unpic fullWidth, which renders a flowing image without data-nimg="fill" or absolute positioning. That violates Next.js's fill invariant for parent-filling layouts.

Centralize fill styles and render remote fill with the same DOM contract as local fill and getImageProps. Add SSR regression coverage for remote fill.
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 16, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1265

commit: 1e298cc

@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 12:28
@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

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

Clean, well-scoped fix that correctly aligns remote fill rendering with Next.js semantics. The getFillStyle() extraction reduces duplication across all three fill paths (local, remote, getImageProps), and the test covers the regression well.

One nit and one note below — neither blocks merge.

inset: 0,
width: "100%",
height: "100%",
objectFit: "cover",
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 (pre-existing, not introduced by this PR): Next.js does not default objectFit to "cover" for fill images — it leaves it undefined and passes through the deprecated objectFit prop or lets the user set it via style. The "cover" default here is a deliberate vinext choice that's been around since before this PR, so it's fine to keep for now, but worth noting as a parity gap if someone reports that their fill image behaves differently from Next.js (e.g., they expect objectFit: "contain" to be the only active value, but vinext's "cover" base gets spread under it).

No action needed in this PR — just flagging for awareness.

backgroundPosition: "center",
}
: undefined;
const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined;
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: blurStyle is now constructed above (lines 462-470) and passed directly to getFillStyle() on line 487, making the bg variable unused in the fill branch. It's still used by the non-fill UnpicImage constrained path on line 506, so it's not dead code overall — but it's now only relevant to the non-fill path. Consider moving the bg computation inside the if (imgWidth && imgHeight) block to make the scoping clearer. Not blocking.

Suggested change
const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined;
const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined;

expect(html).toContain("width:100%");
expect(html).toContain("height:100%");
expect(html).toContain('data-nimg="fill"');
expect(html).toContain('sizes="100vw"');
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.

Nice coverage. One optional addition: you could also assert expect(html).toContain('decoding="async"') to fully match the local fill test's assertions and confirm the attribute is present on the remote fill path too. Minor.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary:

Approved with two nits (neither blocking):

  1. objectFit: "cover" default (image.tsx:233) — Pre-existing divergence from Next.js, which does not set a default objectFit for fill images. Not introduced by this PR, but flagged for awareness since it could cause subtle behavioral differences for users expecting objectFit to be unset.

  2. bg variable scoping (image.tsx:471) — Now that the fill branch uses blurStyle directly via getFillStyle(), the bg variable is only consumed by the non-fill UnpicImage constrained path. Suggested moving it closer to its sole consumer for clarity.

  3. Optional test assertion (image-component.test.ts:100) — Suggested adding a decoding="async" assertion to the remote fill test to match the local fill test's coverage.

The core change is correct: remote fill images now render a plain <img> with position:absolute, inset:0, width:100%, height:100%, data-nimg="fill", and sizes="100vw" — matching the same contract as local fill and getImageProps().

github run

@james-elicx james-elicx merged commit aec23e3 into cloudflare:main May 16, 2026
28 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants