Skip to content

feat(image): support images.loaderFile and emit srcSet from custom loaders - #2700

Open
NathanDrake2406 wants to merge 10 commits into
cloudflare:mainfrom
NathanDrake2406:feat/image-custom-loader
Open

feat(image): support images.loaderFile and emit srcSet from custom loaders#2700
NathanDrake2406 wants to merge 10 commits into
cloudflare:mainfrom
NathanDrake2406:feat/image-custom-loader

Conversation

@NathanDrake2406

@NathanDrake2406 NathanDrake2406 commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Overview

Goal Make images.loaderFile / images.loader work, and make custom loaders emit a real srcSet
Core change Generate a virtual:vinext-image-loader module from next.config, and port Next's getWidths / generateImgAttrs into the shim
Key boundary The shim owns loader selection; the plugin owns resolving the user's loader module at config time
Expected impact Projects using a CDN loader get optimized, responsive images instead of a bare <img>; misconfiguration now fails loudly

Closes #2699.

Why

vinext does not wrap Next's <Image>. public-shim-map.json replaces next/image wholesale with shims/image.tsx. That means none of Next's build-time image machinery carries over automatically, and a next.config key only works if the shim re-implements it.

Upstream implements loaderFile as a bundler alias: create-compiler-aliases.ts swaps next/dist/shared/lib/image-loader for the user's file. There is no such module left to alias here, so the option was parsed and then dropped. A remote fill image landed in the plain-<img> branch and rendered <img src="{original}" data-nimg="fill"> with no optimization at all.

The second defect is independent of config. The loader prop path invoked the loader exactly once, as loader({ src, width: imgWidth ?? 0, quality }). A fill image has no width, so imgWidth is undefined and the ?? 0 converted "unknown width" into a concrete but wrong 0, which flowed straight into user loader code. That path also never built a srcSet.

Area Principle / invariant What this PR changes
Config plumbing A config key is either implemented or rejected, never silently ignored loaderFile is resolved and existence-checked at config time; an unsupported loader value throws
Loader selection A configured loader replaces the built-in loader for every image, matching upstream The loader branch moves ahead of the remote-URL branch so it owns remote sources too
Width selection Absence of a width is not a width of zero fill passes undefined, which maps to the deviceSizes ladder with w descriptors
Two entry points getImageProps must agree with what <Image> renders Both now share generateImgAttrs

What changed

Scenario Before After
images.loaderFile set Ignored; built-in /_next/image used Loader module becomes the default loader for local and remote images
loaderFile path does not exist Silently ignored Throws at config time with the upstream message
loaderFile module has no default export Silently ignored Throws with the upstream message
images.loader: "custom", no loaderFile, no loader prop Fell back to /_next/image Throws the upstream missing-loader-prop error
images.loader: "imgix" with loaderFile Accepted Rejected at config time; named CDN loaders exist only in next/legacy/image upstream
images.loader: "imgix" alone, no loaderFile Accepted, then silently served from /_next/image Rejected at config time
images.loader: "custom", no loaderFile, unoptimized Rendered the original URL, hiding the misconfiguration Throws, as upstream does before it decides whether an image is optimized
Configured loader with placeholder="blur" Placeholder dropped; disagreed with getImageProps Placeholder preserved on both paths
Custom loader, quality omitted Loader received a forced 75 Loader receives undefined and applies its own default
loader prop, fixed width Single URL at the declared width, no srcSet srcSet with 1x/2x candidates; src is the largest candidate
loader prop, fill One call with width: 0, no srcSet One call per deviceSizes entry, w descriptors, sizes defaults to 100vw
getImageProps with a loader Same width: 0 defect, srcSet: undefined Matches the component output
No loader configured Unchanged Unchanged; the built-in /_next/image path is byte-identical

Remote-image optimization through /_next/image is unchanged and still unsupported. That path stays reserved for a separate change.

Maintainer review path
  1. packages/vinext/src/shims/image.tsx for getWidths / generateImgAttrs and the loader branch, which is where both defects lived.
  2. packages/vinext/src/image/image-loader-virtual.ts for the generated module and its three states.
  3. packages/vinext/src/config/next-config.ts (resolveImageLoaderFile) for path resolution and validation.
  4. packages/vinext/src/index.ts for the resolveId / load wiring.
  5. tests/image-loader-plugin.test.ts for proof that a real next.config reaches the generated module.
Validation
  • Ported getWidths and generateImgAttrs were checked line by line against the vendored .refs/nextjs-v16.2.6/packages/next/src/shared/lib/get-img-props.ts, including the w vs x descriptor rule, the widths[last] choice for src, the !sizes && kind === "w" default to 100vw, and the attribute ordering comment about Safari prefetching src before srcSet lands.
  • Added tests/image-loader-config.test.ts: codegen across the three states, plus three tests that write the generated source to disk and import() it, so quoting and escaping are verified by execution rather than string matching.
  • Added tests/image-loader-plugin.test.ts: resolves and loads virtual:vinext-image-loader through a real Vite server with a real next.config.mjs, then serves throwaway projects from a dev server so the configured-loader branches run against the module the plugin actually generated. Three of those cases fail against the pre-review commit.
  • Added component and getImageProps regression tests asserting fill never sends width: 0 and produces a device-size srcSet.
  • The refactor of the built-in path was verified behaviour-preserving: the existing suite passed unchanged after the extraction and before any loader-path edits.
  • Every test added for the review findings was checked in both directions — reverting each fix in isolation and confirming the corresponding test fails — so none of them pass vacuously.
Commands and extended results
pnpm run check                  pass (formatting, lint, types; 1162 files)
node scripts/check-shim-types.mjs   pass (24 modules)
node scripts/sync-next-types.mjs --check   pass (next@16.2.7, 347 files)
vp test run --project unit      8468 passed, 6 skipped, 2 failed
vitest run tests/image-loader-plugin.test.ts   10 passed

Neither unit failure is related to this change:

  • tests/commonjs-loader.test.ts > delegates packages with native addons to Node's loader globs for a compiled better_sqlite3.node addon that was never built in this working tree. Confirmed failing on main as well.
  • tests/dynamic-requests-build.test.ts > serves guarded fully dynamic requests in pages and route handlers during development times out under suite parallelism and passes in isolation (43/43).
Risk / compatibility
  • Behavioural change, intentional: for images using a custom loader, src is now the largest candidate width rather than the declared width, and a srcSet is emitted. This is upstream behaviour. Two existing tests asserted the previous output and are updated with the derivation of the expected values.
  • New config surface: images.loader and images.loaderFile. Previously these were accepted by the type and ignored, so no working configuration breaks. Configurations that were silently broken now fail at config time, which is the intent.
  • Projects with no image loader configured are unaffected. The built-in /_next/image path produces identical output.
  • getImageWidths is preserved and reused rather than reimplemented, so the fixed-width x-descriptor behaviour is untouched.
Non-goals
  • Remote-image fetching in the /_next/image optimizer. parseImageParams still accepts only path-relative URLs, and the SSRF surface that opens deserves its own change.
  • The sizes-less fill case for the built-in loader, which is next/image: fixed-size images emit w-descriptor srcSet without sizes (Next.js emits 1x/2x) #1966 and shares this code but has a wider blast radius.
  • Named CDN loaders (imgix, cloudinary, akamai). They exist only in next/legacy/image upstream and are now rejected rather than partially honoured.
  • The overrideSrc prop is still not applied in the loader branch. Pre-existing, unrelated, left alone to keep the diff reviewable.

References

Reference Why it matters
Issue #2699 Report, reproduction, and expected output
.refs/nextjs-v16.2.6/packages/next/src/shared/lib/get-img-props.ts Source of the ported getWidths / generateImgAttrs
.refs/nextjs-v16.2.6/packages/next/src/build/create-compiler-aliases.ts Upstream loaderFile aliasing this replaces
.refs/nextjs-v16.2.6/packages/next/src/server/config.ts Upstream loaderFile validation and error messages
Issue #1966 Adjacent srcSet behaviour in the same file, deliberately out of scope

Note on a separate parity gap

While verifying widths I found an unrelated pre-existing divergence: vinext defaults images.imageSizes to [16, 32, 48, 64, 96, 128, 256, 384] in index.ts and shims/image.tsx, but upstream imageConfigDefault is [32, 48, 64, 96, 128, 256, 384] with no 16. That extra entry changes allSizes and therefore srcSet candidate selection. Not touched here; happy to open a separate issue.

vinext replaces next/image with a from-scratch shim, so Next's build-time
machinery for `images.loaderFile` — a bundler alias that swaps the default
loader module — had no equivalent here. The option was accepted by config
and then silently ignored, leaving remote images unoptimized.

Generate a `virtual:vinext-image-loader` module from next.config so a
configured loaderFile becomes the default loader for every image, local or
remote. `loader: "custom"` without a loaderFile now reports the missing
per-image `loader` prop instead of quietly falling back to /_next/image,
and a missing or default-export-less loaderFile fails at config time.

Custom loaders also never produced a srcSet: the loader ran once with
`imgWidth ?? 0`, so `fill` images — which have no intrinsic width — sent
`width: 0` into user loader code. Port Next's getWidths/generateImgAttrs
so the loader is invoked once per candidate width, with the width-less
case mapping to the deviceSizes ladder rather than 0. This also applies to
getImageProps, which shared the same defect.

Two existing tests asserted the old behavior of `src` being the declared
width; upstream makes `src` the largest candidate so non-srcSet browsers
get the highest-fidelity image. They are updated with the derivation.

Fixes cloudflare#2699
@pkg-pr-new

pkg-pr-new Bot commented Jul 24, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2700
npm i https://pkg.pr.new/create-vinext-app@2700
npm i https://pkg.pr.new/@vinext/types@2700
npm i https://pkg.pr.new/vinext@2700

commit: 97eefa3

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared 97eefa3 against base 3570928 using alternating same-runner rounds. Next.js was unchanged and skipped.

0 improved · 0 regressed · 6 within ±1.5%

Scenario Framework Baseline Current Change
Client bundle size (gzip) vinext 132.4 KB 132.4 KB ⚫ +0.0%
Client entry size (gzip) vinext 119.8 KB 119.8 KB ⚫ 0.0%
Dev server cold start vinext 2.96 s 2.94 s ⚫ -0.5%
Production build time vinext 3.21 s 3.23 s ⚫ +0.6%
RSC entry closure size (gzip) vinext 105.6 KB 105.6 KB ⚫ -0.0%
Server bundle size (gzip) vinext 179.8 KB 179.8 KB ⚫ 0.0%

View detailed results and traces

🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head

@kHorozhanov

Copy link
Copy Markdown

Strong PR — the diagnosis matches what I found independently while chasing the same issue on a real project (Next 16 + @vinext/cloudflare on Workers). I reproduced both defects and confirm the root cause: images.loaderFile was parsed then dropped (a remote fill image landed in the plain-<img> branch with the original src), and the loader prop path collapsed a missing intrinsic width to width: 0. I also built an equivalent fix and verified it end-to-end in wrangler dev (workerd) — the custom loader now runs for a remote fill image and emits a full width-descriptor srcSet with no width=0, so the mechanism (virtual:vinext-image-loader + a faithful getWidths/generateImgAttrs port) holds in a production build, not just unit tests.

One concrete thing worth tightening before merge — the <Image> and getImageProps paths disagree for a remote source under a configured loaderFile, which is the exact invariant the PR sets out to uphold:

  • In the component, the effectiveLoader branch runs before validateRemoteUrl, so a configured loader owns remote sources unconditionally (correct — a custom loader bypasses the built-in optimizer, so there is no remotePatterns SSRF surface to guard).
  • In getImageProps, validateRemoteUrl runs first and sets blockedInProd, then loaderAttributes = effectiveLoader && !blockedInProd ? … : null, with resolvedSrc = blockedInProd ? "" : ….

So for a remote src whose host is not in remotePatterns, with images.loaderFile set, in production: <Image> renders the loader URL, but getImageProps() returns an empty src. A <picture> built from getImageProps would then diverge from what <Image> renders for the same props. Gating the getImageProps loader path on blockedInProd (rather than letting the loader own the remote source as the component does) looks like the fix.

Minor, optional: overrideSrc is still only honored on the unoptimized path — with a custom loader in effect, src comes from attributes.src and overrideSrc is ignored, whereas upstream applies overrideSrc to the final src regardless of loader. Pre-existing, but the loader branch is a natural place to also respect it.

Happy to see this land — it unblocks CDN/CMS images through next/image on vinext.

Production getImageProps calls blocked remote sources before a custom loader could translate them, diverging from the Image component and breaking picture elements built from the returned props. Let an effective loader own remote URL generation in both paths, and preserve overrideSrc as the fallback source without discarding loader-generated candidates.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3707766599

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread .changeset/image-custom-loader-support.md Outdated
Comment thread packages/vinext/src/config/next-config.ts Outdated
Comment thread packages/vinext/src/shims/image.tsx
Comment thread packages/vinext/src/shims/image.tsx
Comment thread packages/vinext/src/shims/image.tsx
Four review findings, all where loader selection and image rendering were
ordered wrongly relative to each other.

A named `images.loader` ("imgix", "cloudinary", "akamai") was only rejected
when paired with a `loaderFile`. On its own it fell past the early return and
nothing downstream caught it, so every image was quietly served from
/_next/image instead of the CDN the config named. Validate the loader value
before `loaderFile` is required.

`images.loader: "custom"` with no `loaderFile` generates a loader that reports
the missing per-image `loader` prop. The shim selected it only after the
`unoptimized` early return, so `<Image unoptimized>` and
`getImageProps({ unoptimized: true })` rendered the original URL from a config
that cannot produce URLs at all. Upstream throws above `generateImgAttrs`,
which is what handles `unoptimized`; mark the generated stub so the shim can
tell it from a working loader and honour that order. A valid loader is still
bypassed for unoptimized images.

Selecting a configured loader entered an <img> branch that ignored
`placeholder="blur"`, so an image that showed a placeholder under the built-in
loader silently lost it — and disagreed with `getImageProps`, which still
returned the blur style. Upstream merges `placeholderStyle` on every path.

Custom loaders received a forced `quality: 75` because the default was applied
at the loader seam rather than inside the built-in optimizer. A loader written
as `quality ?? 80`, or one letting a CDN choose, generated different URLs under
vinext than under Next.js. Pass the absence through; `imageOptimizationUrl`
still applies 75 at its own boundary.

Also drops the hand-authored changeset: CI generates changesets from
Conventional Commit subjects, so keeping it risks a duplicate release entry.
The shim's `images.loaderFile` / bare-`custom` branches were covered by a unit
test that swapped `virtual:vinext-image-loader` with `vi.doMock`, which is
exactly what `image/image-loader-unconfigured.ts` tells tests not to do.

Move them into the existing plugin integration test, where a throwaway project
with a real next.config is served by a real dev server, so the loader under
test is the one the plugin generated. `getImageProps` runs from a client page:
`next/image` is a "use client" module, so its exports are client references in
the RSC environment and a route handler cannot call it, and the `loader` prop
is a function that would otherwise have to cross the server/client boundary.

The fixture loader now emits `quality=auto` when quality is absent. With
`?? 75` it produced the same URL whether or not the shim forced a default,
so the case could not fail.
The bare-`custom` loader carried its state as a property stamped on the
exported function, which made the property name an implicit runtime
protocol duplicated across the generator, the ambient declaration, the
test stand-in, and the shim.

Replace it with a `requiresLoaderProp` named export. Every branch of the
generator emits it, so the shim's named import links in all three
configurations rather than reading an optional property that happens to
be absent. Drops the augmented function type from both type surfaces.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

Addressed the structural feedback on the function-property marker in ff11d0b76.

customImageLoader.__vinext_img_missing_loader = true is gone. The virtual module now exposes the state explicitly:

export default customImageLoader;
export const requiresLoaderProp = true;

All three generator branches emit requiresLoaderProp (false for unconfigured and for a configured loaderFile), because the shim imports the name unconditionally — an omitted branch would fail to link rather than silently read undefined, which is the point: the contract is now checked by the module graph instead of by a property that happens to be absent.

Removed along with it: the augmented function type in both virtual-vinext-image-loader.d.ts and the image-loader-unconfigured.ts stand-in, the MISSING_CUSTOM_LOADER_MARKER constant, and the comments explaining why a function was carrying configuration state.

Test change: the two marker tests (one string-matching the generated source, one asserting the property survived export) collapse into one that imports all three generated variants and asserts requiresLoaderProp — it exercises the linking contract rather than the codegen text.

Verified: vp check clean on the four changed sources, all 8 tests/image*/static-image-emission files pass (198 tests), vp run vinext#build succeeds, and the pre-commit gate ran the full check + full unit/integration suite + knip.

@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ff11d0b76c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/vinext/src/shims/image.tsx Outdated
<img
ref={mergedRef}
src={resolvedSrc}
src={renderedSrc}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Render src after srcSet for custom loaders

When a custom-loader image renders, this JSX inserts src before srcSet, despite generateImgAttrs intentionally returning src last. Browsers may begin fetching the largest fallback candidate before discovering the responsive set, so lower-DPR clients can download both the largest image and their selected candidate; getImageProps similarly reconstructs its object with src before srcSet. Preserve the generated attribute order in both paths.

AGENTS.md reference: AGENTS.md:L84-L84

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b4cd9582d. Confirmed: generateImgAttrs returns src last on purpose, but both call sites then wrote it first — the component in JSX, getImageProps in its object literal — so the intent never reached the DOM. src now comes after srcSet/sizes in both, matching upstream, which spreads imgAttributes wholesale and so inherits the object order.

{...rest} still comes last in the component, so caller overrides keep the precedence they had.

const effectiveLoader = loader ?? configuredImageLoader;
if (effectiveLoader) {
const resolvedQuality = typeof quality === "string" ? Number(quality) : quality;
const attributes = generateImgAttrs({

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Bypass custom loaders for data and blob sources

When images.loaderFile is configured and an image uses a data: or blob: source, this unconditional call sends the source through the loader for every candidate width and emits transformed src/srcSet URLs. Next.js automatically treats these sources as unoptimized, leaving the original URL untouched with no srcSet; without that guard, inline images and browser-created blob URLs can become invalid CDN requests.

AGENTS.md reference: AGENTS.md:L84-L84

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in b4cd9582d. Verified against Next.js 16.2.7 get-img-props.js:270:

if (!src || src.startsWith('data:') || src.startsWith('blob:')) {
    unoptimized = true;
    isLazy = false;
}

with generateImgAttrs returning early on unoptimized (:97), so the loader is never invoked. Added isInlineSrc() alongside the other unoptimized triggers in both paths.

Placement follows upstream's ordering rather than being an early return: the missing-loader-prop throw happens earlier still (:184), so the guard sits behind reportsMissingLoaderProp — a bare images.loader: "custom" is still reported for an inline source, it just no longer gets rewritten.

Regression test leaves data: and blob: sources untouched in image-loader-plugin.test.ts. I confirmed it fails without the guard, producing exactly the breakage you described:

OK https://images.example.comdata:image/gif;base64,R0lGODlhAQAB...?width=640&quality=auto
  | ...width=384 1x, ...width=640 2x

Not included: upstream also sets isLazy = false in the same block. That is a separate loading difference which already applies to inline sources today, so I left it out rather than widen this PR — flagging it as a known gap.

// loader is used. A per-image `loader` prop always takes precedence.
// Config cannot travel through the `process.env.__VINEXT_IMAGE_*` defines used
// below — those carry JSON, and a loader is a function.
import configuredImageLoader, { requiresLoaderProp } from "virtual:vinext-image-loader";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Keep the image shim inside the SSR transform graph

When an App Router project sets ssr.external: true, the SSR environment configuration in index.ts deliberately omits noExternal because dependencies are loaded directly by Node. The newly externalized image shim then evaluates this top-level virtual: import without Vite's resolver and fails with ERR_UNSUPPORTED_ESM_URL_SCHEME before any <Image> can render, even when no custom loader is configured. Keep this shim or its loader dependency in Vite's transform graph, or use a Node-resolvable module boundary.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Not reproducible — I believe this one is incorrect, and I tested it directly rather than reasoning about it.

The premise is that the shim is reached as a bare specifier and so gets externalized. It isn't. next/image is mapped through resolve.alias to an absolute path (index.ts:2409-2417, nextShimMappath.join(shimsDir, definition.shim)). Vite's SSR externalization only applies to bare imports, so by the time that decision is made the id is already an absolute file path and the module stays in the transform graph — where the plugin's own resolveId/load hooks resolve virtual:vinext-image-loader. The vinext/shims/* specifier is likewise intercepted at index.ts:3615 and returned as an absolute path.

Empirical check — tests/fixtures/app-basic (which uses next/image) with ssr: { external: true } added:

$ npx vinext build     # succeeds
$ npx vinext start
$ curl -s -o /dev/null -w '%{http_code}' /nextjs-compat/image-blur-placeholder
200

Rendered output:

<img src="/transparent-image.svg" ... srcSet="/transparent-image.svg 64w" data-nimg="1" style="background-image:url(data:image/png;base64,...)"/>

No ERR_UNSUPPORTED_ESM_URL_SCHEME, no server errors. Grepping the build output, there is no unresolved import ... from "virtual:" statement in any server chunk. The only occurrences are inside the SSR-manifest JSON, under a key carrying Rollup's resolved-virtual-module NUL prefix — which is itself the evidence that Vite processed the module internally and emitted it into a client chunk rather than leaving it for Node.

Happy to be shown otherwise if there is a config path I missed — but under ssr.external: true on App Router this renders correctly today. (The fixture change was reverted; it was only for this check.)

Comment thread packages/vinext/src/shims/image.tsx Outdated
onLoad={handleLoad}
onError={handleError}
style={fill ? getFillStyle(style) : style}
style={fill ? getFillStyle(style, blurStyle) : { ...blurStyle, ...style }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Let blur placeholders override custom background styles

When a custom-loader image uses placeholder="blur" together with a user style containing backgroundImage, backgroundSize, or another placeholder property, this merge applies the user style after blurStyle, so the placeholder is hidden before the image loads. Next.js merges its placeholder style last, and getImageProps repeats the same reversed ordering; apply the blur properties after the user style in both custom-loader paths.

AGENTS.md reference: AGENTS.md:L84-L84

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Correct about upstream, but deliberately not fixed here — this is pre-existing, not something this PR introduced.

Verified the divergence is real. Next.js 16.2.7 get-img-props.js:575-576 merges placeholder last:

style: {
    ...imgStyle,
    ...placeholderStyle
}

vinext has it reversed. But { ...blurStyle, ...style } is the existing convention at every site, four of which predate this PR — on 05eee9fe they are at lines 571, 772, 837 and 909, covering the unoptimized, remote, and both getImageProps paths. This PR added a fifth instance following the same pattern.

So the choice is: fix only the two custom-loader paths, which leaves the loader paths silently disagreeing with the other three and makes the inconsistency harder to spot; or fix all five, which changes rendered output on paths this PR does not otherwise touch. Neither belongs in a feature PR under the repo's "smallest coherent change / avoid unrelated refactors" rule, and the second is a behavior change deserving its own regression tests.

Filing as a follow-up instead: flip all five sites to { ...style, ...blurStyle } (and the getFillStyle argument order to match) with a test that a user backgroundImage no longer suppresses the placeholder.

Worth noting the comment on blurBackgroundStyle already documents upstream's ordering, so the divergence is recorded in-tree rather than silent.

Two custom-loader defects found in review.

Inline sources carry their own bytes, so no loader can produce a fetchable
URL for them. Upstream forces `unoptimized` for `data:`/`blob:` before any
loader runs (`get-img-props.ts:270`), which makes `generateImgAttrs` return
the original `src` with no `srcSet`. Without that guard a configured
`loaderFile` rewrote a data URI into
`https://cdn.example.com/data:image/gif;base64,...?width=640` and emitted a
srcSet of the same. The guard sits with the other `unoptimized` triggers and
behind the missing-loader-prop check, matching upstream's ordering: the bare
`images.loader: "custom"` error is raised earlier still (`:184`), so it is
still reported for an inline source.

`generateImgAttrs` deliberately returns `src` last so the browser sees the
responsive set before the fallback candidate, but both custom-loader call
sites then wrote `src` first — the component in JSX, `getImageProps` in its
object literal — which defeats it, since React applies attributes in prop
order. Move `src` after `srcSet`/`sizes` in both.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b4cd9582db

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// image would quietly be served from `/_next/image` instead of the CDN the
// config names.
const loader: string | undefined = images.loader;
if (loader !== undefined && loader !== "default" && loader !== "custom") {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Support the valid named image loaders

When an existing Next.js app configures images.loader as imgix, cloudinary, or akamai without loaderFile, this branch now throws during config resolution and prevents the app from starting. These are current valid loader values—the repository's vendored Next 16.2.7 declaration in packages/types/next/upstream/dist/shared/lib/image-config.d.ts includes all three in VALID_LOADERS—so the claim that they exist only for next/legacy/image is incorrect. Implement their URL generation or preserve a nonfatal fallback rather than rejecting valid Next.js configuration.

AGENTS.md reference: AGENTS.md:L293-L297

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Disagreeing on this one — Next.js rejects this configuration too, so the current behavior is parity, not a regression.

VALID_LOADERS does list all three, but that list gates both next/image and next/legacy/image. What decides the outcome is the config validation. Next 16.2.7, dist/server/config.js:540-541:

if (images.loader !== 'default' && images.loader !== 'custom' && images.path === imageConfigDefault.path) {
    throw new Error(
      `Specified images.loader property (${images.loader}) also requires images.path property to be assigned to a URL prefix.\n` +
      `See more info here: https://nextjs.org/docs/api-reference/next/legacy/image#loader-configuration`
    )
}

So images.loader: "imgix" with no loaderFile and no custom images.path throws during config resolution and prevents the app from starting — in Next.js, today. That is precisely the case this branch rejects. Note also where upstream's own error message points: the next/legacy/image loader-configuration docs.

The implementations back that up. In 16.2.7 the named loaders exist only in dist/client/legacy/image.js:

45: function imgixLoader({ config, src, width, quality })
58: function akamaiLoader({ config, src, width })
61: function cloudinaryLoader({ config, src, width, quality })

dist/shared/lib/image-loader.js — what modern next/image actually uses — contains only defaultLoader (:13, marked __next_img_default at :110). There is no imgix/cloudinary/akamai code path in modern next/image to implement or fall back to.

The one real difference: upstream permits a named loader when images.path is set to a custom prefix, because legacy image consumes it. vinext implements neither next/legacy/image nor custom images.path, so there is no configuration under which a named loader could work — which is why this rejects unconditionally and says so in the message.

A non-fatal fallback is specifically what the earlier round of this review asked to remove, and for a good reason: silently serving /_next/image when the config names a CDN is the failure mode that is hardest to notice in production.

Reverting would restore that. Leaving as-is unless there's a concrete case where Next.js starts an app in this configuration — happy to look at one.

Comment thread tests/image-loader-config.test.ts Outdated
it("resolves a relative loaderFile against the project root", async () => {
const root = makeProjectRoot();
const config = await resolveNextConfig({ images: { loaderFile: "./loader.mjs" } }, root);
expect(config.images?.loaderFile).toBe(`${root}/loader.mjs`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Canonicalize the resolved loader path expectation

On Windows, root contains backslashes while resolveImageLoaderFile() returns a path normalized by toSlash, so this expected value becomes a mixed C:\.../loader.mjs string and the test fails despite correct production behavior. Keep the native node:path fixture input, but canonicalize the expected path.join(root, "loader.mjs") with toSlash as required for source-output comparisons.

AGENTS.md reference: AGENTS.md:L332-L332

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch — fixed in 741fb3ec3.

resolveImageLoaderFile returns toSlash(path.resolve(root, loaderFile)) while root comes from mkdtempSync with native separators, so on Windows the expectation was a mixed C:\...\xxx/loader.mjs against an actual C:/.../xxx/loader.mjs. Now toSlash(path.join(root, "loader.mjs")), keeping the native fixture input as you suggested. Follows the existing convention in tests/app-route-graph.test.ts.

Comment thread packages/vinext/src/shims/image.tsx Outdated
onLoad={handleLoad}
onError={handleError}
style={fill ? getFillStyle(style) : style}
style={fill ? getFillStyle(style, blurStyle) : { ...blurStyle, ...style }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Apply the blur placeholder after custom styles

When a custom or configured loader is combined with placeholder="blur" and the caller's style defines backgroundImage, backgroundSize, backgroundRepeat, or backgroundPosition, this merge applies the caller style after blurStyle, immediately overwriting the placeholder and making it invisible. Next.js merges its placeholder style last—the new helper's own comment documents that ordering—so reverse the precedence for both the fill and non-fill custom-loader paths until loading completes.

AGENTS.md reference: AGENTS.md:L293-L297

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 741fb3ec3 — you were right to re-raise it, and the point that decided it was the one about the helper's own comment.

I deferred this last round as pre-existing, which it is: the reversed merge is at every site, four of them predating this PR. But this PR added blurBackgroundStyle, whose doc states upstream merges placeholderStyle last, while every caller then did the opposite. A helper that documents an ordering its callers contradict is this PR's problem regardless of where the callers came from, so the choice was to fix the behavior or delete the claim. The behavior is the one worth having.

Verified against 16.2.7 get-img-props.js:

const imgStyle = Object.assign(fill ? {position:'absolute', ...} : {}, showAltText ? {} : {color:'transparent'}, style)
...
style: { ...imgStyle, ...placeholderStyle }

Two orderings to preserve, and I kept both: the caller's style wins over the fill defaults, and placeholderStyle wins over everything while it is showing. So getFillStyle became {...fillDefaults, ...style, ...backgroundStyle} and the five non-fill sites became {...style, ...blurStyle} — all of them, not just the two custom-loader paths, since fixing those alone would have left the loader paths disagreeing with the other three.

Regression test keeps the blur placeholder above a caller background style in image-component.test.ts, covering fill and non-fill. It asserts the blur URL wins rather than merely appearing — the old behavior still emitted the placeholder into the style object, just invisibly, so a containment check would have passed against the bug. Confirmed it fails on the pre-fix ordering.

Also re-ran tests/e2e/app-router/nextjs-compat/image.spec.ts, which covers placeholder removal after load: passes, so the caller's style still takes over once blurComplete drops the placeholder.

Upstream merges `placeholderStyle` after `imgStyle` — which already contains
the caller's `style` — so the placeholder wins for as long as it is showing
(`get-img-props.ts:574-577`). vinext had the merge reversed at every site, so
an `<Image placeholder="blur">` whose caller also set `backgroundImage`,
`backgroundSize`, `backgroundRepeat` or `backgroundPosition` emitted the
placeholder into the style object but rendered it invisible.

Pre-existing on the unoptimized, remote and both `getImageProps` paths rather
than introduced here, but this PR added `blurBackgroundStyle`, whose own
comment documents the upstream ordering the callers then contradicted. Fixing
only the two custom-loader paths would leave the loader paths disagreeing with
the other three, so all five move together, along with `getFillStyle`, which
had the same inversion for `fill` images.

The caller's style still wins over the fill defaults, matching upstream, and
takes over completely once `blurComplete` drops the placeholder.

Also normalize the `loaderFile` path expectation with `toSlash`: the resolver
returns a normalized path while the fixture root carries native separators,
which compares a half-backslash path against a forward-slash one on Windows.
@NathanDrake2406

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. More of your lovely PRs please.

Reviewed commit: 741fb3ec30

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

…ader calls

Cleanup pass over the shim, no behavior change.

`generateImgAttrs` invoked the loader once per candidate width and then a
final time to rebuild the widest URL the map had already produced — N+1 calls
where N suffice, per image, per render. Loaders are arbitrary user code and a
signed-URL loader hashes per call, so materialize the URLs once and reuse the
last. Verified: a 400px image now makes 2 loader calls for its 2 candidates,
previously 3.

`generateImageAttributes` had become a pass-through around `generateImgAttrs`
whose only effect was discarding `sizes`; both call sites now call
`generateImgAttrs` directly.

The blur placeholder repeated the same sanitize-and-guard pair at six sites,
of which this branch added two. `blurPlaceholder(blurDataURL, show)` absorbs
it and returns the sanitized url for the one caller that needs it bare.
`showBlurPlaceholder` answers the `!blurComplete && placeholder === "blur"`
question once per render instead of four times.

The missing-loader-prop ordering was stated twice, once per path. It is a rule
about upstream's ordering, so it gets one home in `bypassesLoaders()`.

In `getImageProps`, `skipOpt` evaluated `isSvgUrl` — the only URL parse in the
function — before the cheap terms that decide the result anyway. With a
configured `loaderFile` the loader term is always true, so every image paid a
parse that could not change the outcome; reordered to short-circuit.

Also: reattach the `resolveCacheHandlerPathToFilesystem` doc comment, which
this branch had stranded above `resolveImageLoaderFile`; drop the unused typed
`undefined` in the test stand-in; reuse `startFixtureServer` instead of
hand-rolling the same dev server; and shorten the rationale emitted into the
generated module, which ships to users' bundles and duplicates the TS doc.
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.

next/image ignores images.loaderFile for remote src (renders unoptimized); per-component loader prop yields width=0

2 participants