Skip to content

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

Open
NathanDrake2406 wants to merge 5 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 5 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: c98d303

@github-actions

github-actions Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Performance benchmarks

Compared c98d303 against base 05eee9f 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.7 KB 119.7 KB ⚫ -0.0%
Dev server cold start vinext 2.94 s 2.90 s ⚫ -1.3%
Production build time vinext 3.19 s 3.17 s ⚫ -0.5%
RSC entry closure size (gzip) vinext 103.3 KB 103.3 KB ⚫ +0.0%
Server bundle size (gzip) vinext 177.8 KB 177.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.
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