Skip to content

fix(image): emit static imports as managed assets - #2040

Merged
james-elicx merged 4 commits into
mainfrom
codex/fix-static-image-emission-wave2
Jun 15, 2026
Merged

fix(image): emit static imports as managed assets#2040
james-elicx merged 4 commits into
mainfrom
codex/fix-static-image-emission-wave2

Conversation

@james-elicx

Copy link
Copy Markdown
Member

vinext parity: static image import emission

Source evidence

  • Wave2 report: /tmp/vinext-e2e-27514800656-backlog-wave2.md
  • Source deploy-suite run: 27514800656
  • Source main commit: 10b5086f4d96a7f2054cbc9c6c239aa16a1d6dd8 (June 14, 2026)
  • Exact suite: test/e2e/app-dir/next-image/next-image.test.ts
  • Exact direct assertions:
    • browser: should render images nested under page dir on /nested route
    • SSR: should render images on /client route
    • SSR root image assertion crashed because the expected static-media attribute was absent
  • Exact symptom: expected an optimizer URL whose url= was /_next/static/media/test.HASH.png and whose selected width was 828; received an optimizer URL whose url= was a large data:image/png;base64,... and whose width was 400.
  • Downstream failures: optimizer fetches returned 400/500 because the optimizer received embedded data URLs instead of managed static asset URLs.
  • Explicitly excluded from this candidate: cacheComponents, PPR, resume, and fallback-shell work.

Upstream and bundler semantics

  • Next.js next-image-loader always emits imported image content and returns StaticImageData with a URL under /static/media/[name].[hash:8].[ext]; it does not apply a general inline threshold to static image imports.
  • Next.js webpack CSS handling similarly uses asset/resource where managed asset URLs are required, specifically to avoid data-URI inlining.
  • Vite normally applies build.assetsInlineLimit independently in each build environment. In App Router builds, RSC can therefore inline a source even when the client environment emits it, producing different StaticImageData.src values across environments.
  • App Router production build order is RSC scan, SSR scan, RSC build, client build, SSR build. A plugin-instance registry can therefore collect server-only image imports before the client output is written.

Reproduction added

tests/static-image-emission.test.ts production-builds small real fixtures with build.assetsInlineLimit: 100_000 so the images would inline without an explicit framework override.

Coverage:

  • App Router server component static import.
  • App Router client component static import.
  • Pages Router static import.
  • Emitted file under dist/client/.../_next/static/media/ with original bytes.
  • Rendered optimizer src and srcSet point to the same managed media URL and never contain data:image.
  • Direct fetch of the managed asset returns 200 and the original bytes.
  • A separate ordinary ?url import remains a data URL, proving the user's inline threshold is preserved for unrelated assets.
  • basePath, path assetPrefix, and deploymentId URL/on-disk behavior.

Implementation

  • Static image transforms now import an internal absolute-path ?vinext-image-url virtual module plus the existing metadata module.
  • In dev, the URL module delegates to Vite's normal ?url handling.
  • In production, the URL module:
    • reads the image once,
    • computes a deterministic 8-character SHA-256 content hash,
    • registers media/[name].[hash][ext],
    • returns the existing vinext managed asset URL shaped by assetPrefix and deploymentId.
  • The client environment's post-write hook writes all registered image bytes into its actual output directory. This covers server-only RSC imports while keeping one public asset location for Node and Cloudflare static serving.
  • Existing dimension extraction and StaticImageData shape are unchanged.
  • No global assetsInlineLimit override was added; public files, CSS assets, ordinary Vite imports, and explicit user thresholds retain their existing behavior.
  • SVG bytes are emitted unchanged; existing image optimizer SVG/security configuration remains authoritative.

Validation

Branch base: current origin/main at a3d2f921520ff140a826224616df5e0db4ed0186 on June 15, 2026.

Passed:

  • vp test run tests/image-imports.test.ts tests/image-component.test.ts tests/image-optimization-parity.test.ts tests/static-image-emission.test.ts
    • 4 files, 92 tests.
  • vp check
    • all 1953 files formatted;
    • no warnings, lint errors, or type errors in 899 files.
  • vp run vinext#build
    • package build completed successfully.
  • git diff --check
    • clean.

Exact assertion disposition

The local production assertion corresponding to the wave2 failure now observes:

  • optimizer url= resolving to a managed /_next/static/media/<name>.<8-char-hash>.png URL;
  • generated srcSet candidates using that managed URL;
  • no embedded data:image source;
  • a directly fetchable emitted asset.

The full upstream Next.js deploy suite was not rerun locally; the focused vinext-owned production fixture exercises the same failing contract without cacheComponents/PPR/resume scope.

Review follow-up — 4225b4c

All static-image review findings were addressed in commit 4225b4cfd705a12b71079e7499b7c0a384a2f606:

  • Production/watch builds call addWatchFile for image URL and metadata modules.
  • Per-build image bytes/dimensions are cleared and recomputed; importer dependencies are tracked so changed images receive new hashes/dimensions and removed imports delete stale emitted files.
  • SVG classification inspects the parsed URL pathname, so managed .svg?dpl=... sources bypass the image optimizer unless SVG optimization is explicitly allowed.
  • Static-file ETag recognition safely accepts vinext's exact dot-delimited eight-lowercase-hex image hash format while rejecting arbitrary dotted suffixes.
  • Production direct requests assert immutable caching, stable filename-derived ETags, and matching If-None-Match responses returning 304 with no body.

Review validation completed June 15, 2026:

  • vp test run tests/static-image-emission.test.ts tests/image-component.test.ts tests/static-file-cache.test.ts tests/serve-static.test.ts tests/image-imports.test.ts — 5 files, 151 tests passed.
  • Earlier scoped image optimizer parity run in this review cycle — 6 tests passed.
  • vp check — all 1953 files formatted; no warnings, lint errors, or type errors in 899 files.
  • vp run vinext#build — package build completed successfully.
  • git diff --check — clean before commit.

No upstream deploy suite was started. Nothing was pushed and no PR was opened.

Independent review follow-up — managed media ETag scope

The independent P2 review finding was addressed by restricting vinext's dot-delimited eight-character image hash ETag format to files under _next/static/media/, including path-prefixed layouts. Dash-delimited Vite asset hashes remain unchanged.

Focused regression coverage now verifies:

  • an arbitrary _next/static/config.deadbeef.json file does not receive W/"deadbeef";
  • changing that file's content produces a different validator and a conditional request returns 200 with the new content rather than an incorrect 304;
  • managed _next/static/media/photo.0123abcd.png files retain the stable filename-derived ETag, immutable cache control, and matching conditional 304 behavior.

Validation completed June 15, 2026:

  • vp test run tests/static-file-cache.test.ts tests/serve-static.test.ts — 2 files, 64 tests passed.
  • vp check --fix packages/vinext/src/server/static-file-cache.ts tests/static-file-cache.test.ts tests/serve-static.test.ts — formatting completed; no warnings, lint errors, or type errors in 3 files.
  • git diff --check — clean.

@pkg-pr-new

pkg-pr-new Bot commented Jun 15, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@vinext/cloudflare@2040
npm i https://pkg.pr.new/vinext@2040

commit: 320213a

Comment thread tests/image-component.test.ts Fixed
Comment thread tests/image-component.test.ts Fixed
@github-actions

Copy link
Copy Markdown
Contributor
Example Preview Production Original
app-router-cloudflare preview production
pages-router-cloudflare preview production
app-router-playground preview production original
realworld-api-rest preview production
nextra-docs-template preview production
benchmarks preview production
hackernews preview production original
workers-cache preview production
web preview production

@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(image): emit static imports as managed assets

I reviewed the diff across index.ts, static-file-cache.ts, image.tsx, and the tests. Ran the relevant suites locally — static-image-emission, image-component, image-imports, static-file-cache all pass (4 + 59 + 26 + 27), and vp check is clean on the changed source files. The implementation is solid and well-targeted. No blocking issues found.

What's correct and well done

  • Static media emission writes to dist/client/_next/static/media/ — exactly where both the Node prod server (resolveAppRouterAssetPath) and Cloudflare static serving read from. Verified the on-disk path matches the emitted URL for the default, assetPrefix, and path-prefix layouts (the basePath/assetPrefix test exercises this).
  • Server-only RSC imports are handled: staticImageImportsByModule is shared across the RSC/SSR/client environments (single plugin instance, keyed by module id), so the client writeBundle sees RSC-collected imports and emits them. The build-order reasoning in the PR body checks out.
  • ETag scoping (etagFromFilenameHash) correctly restricts the dot-delimited 8-hex form to _next/static/media/, so _next/static/config.deadbeef.json no longer gets a bogus W/"deadbeef". The dash branch still handles Vite hashes first; names like my-icon.0123abcd.png fall through to the dot branch (dash suffix exceeds the 6–12 length cap). Good coverage of both the positive and negative cases.
  • isSvgUrl is a genuine correctness/security fix: .endsWith(".svg") missed managed SVG sources carrying ?dpl=..., which would have routed them to the optimizer instead of skipping. Parsing the pathname matches Next's "SVG is unoptimized by default" behavior.

Minor (non-blocking)

1. CodeQL flagged .replace("&", "&amp;") in tests/image-component.test.ts:233-234 — only replaces the first &. It happens to be a no-op here because the test src (...icon.0123abcd.svg?dpl=deployment-1) contains no &, so the assertions are correct today. But the intent is HTML-escaping, and it would silently break if anyone added a multi-param query. Suggest .replaceAll("&", "&amp;") (or a real escape helper) to make the intent explicit and satisfy CodeQL.

2. buildStart clears staticImageAssets per-environment. In a multi-env production build, the client buildStart wipes the RSC-populated entries before client load repopulates. It's not a bug — writeBundle falls back to createStaticImageAsset(imagePath) reading from disk, and the server-component test confirms emission works — but the asset cache is effectively bypassed for server-only images (redundant re-reads/re-hashes at write time). Optional: skip the staticImageAssets.clear() (rely on watchChange for invalidation) or only clear in the first environment, if you want the cache to actually pay off.

3. Map key-space mismatch in watchChange(id). It deletes id from both staticImageAssets (keyed by image path) and staticImageImportsByModule (keyed by importer module id). For an image-file change, id is the image path, so the staticImageImportsByModule.delete(id) is a no-op (and vice-versa for a source-module change). Harmless given the disk-read fallback, but a brief comment noting the two key spaces would help future readers.

None of these block merge. Tests are thorough (App + Pages routers, server + client components, basePath/assetPrefix/deploymentId, immutable caching + 304 parity, and the inline-threshold-preserved control case).

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2040.

Summary

I reviewed PR #2040 (fix(image): emit static imports as managed assets), ran the relevant test suites (116 tests pass) and vp check (clean), and posted a review. No blocking issues — the implementation is solid.

Verified correct:

  • Static media written to dist/client/_next/static/media/ matches where both Node prod server and Cloudflare serve from
  • Server-only RSC imports correctly emitted via the shared cross-environment import map + client writeBundle
  • ETag scoping properly restricts the dot-hash form to managed media, preventing stale ETags on arbitrary files like config.deadbeef.json
  • isSvgUrl is a genuine fix — .endsWith(".svg") missed deployment-tagged SVGs (?dpl=...) that would wrongly hit the optimizer

Minor (non-blocking) notes raised:

  1. CodeQL: .replace("&", "&amp;") in test only escapes first & — works today (no & in src) but intent is fragile; suggest replaceAll
  2. buildStart clears staticImageAssets per-environment, making the cache ineffective for server-only images (falls back to disk reads — works, just redundant)
  3. watchChange(id) mixes two key spaces (image-path map vs module-id map) — harmless given the fallback, but worth a comment

Tests are thorough, covering both routers, server/client components, basePath/assetPrefix/deploymentId, immutable caching + 304 parity, and a control case proving ordinary ?url imports keep their inline threshold.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 15, 2026 10:32
@james-elicx
james-elicx marked this pull request as draft June 15, 2026 10:44
@james-elicx

Copy link
Copy Markdown
Member Author

/bigbonk review for issues

@ask-bonk ask-bonk Bot left a comment

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.

Review: fix(image): emit static imports as managed assets

Re-reviewed the diff (index.ts, static-file-cache.ts, image.tsx, tests) and re-ran the suites locally — static-image-emission + static-file-cache pass (31 tests). The implementation is solid and the failing wave2 contract is correctly addressed. No blocking issues.

Verified correct

  • Cross-environment URL stability. The ?vinext-image-url virtual module returns a deterministic, content-hashed Next-shaped URL (renderVinextBuiltUrl(...)). Because RSC, SSR, and client environments each run load independently but compute the same SHA-256 content hash, StaticImageData.src is now identical across environments — which is exactly the divergence the wave2 failure exposed (RSC inlining a data URL while client emitted a file).
  • On-disk / URL parity. writeBundle writes to path.join(clientOutDir, resolveAssetsDir(assetPrefix), asset.fileName), and the rendered URL is built from the same resolveAssetsDir(...) + "/" + asset.fileName. The basePath/assetPrefix/deploymentId test confirms the path-prefix layout (cdn/_next/static/media/...) lands where the prod server and Cloudflare ASSETS binding read from. No double-prefixing.
  • Server-only RSC emission. staticImageImportsByModule is keyed by importer module id on the single shared plugin instance, so the client writeBundle sees RSC-collected imports and emits them even when no client module imports the image.
  • ETag scoping. etagFromFilenameHash restricts the dot-delimited 8-hex form to _next/static/media/. Verified the dash branch runs first and falls through for dashed media names (my-icon.0123abcd → dash suffix name.0123abcd exceeds the 6–12 cap and contains a ., failing the charclass), and that arbitrary files like _next/static/config.deadbeef.json no longer receive a bogus W/"deadbeef". Both positive and negative cases are covered.
  • isSvgUrl fix. Parsing the pathname instead of .endsWith(".svg") correctly skips the optimizer for deployment-tagged managed SVGs (...icon.HASH.svg?dpl=...), matching Next's "SVG is unoptimized by default" behavior. The earlier CodeQL .replace("&", ...) finding was already resolved with .replaceAll in 320213a.

Minor (non-blocking)

  1. buildStart clears staticImageAssets per environment. In the multi-env build, the client buildStart wipes RSC-populated entries before writeBundle runs, so writeBundle falls back to createStaticImageAsset(imagePath) re-reading/re-hashing from disk for server-only images. Not a bug (the server-component test confirms emission), just a redundant re-read — the asset cache effectively never pays off for server-only images. Optional: skip staticImageAssets.clear() and rely on watchChange for invalidation.
  2. watchChange(id) mixes two key spaces. It deletes id from both staticImageAssets (keyed by image path) and staticImageImportsByModule (keyed by importer module id); only one delete can ever hit per call. Harmless given the disk-read fallback and the importer's own watchChange re-running transform, but a one-line comment noting the two key spaces would help future readers.
  3. media/ directory shared with Vite-emitted assets. Vite's own asset output (createClientAssetFileNames) also writes to <assetsDir>/media/. Collision is effectively impossible (Vite uses dash hashes name-HASH.ext, vinext uses dot hashes name.HASH.ext), but worth being aware of if the asset naming scheme ever changes.

Test coverage is thorough: both routers, server + client components, basePath/assetPrefix/deploymentId, watch rebuilds (changed image rehash + removed-import cleanup), immutable caching + 304 conditional parity, and a control case proving ordinary ?url imports keep the user's inline threshold. Nothing blocks merge.

@ask-bonk

ask-bonk Bot commented Jun 15, 2026

Copy link
Copy Markdown
Contributor

Review posted on PR #2040.

Summary

I re-reviewed PR #2040 (fix(image): emit static imports as managed assets), re-ran the relevant suites (31 tests pass), and posted a review. No blocking issues — the implementation is solid and correctly fixes the wave2 contract.

Verified correct:

  • Cross-environment URL stability — the ?vinext-image-url module produces a deterministic content-hashed URL, so StaticImageData.src is now identical across RSC/SSR/client (the exact divergence wave2 exposed)
  • On-disk/URL paritywriteBundle writes to the same resolveAssetsDir(...) + fileName the rendered URL is built from; the basePath/assetPrefix/deploymentId test confirms path-prefix layouts land where the prod server and Cloudflare serve from
  • Server-only RSC emission via the module-id-keyed import map + client writeBundle
  • ETag scoping correctly restricts the dot-hash form to _next/static/media/, with the dash branch falling through cleanly for dashed media names
  • isSvgUrl properly handles deployment-tagged managed SVGs; the earlier CodeQL .replace finding was already fixed with .replaceAll in 320213a

Minor (non-blocking) notes raised:

  1. buildStart clears staticImageAssets per-environment, making the cache redundant for server-only images (falls back to disk reads — works, just no payoff)
  2. watchChange(id) mixes two key spaces (image-path vs module-id maps); harmless given fallbacks, but a comment would help
  3. media/ directory is shared with Vite-emitted assets — collision impossible today (dash vs dot hash schemes) but worth noting

Test coverage is comprehensive across both routers, server/client components, basePath/assetPrefix/deploymentId, watch rebuilds, immutable caching + 304 parity, and the inline-threshold control case.

github run

@james-elicx
james-elicx marked this pull request as ready for review June 15, 2026 10:52
@james-elicx
james-elicx merged commit 76de660 into main Jun 15, 2026
55 checks passed
@james-elicx
james-elicx deleted the codex/fix-static-image-emission-wave2 branch June 15, 2026 22:04
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