Skip to content

Commit 65eab27

Browse files
committed
fix: address LCP regression on PLP caused by responsive image densities @W-18960755
1 parent 955aac1 commit 65eab27

File tree

8 files changed

+378
-46
lines changed

8 files changed

+378
-46
lines changed

packages/template-retail-react-app/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
- Provide base image for convenient perf optimizations [#2642](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2642)
1111
- Support saving billing phone number on user registration from order confirmation [#2653](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2653)
1212
- Support saving default shipping address on user registration from order confirmation [#2706](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2706)
13+
- Support influencing density factors of responsive images [#2724](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2724)
1314

1415
## v6.1.0 (May 22, 2025)
1516

packages/template-retail-react-app/app/components/dynamic-image/index.jsx

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,14 @@ import {getResponsiveImageAttributes} from '@salesforce/retail-react-app/app/uti
2020
* <DynamicImage src="http://example.com/image.jpg[?sw={width}&q=60]" widths={['50vw', '100vw', '500px']} />
2121
* @see {@link https://help.salesforce.com/s/articleView?id=cc.b2c_image_transformation_service.htm&type=5}
2222
*/
23-
const DynamicImage = ({src, widths, imageProps, as, ...rest}) => {
23+
const DynamicImage = ({src, widths, densities, imageProps, as, ...rest}) => {
2424
const Component = as ? as : Image
2525
const theme = useTheme()
2626

2727
const responsiveImageProps = useMemo(
28-
() => getResponsiveImageAttributes({src, widths, breakpoints: theme.breakpoints}),
29-
[src, widths, theme.breakpoints]
28+
() =>
29+
getResponsiveImageAttributes({src, widths, densities, breakpoints: theme.breakpoints}),
30+
[src, widths, densities, theme.breakpoints]
3031
)
3132

3233
return (
@@ -45,6 +46,10 @@ DynamicImage.propTypes = {
4546
* Image widths relative to the breakpoints, whose units can either be px or vw or unit-less. They will be mapped to the corresponding `sizes` and `srcSet`.
4647
*/
4748
widths: PropTypes.oneOfType([PropTypes.array, PropTypes.object]),
49+
/**
50+
* Image density factors to apply relative to the breakpoints. Will be mapped to the corresponding `srcSet`.
51+
*/
52+
densities: PropTypes.array,
4853
/**
4954
* Props to pass to the inner image component
5055
*/

packages/template-retail-react-app/app/components/product-tile/index.jsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,7 @@ const ProductTile = (props) => {
195195
product?.image?.link
196196
}[?sw={width}&q=60]`}
197197
widths={dynamicImageProps?.widths}
198+
densities={dynamicImageProps?.densities}
198199
imageProps={{
199200
// treat img as a decorative item, we don't need to pass `image.alt`
200201
// since it is the same as product name

packages/template-retail-react-app/app/constants.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export const RECENT_SEARCH_MIN_LENGTH = 3
2929
// Constants for product list page
3030
export const PRODUCT_LIST_IMAGE_VIEW_TYPE = 'medium'
3131
export const PRODUCT_LIST_SELECTABLE_ATTRIBUTE_ID = 'color'
32+
export const PRODUCT_LIST_IMAGE_RESPONSIVE_DENSITIES = [1]
3233

3334
// Constants for product tile page
3435
export const PRODUCT_TILE_IMAGE_VIEW_TYPE = 'medium'

packages/template-retail-react-app/app/pages/product-list/index.jsx

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ import {
9191
STALE_WHILE_REVALIDATE,
9292
PRODUCT_LIST_IMAGE_VIEW_TYPE,
9393
PRODUCT_LIST_SELECTABLE_ATTRIBUTE_ID,
94+
PRODUCT_LIST_IMAGE_RESPONSIVE_DENSITIES,
9495
STORE_LOCATOR_IS_ENABLED
9596
} from '@salesforce/retail-react-app/app/constants'
9697
import useNavigation from '@salesforce/retail-react-app/app/hooks/use-navigation'
@@ -651,10 +652,13 @@ const ProductList = (props) => {
651652
'20vw',
652653
'25vw'
653654
],
654-
// The first two product images should render eagerly to
655-
// ensure prioritized loading for above-the-fold images
656-
// on mobile.
657-
...(index < 2
655+
densities:
656+
PRODUCT_LIST_IMAGE_RESPONSIVE_DENSITIES,
657+
// For the sake of LCP, load the first three product images
658+
// eagerly to ensure prioritized loading for all plus one
659+
// above-the-fold images on mobile and most above-the-fold
660+
// images on tablet and desktop.
661+
...(index < 3
658662
? {
659663
imageProps: {
660664
loading: 'eager'

packages/template-retail-react-app/app/pages/product-list/index.test.js

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,15 +138,15 @@ test('should render product list page', async () => {
138138
})
139139

140140
const helmet = Helmet.peek()
141-
expect(helmet.linkTags).toHaveLength(2)
141+
expect(helmet.linkTags).toHaveLength(3)
142142
expect(helmet.linkTags[0]).toStrictEqual({
143143
as: 'image',
144144
rel: 'preload',
145145
href: 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg',
146146
imageSizes:
147147
'(min-width: 80em) 25vw, (min-width: 62em) 20vw, (min-width: 48em) 20vw, (min-width: 30em) 50vw, 50vw',
148148
imageSrcSet:
149-
'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=198&q=60 198w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=396&q=60 396w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=240&q=60 240w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=480&q=60 480w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=256&q=60 256w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=512&q=60 512w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=384&q=60 384w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=768&q=60 768w'
149+
'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=198&q=60 198w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=240&q=60 240w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=256&q=60 256w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw1c5f9222/images/large/PG.10221626.JJ3WCXX.PZ.jpg?sw=384&q=60 384w'
150150
})
151151
expect(helmet.linkTags[1]).toStrictEqual({
152152
as: 'image',
@@ -155,7 +155,16 @@ test('should render product list page', async () => {
155155
imageSizes:
156156
'(min-width: 80em) 25vw, (min-width: 62em) 20vw, (min-width: 48em) 20vw, (min-width: 30em) 50vw, 50vw',
157157
imageSrcSet:
158-
'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=198&q=60 198w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=396&q=60 396w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=240&q=60 240w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=480&q=60 480w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=256&q=60 256w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=512&q=60 512w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=384&q=60 384w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=768&q=60 768w'
158+
'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=198&q=60 198w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=240&q=60 240w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=256&q=60 256w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw22e88fa3/images/large/PG.10224484.JJ0CZXX.PZ.jpg?sw=384&q=60 384w'
159+
})
160+
expect(helmet.linkTags[2]).toStrictEqual({
161+
as: 'image',
162+
rel: 'preload',
163+
href: 'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwab413b1b/images/large/PG.10243116.JJ2DGXX.PZ.jpg',
164+
imageSizes:
165+
'(min-width: 80em) 25vw, (min-width: 62em) 20vw, (min-width: 48em) 20vw, (min-width: 30em) 50vw, 50vw',
166+
imageSrcSet:
167+
'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwab413b1b/images/large/PG.10243116.JJ2DGXX.PZ.jpg?sw=198&q=60 198w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwab413b1b/images/large/PG.10243116.JJ2DGXX.PZ.jpg?sw=240&q=60 240w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwab413b1b/images/large/PG.10243116.JJ2DGXX.PZ.jpg?sw=256&q=60 256w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dwab413b1b/images/large/PG.10243116.JJ2DGXX.PZ.jpg?sw=384&q=60 384w'
159168
})
160169
})
161170

packages/template-retail-react-app/app/utils/responsive-image.js

Lines changed: 115 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -18,32 +18,56 @@ const getBreakpointLabels = (breakpoints) =>
1818
.map(([key]) => key)
1919

2020
const {breakpoints: defaultBreakpoints} = theme
21+
const defaultDensityFactors = [
22+
[1, 1],
23+
[2, 2]
24+
]
25+
const getEffectiveDensities = (breakpoints) =>
26+
Object.entries(breakpoints)
27+
.sort((a, b) => parseFloat(a[1]) - parseFloat(b[1]))
28+
.map(() => defaultDensityFactors)
29+
2130
let themeBreakpoints = defaultBreakpoints
2231
let breakpointLabels = getBreakpointLabels(themeBreakpoints)
32+
let effectiveDefaultDensities = getEffectiveDensities(defaultBreakpoints)
33+
34+
// Init densities cache
35+
const densitiesCache = new WeakMap()
36+
densitiesCache.set(themeBreakpoints, new WeakMap())
2337

2438
/**
2539
* @param {Object} props
2640
* @param {string} props.src - Dynamic src having an optional param that can vary with widths. For example: `image[_{width}].jpg` or `image.jpg[?sw={width}&q=60]`
2741
* @param {(number[]|string[]|Object)} [props.widths] - Image widths relative to the breakpoints, whose units can either be px or vw or unit-less. They will be mapped to the corresponding `sizes` and `srcSet`.
42+
* @param {(number[]|[number, number][])} [props.densities] - Image density factors to apply relative to the breakpoints. Will be mapped to the corresponding `srcSet`.
2843
* @param {Object} [props.breakpoints] - The current theme's breakpoints. If not given, Chakra's default breakpoints will be used.
2944
* @return {Object} src, sizes, and srcSet props for your image component
3045
*/
31-
export const getResponsiveImageAttributes = ({src, widths, breakpoints = defaultBreakpoints}) => {
46+
export const getResponsiveImageAttributes = ({
47+
src,
48+
widths,
49+
densities,
50+
breakpoints = defaultBreakpoints
51+
}) => {
3252
if (!widths) {
3353
return {
3454
src: getSrcWithoutOptionalParams(src)
3555
}
3656
}
3757

38-
themeBreakpoints = breakpoints
39-
breakpointLabels = getBreakpointLabels(themeBreakpoints)
58+
if (breakpoints !== themeBreakpoints) {
59+
!densitiesCache.has(breakpoints) && densitiesCache.set(breakpoints, new WeakMap())
60+
themeBreakpoints = breakpoints
61+
breakpointLabels = getBreakpointLabels(themeBreakpoints)
62+
effectiveDefaultDensities = getEffectiveDensities(themeBreakpoints)
63+
}
4064

4165
// Order of these attributes matter! If src is not last, Safari will refetch images
4266
// multiple times (once when it processes src and again when it processes sizes / srcSet)
4367
// See https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2223
4468
return {
4569
sizes: mapWidthsToSizes(widths),
46-
srcSet: mapWidthsToSrcSet(widths, src),
70+
srcSet: mapWidthsToSrcSet({src, widths, densities}),
4771
src: getSrcWithoutOptionalParams(src)
4872
}
4973
}
@@ -53,7 +77,7 @@ export const getResponsiveImageAttributes = ({src, widths, breakpoints = default
5377
* @return {string}
5478
*/
5579
const mapWidthsToSizes = (widths) => {
56-
const _widths = withUnit(Array.isArray(widths) ? widths : widthsAsArray(widths))
80+
const _widths = withUnit(Array.isArray(widths) ? widths : breakpointMapAsArray(widths))
5781

5882
return breakpointLabels
5983
.slice(0, _widths.length)
@@ -64,29 +88,90 @@ const mapWidthsToSizes = (widths) => {
6488
.join(', ')
6589
}
6690

91+
function padArray(arr) {
92+
const l1 = arr.length
93+
const l2 = breakpointLabels.length
94+
if (l1 < l2) {
95+
const lastEntry = arr.at(-1)
96+
const amountToPad = l2 - l1
97+
return [...arr, ...Array(amountToPad).fill(lastEntry)]
98+
}
99+
return arr
100+
}
101+
67102
/**
68-
* @param {(number[]|string[]|Object)} widths
69-
* @return {string}
103+
* @param {(number[]|[number, number][])} densities
104+
* @param {number[][][]} [collector]
105+
* @returns {number[][][]}
70106
*/
71-
const mapWidthsToSrcSet = (widths, dynamicSrc) => {
72-
let _widths = isObject(widths) ? widthsAsArray(widths) : widths.slice(0)
73-
74-
if (_widths.length < breakpointLabels.length) {
75-
const lastWidth = _widths[_widths.length - 1]
76-
const amountToPad = breakpointLabels.length - _widths.length
107+
function mapDensities(densities, collector) {
108+
const flat = Array.isArray(collector)
109+
return densities.reduce(
110+
(acc, entry) => {
111+
if (!flat && Array.isArray(entry) && Array.isArray(entry.at(0))) {
112+
acc.push(mapDensities(entry, []))
113+
} else if (
114+
Array.isArray(entry) &&
115+
typeof entry.at(0) === 'number' &&
116+
!Number.isNaN(entry.at(0))
117+
) {
118+
const w = entry.at(0)
119+
const f = entry.at(1)
120+
const result = [w, typeof f === 'number' && !Number.isNaN(f) ? f : w]
121+
acc.push(flat ? result : [result])
122+
} else if (typeof entry === 'number' && !Number.isNaN(entry)) {
123+
const result = [entry, entry]
124+
acc.push(flat ? result : [result])
125+
} else if (flat) {
126+
acc.push(...defaultDensityFactors)
127+
} else {
128+
acc.push(defaultDensityFactors)
129+
}
130+
return acc
131+
},
132+
flat ? collector : []
133+
)
134+
}
77135

78-
_widths = [..._widths, ...Array(amountToPad).fill(lastWidth)]
136+
function obtainDensities(densities) {
137+
const cache = densitiesCache.get(themeBreakpoints)
138+
if (cache?.has(densities)) {
139+
return densitiesCache.get(themeBreakpoints).get(densities)
140+
} else if (isObject(densities)) {
141+
const _densities = mapDensities(padArray(breakpointMapAsArray(densities)))
142+
cache?.set(densities, _densities)
143+
return _densities
144+
} else if (Array.isArray(densities)) {
145+
const _densities = mapDensities(padArray(densities))
146+
cache?.set(densities, _densities)
147+
return _densities
79148
}
149+
return effectiveDefaultDensities
150+
}
80151

81-
_widths = uniqueArray(convertToPxNumbers(_widths)).sort()
82-
83-
const srcSet = []
84-
_widths.forEach((width) => {
85-
srcSet.push(width)
86-
srcSet.push(width * 2) // for devices with higher pixel density
87-
})
152+
/**
153+
* @param {Object} props
154+
* @param {string} props.src
155+
* @param {(number[]|string[]|Object)} props.widths
156+
* @param {(number[]|[number, number][])} props.densities
157+
* @return {string}
158+
*/
159+
const mapWidthsToSrcSet = ({src, widths, densities}) => {
160+
let _widths = isObject(widths) ? breakpointMapAsArray(widths) : widths.slice(0)
161+
_widths = uniqueArray(convertToPxNumbers(padArray(_widths))).sort()
88162

89-
return srcSet.map((imageWidth) => `${getSrc(dynamicSrc, imageWidth)} ${imageWidth}w`).join(', ')
163+
// Request images using given or default density factors. By default, images are requested with factors 1 and 2.
164+
const _densities = obtainDensities(densities)
165+
const set = _widths.reduce((acc, width, idx) => {
166+
const densityFactors = _densities.at(idx)
167+
for (const [w, f] of densityFactors) {
168+
const w1 = Math.round(width * w)
169+
const w2 = Math.round(width * f)
170+
acc.add(`${getSrc(src, w2)} ${w1}w`)
171+
}
172+
return acc
173+
}, new Set())
174+
return [...set].join(', ')
90175
}
91176

92177
const vwValue = /^\d+vw$/
@@ -140,22 +225,21 @@ const withUnit = (widths) =>
140225
const isObject = (o) => o?.constructor === Object
141226

142227
/**
143-
* @param {Object} widths
228+
* @param {Object} map
144229
* @example
145230
* // returns the array [10, 10, 10, 50]
146-
* widthsAsArray({base: 10, lg: 50})
231+
* breakpointMapAsArray({base: 10, lg: 50})
147232
*/
148-
const widthsAsArray = (widths) => {
149-
const biggestBreakpoint = breakpointLabels.filter((bp) => Boolean(widths[bp])).pop()
233+
const breakpointMapAsArray = (map) => {
234+
const biggestBreakpoint = breakpointLabels.filter((bp) => Boolean(map[bp])).pop()
150235

151236
let mostRecent
152237
return breakpointLabels.slice(0, breakpointLabels.indexOf(biggestBreakpoint) + 1).map((bp) => {
153-
if (widths[bp]) {
154-
mostRecent = widths[bp]
155-
return widths[bp]
156-
} else {
157-
return mostRecent
238+
if (map[bp]) {
239+
mostRecent = map[bp]
240+
return map[bp]
158241
}
242+
return mostRecent
159243
})
160244
}
161245

0 commit comments

Comments
 (0)