fix(image): emit x-descriptor srcSet for fixed-size images without sizes (#1966)#2062
fix(image): emit x-descriptor srcSet for fixed-size images without sizes (#1966)#2062Divkix wants to merge 1 commit into
Conversation
commit: |
|
/bigbonk review for issues |
There was a problem hiding this comment.
Reviewed against the authoritative Next.js source (packages/next/src/shared/lib/get-img-props.ts, getWidths + generateImgAttrs).
The core fix is correct. The no-sizes x-descriptor path faithfully mirrors Next.js:
[width, width*2].map(w => ALL_SIZES.find(p => p >= w) ?? largest)matches Next'sallSizes.find(p => p >= w) || allSizes[last].ALL_SIZES = deviceSizes ∪ imageSizessorted ascending matches Next'sallSizes.src = loader(widths[last])matches Next (largest candidate), and reading__VINEXT_IMAGE_SIZESis wired correctly (index.ts:1385,deploy.ts:491, defaults matchDEFAULT_IMAGE_SIZES).
The "latent 400" claim is verified. parseImageParams rejects any w not in allowedWidths (server/image-optimization.ts:142), so the old fallback src with the raw declared width (e.g. w=100) would 400. The new quantized width always lands in the allowlist. Good catch.
Two non-blocking notes below. Overall LGTM.
| ): { widths: number[]; kind: "w" | "x" } { | ||
| if (sizes) { | ||
| const widths = RESPONSIVE_WIDTHS.filter((w) => w <= originalWidth * 2); | ||
| return { widths: widths.length ? widths : [originalWidth], kind: "w" }; |
There was a problem hiding this comment.
Non-blocking parity note: this sizes-present branch does not mirror Next.js's getWidths. Next parses the vw percentages out of the sizes string and returns allSizes.filter(s => s >= deviceSizes[0] * smallestRatio) (or the full allSizes when no vw is present), and it does not cap at 2x. Here it returns deviceSizes-only filtered by w <= originalWidth * 2.
This divergence is pre-existing (the old generateSrcSet did the same), so it's out of scope for this PR. But the PR description states it "mirror[s] Next.js getWidths" and that the sizes path is "kept" / "already correct" — that's only true for the no-sizes path you actually changed. Consider softening that wording, or filing a follow-up for the sizes-path divergence so it isn't lost.
| .join(", ") || `${src} ${imgWidth}w` | ||
| : undefined; | ||
| ? buildResponsiveSrc(src, imgWidth, imgQuality, sizes) | ||
| : null; |
There was a problem hiding this comment.
Heads up: routing the sizes-present case through buildResponsiveSrc also changes the fallback src for that path. Previously src = imageOptimizationUrl(src, imgWidth, ...) (the declared width); now it's widths[last] from the sizes branch (largest device width <= imgWidth*2). e.g. width=500 + sizes was w=500, now w=828.
This is arguably closer to Next.js (which always uses widths[last]), so it's likely an improvement — but it's an undocumented behavior change beyond the no-sizes fix described in the PR, and the existing sizes-prop test (image-component.test.ts:165) only asserts the sizes attribute, not the srcSet/src. Worth a one-line mention in the PR body and ideally a test asserting the srcSet+src for the sizes-provided case so this path is no longer untested.
|
Review posted successfully to PR #2062. SummaryI reviewed PR #2062 against the authoritative Next.js source ( Conclusion: LGTM (with 2 non-blocking notes). What's correct:
Two non-blocking notes I flagged inline:
|
|
/bigbonk review for issues |
There was a problem hiding this comment.
Independently verified the core fix against the authoritative Next.js source (packages/next/src/shared/lib/get-img-props.ts → getWidths + generateImgAttrs). The no-sizes x-descriptor path is correct and the fix is sound.
What I confirmed:
[width, width*2].map(w => ALL_SIZES.find(p => p >= w) ?? largest)is a faithful port of Next'sallSizes.find(p => p >= w) || allSizes[last], including thenew Setdedup andkind: 'x'.ALL_SIZES = deviceSizes ∪ imageSizessorted ascending matches Next'sallSizes.src = loader(widths[last])matches Next (largest candidate). Thesizesattribute staysundefinedfor the fixed/no-sizescase and only becomes100vwforfill— matches Next's!sizes && kind === 'w' ? '100vw' : sizes(our fixed path iskind === 'x', so no100vw).- The "latent 400" claim is verified.
parseImageParamsreturnsnull(→ 400) for anywnot inallowedWidths(server/image-optimization.ts:142), andallowedWidthsis the samedeviceSizes ∪ imageSizesunion. The old fallbacksrcused the raw declared width (e.g.w=100/w=500), which is not in the allowlist, so it would 400. Good catch. __VINEXT_IMAGE_SIZESwiring is correct (index.ts:1385,deploy.ts:491,global.d.ts:458) and defaults matchDEFAULT_IMAGE_SIZES. The shim previously never read it.image-component.test.ts60/60 andshims.test.tsimage cases pass locally.
Adding one point that strengthens the PR beyond what the description claims: routing the sizes-present case through buildResponsiveSrc also fixes a latent 400 there. Previously that path set src = imageOptimizationUrl(src, imgWidth) with the raw declared width (e.g. width=500 → w=500), and 500 is not in allowedWidths either — so it would 400 today. The new widths[last] (828 for width=500) always lands on an allowed width. Worth calling out in the PR body since it's a second real fix, not just a cosmetic change.
Non-blocking notes below. LGTM.
| ): { widths: number[]; kind: "w" | "x" } { | ||
| if (sizes) { | ||
| const widths = RESPONSIVE_WIDTHS.filter((w) => w <= originalWidth * 2); | ||
| return { widths: widths.length ? widths : [originalWidth], kind: "w" }; |
There was a problem hiding this comment.
Confirming the prior note: this sizes-present branch does not mirror Next's getWidths. Next parses the vw percentages from the sizes string and returns allSizes.filter(s => s >= deviceSizes[0] * smallestRatio) (or the full allSizes when no vw is present), with no 2x cap. Here it returns deviceSizes-only filtered by w <= originalWidth*2.
This is pre-existing (old generateSrcSet did the same), so out of scope — but the PR description's "mirror[s] Next.js getWidths" / sizes path "already correct" wording only holds for the no-sizes path you actually changed. Suggest softening the wording or filing a follow-up so the divergence isn't lost.
Also note the empty-filter fallback [originalWidth]: for a tiny sizes-present image (e.g. width=10), widths[last] would be 10, which is not in allowedWidths and would 400. Edge case, but it's the same class of bug this PR is fixing for the no-sizes path.
| .join(", ") || `${src} ${imgWidth}w` | ||
| : undefined; | ||
| ? buildResponsiveSrc(src, imgWidth, imgQuality, sizes) | ||
| : null; |
There was a problem hiding this comment.
Routing the sizes-present case through buildResponsiveSrc silently changes the fallback src for that path (declared width → widths[last]), e.g. width=500 + sizes goes from w=500 to w=828. As noted in the summary this is actually a fix (the old w=500 would 400), but it's untested — the existing sizes-prop test (tests/image-component.test.ts:165) only asserts the sizes attribute, not src/srcSet.
Worth adding an assertion so this path is covered:
| : null; | |
| it("renders with custom sizes prop", () => { | |
| const html = ReactDOMServer.renderToString( | |
| React.createElement(Image, { | |
| alt: "sized", | |
| src: "/img.png", | |
| width: 500, | |
| height: 300, | |
| sizes: "(max-width: 768px) 100vw, 50vw", | |
| }), | |
| ); | |
| expect(html).toContain('sizes="(max-width: 768px) 100vw, 50vw"'); | |
| // `sizes` present -> w-descriptor srcSet; fallback src is the largest | |
| // candidate (<= width*2 = 1000 -> 828), always an allowed width. | |
| expect(html).toContain(`src="${optUrlHtml("/img.png", 828)}"`); | |
| }); |
(Adjust the line number target if image-component.test.ts:165 shifted.)
|
Review posted successfully to PR #2062. SummaryI independently verified PR #2062 against the authoritative Next.js source ( Conclusion: LGTM (with 2 non-blocking notes). Verified correct:
New finding that strengthens the PR (beyond the existing review notes):
Two non-blocking inline notes:
|
Performance benchmarksCompared 0 improved · 0 regressed · 6 within ±1.5%
View detailed results and traces 🟢 improvement · 🔴 regression · ⚫ change below 1.5% · paired base/head |
|
Both nits from the review are addressed:
|
…zes (cloudflare#1966) For a fixed-size <Image> with a numeric `width` and no `sizes`, the shim emitted a w-descriptor srcSet and left `sizes` undefined. Per the HTML spec, a w-descriptor srcSet with no `sizes` defaults to `sizes=100vw`, so the browser fetches a viewport-sized image instead of an element-sized one — over-fetching on high-DPR/large viewports and hurting LCP. Mirror Next.js getWidths: when there is no `sizes` prop and a numeric width, emit a 1x/2x x-descriptor srcSet built from [width, width*2] quantized up to the nearest allowed size (deviceSizes ∪ imageSizes). w-descriptors are kept for the `sizes`-provided and `fill` paths. Also set the fallback `src` to the largest srcSet candidate (Next.js sets `src = loader(widths[last])`). This fixes a latent 400: the old `src` used the raw declared width, which is rejected by the optimizer when it is not in the allowed-widths list. - read images.imageSizes (already injected as __VINEXT_IMAGE_SIZES) to build the allSizes union used for x-descriptor quantization - replace generateSrcSet with getImageWidths + buildResponsiveSrc, wired into both the component render and getImageProps - update tests to the Next.js-parity output and add a regression test (width 100 → "w=128 1x, w=256 2x", src w=256, no sizes attribute)
8508baf to
867fd17
Compare
Problem
Closes #1966.
For a fixed-size
<Image width={N} />with nosizesprop, the shim emitted a w-descriptor srcSet (… 640w, 750w, …) and left thesizesattribute undefined. Per the HTML spec, a w-descriptor srcSet with nosizesdefaults tosizes=100vw, so the browser selects an image sized to the full viewport width rather than the element width — over-fetching on high-DPR / large-viewport displays (e.g. a 1000px image fetched at 1920w) and hurting LCP. Next.js handles this with anx-descriptor (1x/2x) srcSet.Fix
Mirror Next.js
getWidths(get-img-props.ts):sizes+ numeric width → x-descriptor srcSet. Build[width, width*2]quantized up to the nearest allowed size fromallSizes(deviceSizes ∪ imageSizes), deduped, emitted as1x/2x. e.g.width=100→w=128 1x, w=256 2x.sizes-provided andfillpaths. Thesizesattribute logic was already correct (fill→100vw), so it is unchanged.images.imageSizes(already injected as__VINEXT_IMAGE_SIZES, but the shim never read it) to build theallSizesunion used for quantization.srcnow uses the largest srcSet candidate (Next.js setssrc = loader(widths[last])). This also fixes a pre-existing latent 400: the oldsrcused the raw declared width, which the optimizer rejects when it isn't in the allowed-widths list (e.g.w=100).Implemented by replacing
generateSrcSetwithgetImageWidths+buildResponsiveSrc, wired into both the component render andgetImageProps.Edge cases
Width clamping past the largest size, dedup to a single
1xentry, tiny widths (16→16 1x, 32 2x), quality propagation, and custom-configured device/image sizes.Test plan
width:100, height:200, nosizes→ srcSetw=128 1x, w=256 2x,src=w=256, nosizesattribute.tests/image-component.test.ts,tests/shims.test.ts, andtests/image-optimization-parity.test.ts.image-component.test.ts60/60 ·shims.test.ts1130/1130 · full unit suite green · integrationimage-optimization-parity6/6 (confirms the server serves the neww=128src with a 200).