Skip to content

fix(image): clear blur placeholder after image load#1267

Merged
james-elicx merged 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/image-blur-placeholder-load
May 16, 2026
Merged

fix(image): clear blur placeholder after image load#1267
james-elicx merged 3 commits into
cloudflare:mainfrom
NathanDrake2406:nathan/image-blur-placeholder-load

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

Overview

Area Summary
Goal Match Next.js image placeholder lifecycle for transparent images.
Core change Clear placeholder="blur" background styling once the image has completed loading or errored.
Main behavior A transparent PNG, SVG, or WebP no longer shows the low quality placeholder through the final image pixels.
Primary files packages/vinext/src/shims/image.tsx, tests/e2e/app-router/nextjs-compat/image.spec.ts
Expected impact Visual parity fix for next/image; no public API or config change.

Why

A blur placeholder is loading UI, not part of the final rendered image. Next.js tracks blur completion separately from the image props and stops emitting the placeholder background after load. Vinext kept the background style static, which makes transparent final images permanently reveal the placeholder.

Area Principle / invariant What this PR changes
Image lifecycle Placeholder styling is only valid before the image settles. Tracks blur completion per src and suppresses blur backgrounds after load or error.
Transparent images Final transparent pixels must reveal the page background, not stale loading UI. Adds a browser regression route with a transparent image and asserts background-image: none after load.
Next.js compatibility next/image visible behavior should follow Next.js unless vinext intentionally diverges. Mirrors Next.js blur-completion semantics while keeping the existing vinext shim structure.

What changed

Scenario Before After
placeholder="blur" local image loads Inline blur backgroundImage remained on the <img>. Blur background is removed after the image loads.
Transparent final image Placeholder bled through transparent pixels. Transparent pixels show the actual page background.
Image errors Placeholder could remain indefinitely. Blur is also completed on error, matching the lifecycle behavior in Next.js.
Maintainer review path
  1. packages/vinext/src/shims/image.tsx
    • Review the new completedBlurSrc state and the load/error paths that call markBlurComplete().
    • Check both local <img> rendering and remote @unpic/react rendering suppress the blur background once complete.
  2. tests/fixtures/app-basic/app/nextjs-compat/image-blur-placeholder/page.tsx
    • Small App Router fixture using next/image, a transparent public image, and a blur data URL.
  3. tests/e2e/app-router/nextjs-compat/image.spec.ts
    • Browser-visible regression check that waits for the image to complete, then asserts the computed background image is gone.
Validation

Added a failing browser regression before the implementation, then verified it passes after the fix.

Commands run:

vp run vinext#build
vp test run tests/image-component.test.ts
PLAYWRIGHT_PROJECT=app-router npx playwright test tests/e2e/app-router/nextjs-compat/image.spec.ts
vp check

All commands passed. The build still prints existing virtual import externalization warnings, but exits successfully.

Risk / compatibility
  • Public API: none.
  • Config: none.
  • Runtime: limited to next/image blur placeholder lifecycle.
  • Compatibility: aligns with Next.js behavior. The placeholder is cleared on successful image load and on error.
  • Existing app risk: low. Apps that depended on blur placeholders remaining after load were depending on a compatibility bug.
Non-goals
  • Does not change image optimization behavior.
  • Does not add new image format handling.
  • Does not change remote image allow-list validation.

References

Reference Why it matters
Next.js image component source Next.js calls blur completion from its image loading lifecycle.
Next.js get-img-props source Placeholder background is conditional on !blurComplete.
Next.js app-dir image E2E tests Includes coverage for removing data URL and blurry placeholders after image load.
Next.js placeholder docs Documents the placeholder as loading-state behavior.
Next.js blurDataURL docs Describes the blur data URL as the placeholder before the image successfully loads.

Blur placeholders currently stay on the img background after the high resolution image finishes loading. That is wrong for transparent image formats because the low quality placeholder remains visible through the rendered pixels.

The shim treated placeholder styling as static props instead of load lifecycle state. Track blur completion per src, clear local and remote placeholder backgrounds after successful load or error, and cover the transparent-image regression in an app-router browser test.
@NathanDrake2406 NathanDrake2406 marked this pull request as ready for review May 16, 2026 12:36
@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@1267

commit: d275d0d

@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 fix that correctly matches Next.js blur-completion semantics. The state design (completedBlurSrc tracking the src that completed, deriving blurComplete from comparison) handles src changes correctly — if src changes, blurComplete becomes false again and the new image shows its blur placeholder until it loads. The setCompletedBlurSrc updater function avoids unnecessary re-renders when already completed for the same src. The test is well-constructed.

Two observations (neither blocking):

  1. Layout effect deps: markBlurComplete and src are called/read inside the useNonWarningLayoutEffect (line 354) but aren't in its dependency array ([placeholder, sizes, _unoptimized]). This is safe in practice because the effect only runs once (didInsertRef.current gates it), and the existing deps were already incomplete before this PR. But if a linter ever enforces exhaustive-deps here, it'll flag it.

  2. Pre-existing: loader path missing blur styles: The loader code path (line 431) doesn't render blur placeholder styles at all — it spreads style directly, no blurStyle. The markBlurComplete() calls in handleLoad/handleError are harmless there but have no visible effect since there's no blur background to clear. Not introduced by this PR — just flagging as a potential follow-up parity gap.


const markBlurComplete = () => {
if (placeholder !== "blur") return;
setCompletedBlurSrc((current) => (current === src ? current : src));
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: markBlurComplete captures placeholder and src from the render closure. If placeholder switches from "blur" to "empty" on the same src, the early return in markBlurComplete will prevent setCompletedBlurSrc from being called, but blurComplete remains true from the previous render (since completedBlurSrc === src still holds). This is fine because the blur background conditions also check placeholder === "blur", so it's a no-op either way. Just noting the invariant holds.

Comment thread packages/vinext/src/shims/image.tsx Outdated
const sanitizedBlur = imgBlurDataURL ? sanitizeBlurDataURL(imgBlurDataURL) : undefined;
const bg = placeholder === "blur" && sanitizedBlur ? `url(${sanitizedBlur})` : undefined;
const bg =
!blurComplete && placeholder === "blur" && sanitizedBlur
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.

Good — this correctly gates the blur background for the remote/UnpicImage path. The blurComplete check is consistent with the local image path at line 563.

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 16, 2026

Review submitted. Here's a summary:

Approved — this is a clean, correct fix for the blur placeholder lifecycle.

What the PR does well:

  • The completedBlurSrc state design handles src changes correctly — when src changes, blurComplete resets to false, so a new image gets its blur placeholder back until it loads.
  • The setCompletedBlurSrc updater avoids unnecessary re-renders when already completed for the same src.
  • Blur is cleared on both load and error, matching Next.js behavior.
  • Both rendering paths (remote/UnpicImage and local <img>) are gated consistently.
  • The E2E test is well-constructed — waits for actual image completion before asserting computed style.

Two non-blocking observations noted in the review:

  1. The useNonWarningLayoutEffect dependency array doesn't include markBlurComplete or src, but this is safe because didInsertRef.current gates it to a single execution. Pre-existing issue.
  2. The loader code path (line 431) doesn't render blur placeholder styles at all — a pre-existing parity gap, not introduced by this PR.

github run

…ceholder-load

# Conflicts:
#	packages/vinext/src/shims/image.tsx
@james-elicx james-elicx merged commit ba1829a into cloudflare:main May 16, 2026
26 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