Skip to content

Commit 0c02e08

Browse files
Merge branch 'develop' into feature/shop-in-store
2 parents 541a00e + 2ca3bd0 commit 0c02e08

File tree

13 files changed

+754
-64
lines changed

13 files changed

+754
-64
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
- Update latest translations for all languages [#2616](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2616)
88
- Added support for Buy Online Pick up In Store (BOPIS) [#2646](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2646)
99
- Load active data scripts on demand only [#2623](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2623)
10+
- Provide base image for convenient perf optimizations [#2642](https://github.com/SalesforceCommerceCloud/pwa-kit/pull/2642)
1011

1112
## v6.1.0 (May 22, 2025)
1213

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,20 +6,22 @@
66
*/
77
import React, {useMemo} from 'react'
88
import PropTypes from 'prop-types'
9-
import {Img, Box, useTheme} from '@salesforce/retail-react-app/app/components/shared/ui'
9+
import {Box, useTheme} from '@salesforce/retail-react-app/app/components/shared/ui'
10+
import Image from '@salesforce/retail-react-app/app/components/image'
1011
import {getResponsiveImageAttributes} from '@salesforce/retail-react-app/app/utils/responsive-image'
1112

1213
/**
13-
* Quickly create a responsive image using your dynamic image service
14+
* Quickly create a responsive image using your Dynamic Imaging Service
1415
* @example
1516
* // Widths without a unit are interpreted as px values
1617
* <DynamicImage src="http://example.com/image.jpg[?sw={width}&q=60]" widths={[100, 360, 720]} />
1718
* <DynamicImage src="http://example.com/image.jpg[?sw={width}&q=60]" widths={{base: 100, sm: 360, md: 720}} />
1819
* // You can also use units of px or vw
1920
* <DynamicImage src="http://example.com/image.jpg[?sw={width}&q=60]" widths={['50vw', '100vw', '500px']} />
21+
* @see {@link https://help.salesforce.com/s/articleView?id=cc.b2c_image_transformation_service.htm&type=5}
2022
*/
2123
const DynamicImage = ({src, widths, imageProps, as, ...rest}) => {
22-
const Component = as ? as : Img
24+
const Component = as ? as : Image
2325
const theme = useTheme()
2426

2527
const responsiveImageProps = useMemo(
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
/* eslint-disable jest/no-conditional-expect */
8+
import React from 'react'
9+
import {Helmet} from 'react-helmet'
10+
import DynamicImage from '@salesforce/retail-react-app/app/components/dynamic-image/index'
11+
import {Img} from '@salesforce/retail-react-app/app/components/shared/ui'
12+
import {renderWithProviders} from '@salesforce/retail-react-app/app/utils/test-utils'
13+
import {isServer} from '@salesforce/retail-react-app/app/components/image/utils'
14+
15+
jest.mock('@salesforce/retail-react-app/app/components/image/utils', () => ({
16+
...jest.requireActual('@salesforce/retail-react-app/app/components/image/utils'),
17+
isServer: jest.fn().mockReturnValue(true)
18+
}))
19+
20+
const src =
21+
'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg'
22+
const imageProps = {
23+
alt: 'Ruffle Front V-Neck Cardigan, large',
24+
title: 'Ruffle Front V-Neck Cardigan'
25+
}
26+
27+
describe('Dynamic Image Component', () => {
28+
test('renders an image without decoding strategy and fetch priority', () => {
29+
const {getAllByTitle} = renderWithProviders(
30+
<DynamicImage src={src} imageProps={imageProps} />
31+
)
32+
const elements = getAllByTitle(imageProps.title)
33+
expect(elements).toHaveLength(1)
34+
expect(elements[0]).not.toHaveAttribute('decoding')
35+
expect(elements[0]).not.toHaveAttribute('fetchpriority')
36+
})
37+
38+
describe('loading="lazy"', () => {
39+
test('renders an image using the default "async" decoding strategy', () => {
40+
const {getAllByTitle} = renderWithProviders(
41+
<DynamicImage
42+
src={src}
43+
imageProps={{
44+
...imageProps,
45+
loading: 'lazy'
46+
}}
47+
/>
48+
)
49+
const elements = getAllByTitle(imageProps.title)
50+
expect(elements).toHaveLength(1)
51+
expect(elements[0]).toHaveAttribute('decoding', 'async')
52+
})
53+
54+
test.each(['sync', 'async', 'auto'])(
55+
'renders an image using an explicit "%s" decoding strategy',
56+
(decoding) => {
57+
const {getAllByTitle} = renderWithProviders(
58+
<DynamicImage
59+
src={src}
60+
imageProps={{
61+
...imageProps,
62+
loading: 'lazy',
63+
decoding
64+
}}
65+
/>
66+
)
67+
const elements = getAllByTitle(imageProps.title)
68+
expect(elements).toHaveLength(1)
69+
expect(elements[0]).toHaveAttribute('decoding', decoding)
70+
}
71+
)
72+
73+
test('renders an image replacing an invalid decoding strategy with the default "async" value', () => {
74+
const {getAllByTitle} = renderWithProviders(
75+
<DynamicImage
76+
src={src}
77+
imageProps={{
78+
...imageProps,
79+
loading: 'lazy',
80+
decoding: 'invalid'
81+
}}
82+
/>
83+
)
84+
const elements = getAllByTitle(imageProps.title)
85+
expect(elements).toHaveLength(1)
86+
expect(elements[0]).toHaveAttribute('decoding', 'async')
87+
})
88+
89+
test('renders an explicitly given image component without attribute modifications', () => {
90+
const {getAllByTitle} = renderWithProviders(
91+
<DynamicImage
92+
src={src}
93+
as={Img}
94+
imageProps={{
95+
...imageProps,
96+
loading: 'lazy'
97+
}}
98+
/>
99+
)
100+
const elements = getAllByTitle(imageProps.title)
101+
expect(elements).toHaveLength(1)
102+
expect(elements[0]).not.toHaveAttribute('decoding')
103+
})
104+
})
105+
106+
describe('loading="eager"', () => {
107+
test('renders an image using the default "high" fetch priority', () => {
108+
const {getAllByTitle} = renderWithProviders(
109+
<DynamicImage
110+
src={src}
111+
imageProps={{
112+
...imageProps,
113+
loading: 'eager'
114+
}}
115+
widths={['50vw', '50vw', '20vw', '20vw', '25vw']}
116+
/>
117+
)
118+
const elements = getAllByTitle(imageProps.title)
119+
expect(elements).toHaveLength(1)
120+
expect(elements[0]).toHaveAttribute('fetchpriority', 'high')
121+
122+
const helmet = Helmet.peek()
123+
expect(helmet.linkTags).toHaveLength(1)
124+
expect(helmet.linkTags[0]).toStrictEqual({
125+
as: 'image',
126+
href: src,
127+
rel: 'preload',
128+
imageSizes:
129+
'(min-width: 80em) 25vw, (min-width: 62em) 20vw, (min-width: 48em) 20vw, (min-width: 30em) 50vw, 50vw',
130+
imageSrcSet:
131+
'https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 198w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 396w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 240w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 480w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 256w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 512w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 384w, https://edge.disstg.commercecloud.salesforce.com/dw/image/v2/ZZRF_001/on/demandware.static/-/Sites-apparel-m-catalog/default/dw4cd0a798/images/large/PG.10216885.JJ169XX.PZ.jpg 768w'
132+
})
133+
})
134+
135+
test.each(['high', 'low', 'auto'])(
136+
'renders an image using an explicit "%s" fetch priority',
137+
(fetchPriority) => {
138+
const {getAllByTitle} = renderWithProviders(
139+
<DynamicImage
140+
src={src}
141+
imageProps={{
142+
...imageProps,
143+
loading: 'eager',
144+
fetchPriority
145+
}}
146+
/>
147+
)
148+
const elements = getAllByTitle(imageProps.title)
149+
expect(elements).toHaveLength(1)
150+
expect(elements[0]).toHaveAttribute('fetchpriority', fetchPriority)
151+
152+
const helmet = Helmet.peek()
153+
if (fetchPriority === 'high') {
154+
expect(helmet.linkTags).toHaveLength(1)
155+
expect(helmet.linkTags[0]).toStrictEqual({
156+
as: 'image',
157+
href: src,
158+
rel: 'preload'
159+
})
160+
} else {
161+
expect(helmet.linkTags).toStrictEqual([])
162+
}
163+
}
164+
)
165+
166+
test('renders an image replacing an invalid fetch priority with the default "auto" value', () => {
167+
const {getAllByTitle} = renderWithProviders(
168+
<DynamicImage
169+
src={src}
170+
imageProps={{
171+
...imageProps,
172+
loading: 'eager',
173+
fetchPriority: 'invalid'
174+
}}
175+
/>
176+
)
177+
const elements = getAllByTitle(imageProps.title)
178+
expect(elements).toHaveLength(1)
179+
expect(elements[0]).toHaveAttribute('fetchpriority', 'auto')
180+
expect(Helmet.peek().linkTags).toStrictEqual([])
181+
})
182+
183+
test('renders an explicitly given image component without modifications', () => {
184+
const {getAllByTitle} = renderWithProviders(
185+
<DynamicImage
186+
src={src}
187+
as={Img}
188+
imageProps={{
189+
...imageProps,
190+
loading: 'eager'
191+
}}
192+
/>
193+
)
194+
const elements = getAllByTitle(imageProps.title)
195+
expect(elements).toHaveLength(1)
196+
expect(elements[0]).not.toHaveAttribute('fetchpriority')
197+
expect(Helmet.peek().linkTags).toStrictEqual([])
198+
})
199+
200+
test('renders an image on the client', () => {
201+
isServer.mockReturnValue(false)
202+
const {getAllByTitle} = renderWithProviders(
203+
<DynamicImage
204+
src={src}
205+
imageProps={{
206+
...imageProps,
207+
loading: 'eager'
208+
}}
209+
/>
210+
)
211+
const elements = getAllByTitle(imageProps.title)
212+
expect(elements).toHaveLength(1)
213+
expect(elements[0]).toHaveAttribute('fetchpriority', 'high')
214+
expect(Helmet.peek().linkTags).toStrictEqual([])
215+
})
216+
})
217+
})

packages/template-retail-react-app/app/components/hero/index.jsx

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,19 @@ import {
1111
Box,
1212
Flex,
1313
Heading,
14-
Stack,
15-
Image
14+
Image as Img,
15+
Stack
1616
} from '@salesforce/retail-react-app/app/components/shared/ui'
17+
import Image from '@salesforce/retail-react-app/app/components/image'
1718

1819
const Hero = ({title, img, actions, ...props}) => {
19-
const {src, alt} = img
20+
const imageProps = {
21+
fit: 'cover', // The Chakra `Image`'s non-standard replacement for `objectFit`
22+
align: 'center', // The Chakra `Image`'s non-standard replacement for `objectPosition`
23+
width: '100%',
24+
height: '100%',
25+
...img
26+
}
2027

2128
return (
2229
<Box
@@ -52,14 +59,7 @@ const Hero = ({title, img, actions, ...props}) => {
5259
paddingTop={{base: 4, lg: 0}}
5360
>
5461
<Box position={'relative'} width={{base: 'full', md: '80%', lg: 'full'}}>
55-
<Image
56-
fit={'cover'}
57-
align={'center'}
58-
width={'100%'}
59-
height={'100%'}
60-
src={src}
61-
alt={alt}
62-
/>
62+
<Image as={Img} {...imageProps} />
6363
</Box>
6464
</Flex>
6565
</Stack>
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
/*
2+
* Copyright (c) 2025, Salesforce, Inc.
3+
* All rights reserved.
4+
* SPDX-License-Identifier: BSD-3-Clause
5+
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/BSD-3-Clause
6+
*/
7+
import React, {useMemo} from 'react'
8+
import {Helmet} from 'react-helmet'
9+
import PropTypes from 'prop-types'
10+
import {Img} from '@salesforce/retail-react-app/app/components/shared/ui'
11+
import {getImageAttributes} from '@salesforce/retail-react-app/app/utils/image'
12+
import {isServer} from '@salesforce/retail-react-app/app/components/image/utils'
13+
14+
/**
15+
* Image component, with the help of which certain performance best practices can be
16+
* implemented in a simple convenient way. The component ensures the following:
17+
* * eagerly-loaded images are attributed with `fetchpriority="high"` and in addition receive a `<link rel="preload"/>` element in the document header during server-side rendering
18+
* * lazily-loaded images are attributed with `decoding="async"` to take as much load as possible from the main thread
19+
* * eventually already existing `fetchpriority` or `decoding` attributes have/keep priority
20+
* @see {@link https://help.salesforce.com/s/articleView?id=cc.b2c_image_transformation_service.htm&type=5}
21+
*/
22+
const Image = (props) => {
23+
const {as, ...rest} = props
24+
const Component = as ? as : Img
25+
const [effectiveImageProps, effectiveLinkProps] = useMemo(() => {
26+
const imageProps = getImageAttributes(rest)
27+
const loadingStrategy = imageProps?.loading?.toLowerCase?.()
28+
const fetchPriority = imageProps?.fetchPriority?.toLowerCase?.()
29+
const linkProps =
30+
fetchPriority === 'high' && (!loadingStrategy || loadingStrategy === 'eager')
31+
? {
32+
rel: 'preload',
33+
as: 'image',
34+
href: imageProps.src,
35+
...(imageProps.sizes ? {imageSizes: imageProps.sizes} : {}),
36+
...(imageProps.srcSet ? {imageSrcSet: imageProps.srcSet} : {})
37+
}
38+
: undefined
39+
return [imageProps, linkProps]
40+
}, [rest])
41+
42+
return (
43+
<>
44+
<Component {...effectiveImageProps} />
45+
{effectiveLinkProps && isServer() && (
46+
<Helmet>
47+
<link {...effectiveLinkProps} />
48+
</Helmet>
49+
)}
50+
</>
51+
)
52+
}
53+
54+
Image.propTypes = {
55+
/**
56+
* Override with your chosen image component
57+
*/
58+
as: PropTypes.elementType,
59+
src: PropTypes.string.isRequired,
60+
alt: PropTypes.string,
61+
loading: PropTypes.oneOf(['eager', 'lazy']),
62+
fetchPriority: PropTypes.oneOf(['high', 'low', 'auto']),
63+
decoding: PropTypes.oneOf(['sync', 'async', 'auto'])
64+
}
65+
66+
export default Image

0 commit comments

Comments
 (0)