Skip to content

Commit 05438c6

Browse files
authored
fix(cms-base-layer): proper image sizes (#2389)
1 parent 7312058 commit 05438c6

5 files changed

Lines changed: 94 additions & 116 deletions

File tree

.changeset/frank-yaks-fry.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@shopware/cms-base-layer": patch
3+
---
4+
5+
Fix product card and CMS image sizing to prevent duplicate/oversized image requests. Move fixed dimensions and `densities="1x"` into the `productCard` preset, and use `useElementSize`-based `width`/`height` props for `CmsElementImage`.

packages/cms-base-layer/AGENTS.md

Lines changed: 40 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -395,12 +395,47 @@ Set `format` or `quality` to `undefined` to omit that parameter. Requires remote
395395

396396
## Responsive Image Architecture
397397

398-
CMS images use a provide/inject pattern for responsive sizing:
398+
### Product Card Images (`SwProductCardImage`)
399399

400-
1. **`CmsGenericBlock`** counts slots, calls `provide("cms-block-slot-count", slotCount)` and `provide("cms-image-sizes", getImageSizes(slotCount, appConfig.imageSizes))`
401-
2. **`CmsElementImage`** calls `inject("cms-image-sizes", "100vw")` and passes it to `<NuxtImg :sizes="...">`
402-
3. If media has thumbnails → native `srcset` from `getSrcSetForMedia()`. If not → synthetic `srcset` via `generateCdnSrcSet()` from `@shopware/helpers`
403-
4. **`useLcpImagePreload`** scans CMS sections for the first image and injects `<link rel="preload" as="image" fetchpriority="high">` during SSR
400+
The `productCard` preset only defines URL modifiers (format/quality/fit). `width`/`height`/`densities`/`loading` must stay on the component — NuxtImg presets don't propagate these reliably:
401+
402+
```ts
403+
// nuxt.config.ts
404+
productCard: {
405+
modifiers: { format: "webp", quality: 90, fit: "cover" },
406+
}
407+
```
408+
409+
```vue
410+
<NuxtImg preset="productCard"
411+
:src="coverSrcPath"
412+
width="400" height="400"
413+
densities="1x"
414+
loading="lazy" />
415+
```
416+
417+
- Fixed `width`/`height` (400) avoid hydration mismatches caused by dynamic DOM measurement
418+
- `densities="1x"` prevents duplicate retina requests
419+
- `loading="lazy"` defers off-viewport images
420+
421+
> **Note:** Avoid adding `decoding` or `sizes` props on the component — they've caused Vue hydration attribute mismatches with NuxtImg, which trigger duplicate image requests.
422+
423+
### CMS Images (`CmsElementImage`)
424+
425+
CMS images use `useElementSize()` to measure the rendered container and pass the size to NuxtImg via `width`/`height` props:
426+
427+
```vue
428+
<NuxtImg :width="imageSize" :height="imageSize" :src="imageAttrs.src" loading="lazy" />
429+
```
430+
431+
- Returns `undefined` during SSR (no image fetched until client measurement)
432+
- After hydration, `useElementSize()` measures the container and NuxtImg fetches the correctly sized image
433+
- The size is multiplied by 2 (for retina) and rounded up to the nearest 100px
434+
435+
### Other patterns
436+
437+
1. **`CmsGenericBlock`** counts slots, calls `provide("cms-block-slot-count", slotCount)`
438+
2. **`useLcpImagePreload`** scans CMS sections for the first image and injects `<link rel="preload" as="image" fetchpriority="high">` during SSR
404439

405440
### Type Declarations
406441

packages/cms-base-layer/README.md

Lines changed: 28 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -372,68 +372,48 @@ The preload URL includes the optimized `format` and `quality` parameters from `a
372372

373373
## Responsive CMS Images
374374

375-
CMS image elements (`CmsElementImage`) automatically serve appropriately-sized images using responsive `srcset` and `sizes` attributes. This prevents the browser from downloading images larger than their displayed dimensions — a common Lighthouse performance issue.
375+
Images are optimized to prevent the browser from downloading images larger than their displayed dimensions — a common Lighthouse performance issue.
376376

377-
### How it works
378-
379-
1. **`CmsGenericBlock`** counts the number of slots in each block, `provide`s a responsive `sizes` value (e.g., a 2-slot block means images are ~50% viewport width on desktop), and `provide`s the slot count via `cms-block-slot-count` for slider SSR breakpoint scaling.
380-
2. **`CmsElementImage`** `inject`s the sizes hint and applies it to `<NuxtImg>`.
381-
3. If the media has **pre-generated thumbnails** from Shopware, the existing `srcset` from thumbnails is used.
382-
4. If **no thumbnails** exist, a synthetic `srcset` is generated using CDN width-based resizing (`?width=400`, `?width=800`, etc.) via the `generateCdnSrcSet` helper from `@shopware/helpers`.
383-
5. **Slider components** (`CmsElementProductSlider`, `CmsElementCrossSelling`) `inject` the slot count to scale their SSR breakpoints — ensuring media queries account for the container being a fraction of the viewport (e.g., a 2-slot block doubles the breakpoint thresholds).
384-
385-
The browser combines `sizes` + `srcset` to download only the image size it actually needs — during HTML parsing, before any JavaScript runs.
386-
387-
### Configuration
377+
### Product Card Images (`SwProductCardImage`)
388378

389-
Default slot-count-to-sizes mappings are set in `app.config.ts` and can be overridden:
379+
The `productCard` preset only defines URL modifiers (format/quality/fit). `width`/`height`/`densities`/`loading` stay on the component — NuxtImg presets don't propagate these reliably:
390380

391381
```ts
392-
export default defineAppConfig({
393-
imageSizes: {
394-
// slot count → sizes attribute value
395-
1: "(max-width: 768px) 100vw, 100vw", // full-width blocks
396-
2: "(max-width: 768px) 100vw, 50vw", // two-column blocks (e.g., image-text)
397-
3: "(max-width: 768px) 100vw, 33vw", // three-column blocks
398-
default: "(max-width: 768px) 50vw, 25vw", // 4+ columns
399-
},
400-
});
382+
// nuxt.config.ts
383+
productCard: {
384+
modifiers: { format: "webp", quality: 90, fit: "cover" },
385+
}
401386
```
402387

403-
For example, to cap image sizes at a fixed pixel width for boxed layouts:
404-
405-
```ts
406-
export default defineAppConfig({
407-
imageSizes: {
408-
1: "(max-width: 768px) 100vw, 1200px",
409-
2: "(max-width: 768px) 100vw, 600px",
410-
3: "(max-width: 768px) 100vw, 400px",
411-
default: "(max-width: 768px) 50vw, 300px",
412-
},
413-
});
388+
```vue
389+
<NuxtImg preset="productCard"
390+
:src="coverSrcPath"
391+
width="400" height="400"
392+
densities="1x"
393+
loading="lazy" />
414394
```
415395

416-
### Per-block override
396+
- **Fixed `width`/`height`** (400px) — avoid hydration mismatches caused by dynamic DOM measurement
397+
- **`densities="1x"`** — prevents duplicate retina requests
398+
- **`loading="lazy"`** — defers off-viewport images
417399

418-
Individual block components can override the sizes value by calling `provide("cms-image-sizes", "custom value")` — this takes precedence over the default from `CmsGenericBlock`.
400+
> **⚠️ Avoid** adding `decoding` or `sizes` props on the component — they trigger Vue hydration attribute mismatches with NuxtImg, which cause duplicate image requests.
419401
420-
### Synthetic srcset fallback
402+
### CMS Images (`CmsElementImage`)
421403

422-
When Shopware media has no thumbnails (common in Cloud/SaaS setups using CDN-based resizing), the layer generates a synthetic `srcset` using CDN query parameters:
404+
CMS image elements use `useElementSize()` to measure the rendered container and pass the size to `<NuxtImg>` via `width`/`height` props:
423405

424-
```html
425-
<img srcset="
426-
...image.jpg?width=400&fit=crop,smart&format=webp&quality=90 400w,
427-
...image.jpg?width=800&fit=crop,smart&format=webp&quality=90 800w,
428-
...image.jpg?width=1200&fit=crop,smart&format=webp&quality=90 1200w,
429-
...image.jpg?width=1600&fit=crop,smart&format=webp&quality=90 1600w"
430-
sizes="(max-width: 768px) 100vw, 50vw"
431-
>
432-
```
406+
- During SSR, no image is fetched (size is `undefined`)
407+
- After hydration, the container is measured and a single correctly-sized image is requested
408+
- The size is multiplied by 2 (for retina) and rounded up to the nearest 100px
409+
410+
### Slider Components
411+
412+
**Slider components** (`CmsElementProductSlider`, `CmsElementCrossSelling`) `inject` the slot count via `cms-block-slot-count` to scale their SSR breakpoints — ensuring media queries account for the container being a fraction of the viewport.
433413

434-
The `format` and `quality` values are taken from the `backgroundImage` config in `app.config.ts`.
414+
### LCP Image Preloading
435415

436-
> **Note:** Synthetic srcset requires CDN-based image resizing support. See the [Image Optimization](#%EF%B8%8F-image-optimization) section for requirements.
416+
**`useLcpImagePreload`** scans CMS sections for the first image and injects `<link rel="preload" as="image" fetchpriority="high">` during SSR.
437417

438418
## 🔄 UnoCSS Runtime
439419

packages/cms-base-layer/app/components/SwProductCardImage.vue

Lines changed: 9 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,7 @@ import {
55
isProductOnSale,
66
isProductTopSeller,
77
} from "@shopware/helpers";
8-
import { useElementSize } from "@vueuse/core";
9-
import { computed, useTemplateRef } from "vue";
10-
import { useImagePlaceholder } from "#imports";
8+
import { computed } from "vue";
119
import type { Schemas } from "#shopware";
1210
1311
type Translations = {
@@ -27,49 +25,31 @@ const props = defineProps<{
2725
productLink: UrlRouteOutput;
2826
}>();
2927
30-
const containerElement = useTemplateRef<HTMLDivElement>("containerElement");
31-
const { width, height } = useElementSize(containerElement);
32-
33-
const DEFAULT_THUMBNAIL_SIZE = 10;
34-
function roundUp(num: number) {
35-
return num ? Math.ceil(num / 100) * 100 : DEFAULT_THUMBNAIL_SIZE;
36-
}
37-
3828
const coverSrcPath = computed(() => {
3929
return (
40-
getSmallestThumbnailUrl(props.product?.cover?.media) ||
41-
props.product?.cover?.media?.url
30+
props.product?.cover?.media?.url ||
31+
getSmallestThumbnailUrl(props.product?.cover?.media)
4232
);
4333
});
4434
45-
const imageModifiers = computed(() => {
46-
// Use the larger dimension and apply 2x for high-DPI displays
47-
// For square containers, width and height should be the same
48-
const containerSize = Math.max(width.value || 0, height.value || 0);
49-
const size = roundUp(containerSize * 2);
50-
return {
51-
width: size,
52-
height: size,
53-
};
54-
});
55-
5635
const coverAlt = computed(() => {
5736
return props.product?.cover?.media?.alt || props.product?.translated?.name;
5837
});
5938
6039
const isOnSale = computed(() => isProductOnSale(props.product));
6140
const isTopseller = computed(() => isProductTopSeller(props.product));
62-
63-
const placeholderSvg = useImagePlaceholder();
6441
</script>
6542

6643
<template>
67-
<div ref="containerElement" class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square">
44+
<div class="self-stretch min-h-[350px] relative flex flex-col justify-start items-start overflow-hidden aspect-square">
6845
<RouterLink :to="productLink" class="self-stretch h-full relative overflow-hidden">
6946
<NuxtImg preset="productCard"
7047
class="w-full h-full absolute top-0 left-0 object-cover"
71-
:placeholder="placeholderSvg"
72-
:src="coverSrcPath" :alt="coverAlt" :modifiers="imageModifiers" data-testid="product-box-img" />
48+
:src="coverSrcPath" :alt="coverAlt"
49+
width="400" height="400"
50+
densities="1x"
51+
loading="lazy"
52+
data-testid="product-box-img" />
7353
</RouterLink>
7454

7555
<div v-if="isTopseller || isOnSale"

packages/cms-base-layer/app/components/public/cms/element/CmsElementImage.vue

Lines changed: 12 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -3,15 +3,10 @@ import type {
33
CmsElementImage,
44
CmsElementManufacturerLogo,
55
} from "@shopware/composables";
6-
import {
7-
buildCdnImageUrl,
8-
buildUrlPrefix,
9-
generateCdnSrcSet,
10-
} from "@shopware/helpers";
6+
import { buildUrlPrefix } from "@shopware/helpers";
117
import { useElementSize } from "@vueuse/core";
12-
import { computed, defineAsyncComponent, inject, useTemplateRef } from "vue";
8+
import { computed, defineAsyncComponent, useTemplateRef } from "vue";
139
import { useCmsElementImage, useUrlResolver } from "#imports";
14-
import { useTypedAppConfig } from "../../../../composables/useTypedAppConfig";
1510
import { isSpatial } from "../../../../helpers/media/isSpatial";
1611
1712
const props = defineProps<{
@@ -30,34 +25,17 @@ const {
3025
mimeType,
3126
} = useCmsElementImage(props.content);
3227
33-
const imageSizes = inject<string>("cms-image-sizes", "100vw");
34-
const appConfig = useTypedAppConfig();
35-
3628
const imageElement = useTemplateRef<HTMLImageElement>("imageElement");
3729
const { width, height } = useElementSize(imageElement);
3830
39-
const cdnOptions = computed(() => ({
40-
format: appConfig.backgroundImage?.format,
41-
quality: appConfig.backgroundImage?.quality,
42-
}));
43-
44-
const srcSet = computed(
45-
() =>
46-
imageAttrs.value.srcset ||
47-
generateCdnSrcSet(imageAttrs.value.src, undefined, cdnOptions.value),
48-
);
31+
function roundUp(num: number) {
32+
return Math.ceil(num / 100) * 100;
33+
}
4934
50-
const srcPath = computed(() => {
51-
// Only add dimension params after mount to avoid hydration mismatch
52-
// (useElementSize returns 0 during SSR). The srcset handles responsive loading.
53-
if (width.value || height.value) {
54-
return buildCdnImageUrl(
55-
imageAttrs.value.src,
56-
{ width: width.value, height: height.value },
57-
cdnOptions.value,
58-
);
59-
}
60-
return imageAttrs.value.src || "";
35+
const imageSize = computed(() => {
36+
const containerSize = Math.max(width.value || 0, height.value || 0);
37+
if (!containerSize) return undefined;
38+
return roundUp(containerSize * 2);
6139
});
6240
6341
const imageComputedContainerAttrs = computed(() => {
@@ -111,7 +89,8 @@ const SwMedia3D = computed(() => {
11189
ref="imageElement"
11290
preset="productDetail"
11391
loading="lazy"
114-
:sizes="imageSizes"
92+
:width="imageSize"
93+
:height="imageSize"
11594
:class="{
11695
'w-full': !imageGallery,
11796
'h-full': !imageGallery && ['cover', 'stretch'].includes(displayMode),
@@ -121,8 +100,7 @@ const SwMedia3D = computed(() => {
121100
'object-contain': imageGallery || displayMode !== 'cover',
122101
}"
123102
:alt="imageAttrs.alt"
124-
:src="srcPath"
125-
:srcset="srcSet"
103+
:src="imageAttrs.src"
126104
/>
127105
</component>
128106
</template>

0 commit comments

Comments
 (0)