diff --git a/.github/workflows/readme-link-check.yml b/.github/workflows/readme-link-check.yml
index c00124272f7..4b77d0e8026 100644
--- a/.github/workflows/readme-link-check.yml
+++ b/.github/workflows/readme-link-check.yml
@@ -26,5 +26,6 @@ jobs:
--exclude https://my-server-DNS-name.tld/api
--exclude 2021.ploneconf.org
--exclude https://www.lanku.eus/
+ --exclude https://medium.com/
'**/README.md'
'PACKAGES.md'
diff --git a/docs/source/conf.py b/docs/source/conf.py
index ba61efbd13f..466b3440234 100644
--- a/docs/source/conf.py
+++ b/docs/source/conf.py
@@ -94,6 +94,7 @@
r"https://browsersl.ist/#",
r"https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS/Errors#Identifying_the_issue",
r"https://docs.cypress.io/guides/references/migration-guide#Migrating-to-Cypress-version-10-0",
+ r"https://medium.com",
]
linkcheck_anchors = True
linkcheck_timeout = 5
diff --git a/docs/source/upgrade-guide/index.md b/docs/source/upgrade-guide/index.md
index 160ec05cf1e..ddf5db45007 100644
--- a/docs/source/upgrade-guide/index.md
+++ b/docs/source/upgrade-guide/index.md
@@ -333,6 +333,20 @@ We pinned the version of `sass` to `1.32.0`, which is the one before they introd
It is unlikely that using this version will cause problems since no real new features were added in later versions that are relevant for Volto developers.
In case that you need a later version of `sass` in your project or add-on, you can override it in your project's {file}`package.json` file.
+### The image component now includes the original image only if necessary
+```{versionadded} Volto 19.0.0-alpha.18
+```
+
+The `Image` component has been optimized to include the original image URL only when necessary.
+Now it is only included if the image does not have all the defined scales present, which could happen if the image uploaded originally is smaller than the defined scales.
+In other scenarios where all the scales are present, including the original image could lead the browser to choose it over the scaled versions, impacting performance.
+This happened especially in high-density resolution screens where the largest scale available was not enough for the browser to pick a scaled version.
+
+This is a breaking change for projects that relied on the original image always being present, for example, in those projects where the original image was always included for large displays, such as televisions or wide-screen displays.
+A pair of additional scales were added to cover those use cases, enough to cover the highest density screens at the largest common resolutions.
+Additionally, if your project relied on the original image to always be present, then you need to either add an additional scale to cover your use case, run the upgrade steps defined in `plone.volto>=6.0.0a0`, or, in Plone 6.2, to use the new image scales named `2k` and `4k`.
+
+
(upgrading-to-volto-18-x-x)=
## Upgrading to Volto 18.x.x
diff --git a/packages/volto/news/7655.bugfix b/packages/volto/news/7655.bugfix
new file mode 100644
index 00000000000..7362a46a0b8
--- /dev/null
+++ b/packages/volto/news/7655.bugfix
@@ -0,0 +1 @@
+Only include the original image in the Image component if the image does not have all the scales present. Fixed and update tests. @sneridagh
diff --git a/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.test.jsx b/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.test.jsx
index 7ff76617814..c4f7acb7ac8 100644
--- a/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.test.jsx
+++ b/packages/volto/src/components/manage/Blocks/Image/ImageSidebar.test.jsx
@@ -15,6 +15,7 @@ it('renders an Image Block Sidebar component', () => {
create: {},
data: {},
},
+ site: { data: { 'plone.image_scales': { preview: {}, listing: {} } } },
intl: {
locale: 'en',
messages: {},
diff --git a/packages/volto/src/components/manage/Blocks/LeadImage/LeadImageSidebar.test.jsx b/packages/volto/src/components/manage/Blocks/LeadImage/LeadImageSidebar.test.jsx
index 477ebd89b50..04d0c652791 100644
--- a/packages/volto/src/components/manage/Blocks/LeadImage/LeadImageSidebar.test.jsx
+++ b/packages/volto/src/components/manage/Blocks/LeadImage/LeadImageSidebar.test.jsx
@@ -16,6 +16,7 @@ test('renders a Lead Image block Sidebar component', () => {
locale: 'en',
messages: {},
},
+ site: { data: { 'plone.image_scales': { preview: {}, listing: {} } } },
});
const component = renderer.create(
diff --git a/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx b/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx
index f1d601b78df..e057d5efb04 100644
--- a/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx
+++ b/packages/volto/src/components/manage/Blocks/Teaser/DefaultBody.jsx
@@ -64,6 +64,7 @@ const TeaserDefaultTemplate = (props) => {
alt=""
loading="lazy"
responsive={true}
+ sizes="auto, (max-width: 940px) 100vw, 940px"
/>
)
diff --git a/packages/volto/src/components/manage/TemplateChooser/TemplateChooser.test.jsx b/packages/volto/src/components/manage/TemplateChooser/TemplateChooser.test.jsx
index 577124a2c08..b2f436823ee 100644
--- a/packages/volto/src/components/manage/TemplateChooser/TemplateChooser.test.jsx
+++ b/packages/volto/src/components/manage/TemplateChooser/TemplateChooser.test.jsx
@@ -13,6 +13,7 @@ test('renders a TemplateChooser component', () => {
locale: 'en',
messages: { templateid: 'Template default translation' },
},
+ site: { data: { 'plone.image_scales': { preview: {}, listing: {} } } },
});
const component = renderer.create(
diff --git a/packages/volto/src/components/manage/Toolbar/PersonalTools.test.jsx b/packages/volto/src/components/manage/Toolbar/PersonalTools.test.jsx
index 4dd3122e7c7..72dcff0d0cd 100644
--- a/packages/volto/src/components/manage/Toolbar/PersonalTools.test.jsx
+++ b/packages/volto/src/components/manage/Toolbar/PersonalTools.test.jsx
@@ -23,6 +23,11 @@ describe('Toolbar Personal Tools component', () => {
userSession: {
token: jwt.sign({ sub: 'admin' }, 'secret'),
},
+ site: {
+ data: {
+ 'plone.image_scales': { preview: {}, listing: {} },
+ },
+ },
content: {
data: {
'@type': 'Folder',
@@ -74,6 +79,11 @@ describe('Toolbar Personal Tools component', () => {
userSession: {
token: jwt.sign({ sub: 'admin' }, 'secret'),
},
+ site: {
+ data: {
+ 'plone.image_scales': { preview: {}, listing: {} },
+ },
+ },
content: {
data: {
'@type': 'Folder',
@@ -126,6 +136,11 @@ describe('Toolbar Personal Tools component', () => {
userSession: {
token: jwt.sign({ sub: 'admin' }, 'secret'),
},
+ site: {
+ data: {
+ 'plone.image_scales': { preview: {}, listing: {} },
+ },
+ },
content: {
data: {
'@type': 'Folder',
diff --git a/packages/volto/src/components/manage/Widgets/ImageWidget.jsx b/packages/volto/src/components/manage/Widgets/ImageWidget.jsx
index 8abd5cf9857..7bd422ff6b6 100644
--- a/packages/volto/src/components/manage/Widgets/ImageWidget.jsx
+++ b/packages/volto/src/components/manage/Widgets/ImageWidget.jsx
@@ -296,7 +296,13 @@ const UnconnectedImageInput = (props) => {
{selected && }
{/* If it's relation choice (preview_image_link) */}
{isRelationChoice ? (
-
+
) : (
locale: 'en',
messages: {},
},
+ site: { data: { 'plone.image_scales': { preview: {}, listing: {} } } },
});
describe('RegistryImageWidget', () => {
diff --git a/packages/volto/src/components/theme/Image/Image.jsx b/packages/volto/src/components/theme/Image/Image.jsx
index d454d476aa1..9415a61d722 100644
--- a/packages/volto/src/components/theme/Image/Image.jsx
+++ b/packages/volto/src/components/theme/Image/Image.jsx
@@ -1,5 +1,6 @@
import PropTypes from 'prop-types';
import cx from 'classnames';
+import { useSelector } from 'react-redux';
import {
flattenToAppURL,
flattenScales,
@@ -26,6 +27,9 @@ export default function Image({
className = '',
...imageProps
}) {
+ const site = useSelector((state) => state.site.data);
+ const siteImageScales = site?.['plone.image_scales'] || {};
+
if (!item && !src) return null;
// TypeScript hints for editor autocomplete :)
@@ -61,11 +65,16 @@ export default function Image({
if (!isSvg && image.scales && Object.keys(image.scales).length > 0) {
const sortedScales = Object.values({
...image.scales,
- original: {
- download: `${image.download}`,
- width: image.width,
- height: image.height,
- },
+ ...(Object.keys(siteImageScales).length >
+ Object.keys(image.scales).length
+ ? {
+ original: {
+ download: `${image.download}`,
+ width: image.width,
+ height: image.height,
+ },
+ }
+ : {}),
}).sort((a, b) => {
if (a.width > b.width) return 1;
else if (a.width < b.width) return -1;
diff --git a/packages/volto/src/components/theme/Image/Image.test.jsx b/packages/volto/src/components/theme/Image/Image.test.jsx
index b45d08140cf..1d6a09b6abf 100644
--- a/packages/volto/src/components/theme/Image/Image.test.jsx
+++ b/packages/volto/src/components/theme/Image/Image.test.jsx
@@ -1,165 +1,266 @@
import React from 'react';
-import renderer from 'react-test-renderer';
+import configureStore from 'redux-mock-store';
+import { Provider } from 'react-intl-redux';
+import { render, screen } from '@testing-library/react';
+
import Image from './Image';
-test('renders an image component with fetchpriority high', () => {
- const component = renderer.create(
- ,
- );
- const json = component.toJSON();
- expect(json).toMatchSnapshot();
-});
+ },
+ ],
+ },
+};
-test('renders an image component with lazy loading', () => {
- const component = renderer.create(
- ,
+ },
+ ],
+ },
+};
+
+const renderImage = (props, { siteData = defaultSiteData } = {}) => {
+ const store = mockStore({
+ intl: { locale: 'en', messages: {} },
+ site: { data: siteData },
+ });
+
+ return render(
+
+
+ ,
);
- const json = component.toJSON();
- expect(json).toMatchSnapshot();
-});
+};
+
+describe('Image', () => {
+ it('renders an image component with fetchpriority high', () => {
+ renderImage({ item: itemWithImage, imageField: 'image', alt: 'alt text' });
+
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveAttribute('fetchpriority', 'high');
+ expect(img).toHaveAttribute('width', '1080');
+ expect(img).toHaveAttribute('height', '920');
+ expect(img).toHaveAttribute('src', '/image/@@images/image.png');
+ expect(img).toHaveAttribute(
+ 'srcset',
+ '/image/@@images/image-400.png 400w, /image/@@images/image.png 1080w',
+ );
+ });
-test('renders an image component with responsive class', () => {
- const component = renderer.create(
- {
+ renderImage({
+ item: itemWithImage,
+ imageField: 'image',
+ alt: 'alt text',
+ loading: 'lazy',
+ });
+
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveAttribute('loading', 'lazy');
+ expect(img).toHaveAttribute('decoding', 'async');
+ expect(img).not.toHaveAttribute('fetchpriority');
+ });
+
+ it('renders an image component with responsive class', () => {
+ renderImage({
+ item: {
+ ...itemWithImage,
image: {
+ ...itemWithImage.image,
download: 'http://localhost:3000/image/@@images/image-1200.png',
- width: 400,
- height: 400,
- scales: {
- preview: {
- download: 'http://localhost:3000/image/@@images/image-400.png',
- width: 400,
- height: 400,
- },
- },
},
- }}
- imageField="image"
- alt="alt text"
- responsive={true}
- />,
- );
- const json = component.toJSON();
- expect(json).toMatchSnapshot();
-});
+ },
+ imageField: 'image',
+ alt: 'alt text',
+ responsive: true,
+ });
-test('renders an image component from a catalog brain', () => {
- const component = renderer.create(
- ,
- );
- const json = component.toJSON();
- expect(json).toMatchSnapshot();
-});
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveClass('responsive');
+ expect(img).toHaveAttribute('fetchpriority', 'high');
+ });
+
+ it('renders an image component from a catalog brain', () => {
+ renderImage({ item: catalogBrain, imageField: 'image', alt: 'alt text' });
+
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveAttribute('src', '/image/@@images/image.png');
+ expect(img).toHaveAttribute(
+ 'srcset',
+ '/image/@@images/image-400.png 400w, /image/@@images/image.png 400w',
+ );
+ });
-test('renders an image component from a catalog brain using `preview_image_link`', () => {
- const component = renderer.create(
- {
+ renderImage({
+ item: previewImageLink,
+ imageField: 'preview_image_link',
+ alt: 'alt text',
+ });
+
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveAttribute('src', '/image.png/@@images/image.png');
+ expect(img).toHaveAttribute(
+ 'srcset',
+ '/image.png/@@images/image-400.png 400w, /image.png/@@images/image.png 400w',
+ );
+ });
+
+ it('includes the original scale in srcset when site defines more scales than the image', () => {
+ renderImage(
+ {
+ item: previewImageLink,
+ imageField: 'preview_image_link',
+ alt: 'alt text',
+ },
+ { siteData: { 'plone.image_scales': { preview: {}, listing: {} } } },
+ );
+
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveAttribute(
+ 'srcset',
+ '/image.png/@@images/image-400.png 400w, /image.png/@@images/image.png 400w',
+ );
+ });
+
+ it('does not include the original scale when site scales are not greater than the image scales', () => {
+ renderImage(
+ {
+ item: previewImageLink,
+ imageField: 'preview_image_link',
+ alt: 'alt text',
+ },
+ { siteData: { 'plone.image_scales': { preview: {} } } },
+ );
+
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveAttribute(
+ 'srcset',
+ '/image.png/@@images/image-400.png 400w',
+ );
+ });
+
+ it('does not render when no item or src is provided', () => {
+ const { container } = renderImage(
+ { alt: 'missing image' },
+ { siteData: {} },
+ );
+
+ expect(container).toBeEmptyDOMElement();
+ });
+
+ it('renders an svg image without srcset', () => {
+ renderImage({
+ item: {
+ ...itemWithImage,
+ image: {
+ ...itemWithImage.image,
+ 'content-type': 'image/svg+xml',
},
- }}
- imageField="preview_image_link"
- alt="alt text"
- />,
- );
- const json = component.toJSON();
- expect(json).toMatchSnapshot();
-});
+ },
+ imageField: 'image',
+ alt: 'svg image',
+ });
-test('renders an image component from a string src', () => {
- const component = renderer.create(
- ,
- );
- const json = component.toJSON();
- expect(json).toMatchSnapshot();
-});
+ const img = screen.getByAltText('svg image');
-test('should not render empty class attribute in img tag', () => {
- const component = renderer.create(
- ,
- );
- const json = component.toJSON();
- expect(json).toMatchSnapshot();
+ expect(img).not.toHaveAttribute('srcset');
+ expect(img).toHaveAttribute('src', '/image/@@images/image.png');
+ });
+
+ it('merges custom className with responsive flag', () => {
+ renderImage({
+ item: itemWithImage,
+ imageField: 'image',
+ alt: 'with class',
+ responsive: true,
+ className: 'custom-class',
+ });
+
+ const img = screen.getByAltText('with class');
+
+ expect(img).toHaveClass('custom-class');
+ expect(img).toHaveClass('responsive');
+ });
+
+ it('renders an image component from a string src', () => {
+ renderImage({
+ src: 'http://localhost:3000/image/@@images/image/image.png',
+ alt: 'alt text',
+ });
+
+ const img = screen.getByAltText('alt text');
+
+ expect(img).toHaveAttribute(
+ 'src',
+ 'http://localhost:3000/image/@@images/image/image.png',
+ );
+ expect(img).toHaveAttribute('fetchpriority', 'high');
+ });
+
+ it('should not render empty class attribute in img tag', () => {
+ renderImage({ src: '/image.png', alt: 'no class attribute' });
+
+ const img = screen.getByAltText('no class attribute');
+
+ expect(img).not.toHaveAttribute('class');
+ });
});
diff --git a/packages/volto/src/components/theme/Image/__snapshots__/Image.test.jsx.snap b/packages/volto/src/components/theme/Image/__snapshots__/Image.test.jsx.snap
deleted file mode 100644
index 251b073417f..00000000000
--- a/packages/volto/src/components/theme/Image/__snapshots__/Image.test.jsx.snap
+++ /dev/null
@@ -1,74 +0,0 @@
-// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
-
-exports[`renders an image component from a catalog brain 1`] = `
-
-`;
-
-exports[`renders an image component from a catalog brain using \`preview_image_link\` 1`] = `
-
-`;
-
-exports[`renders an image component from a string src 1`] = `
-
-`;
-
-exports[`renders an image component with fetchpriority high 1`] = `
-
-`;
-
-exports[`renders an image component with lazy loading 1`] = `
-
-`;
-
-exports[`renders an image component with responsive class 1`] = `
-
-`;
-
-exports[`should not render empty class attribute in img tag 1`] = `
-
-`;
diff --git a/packages/volto/src/components/theme/View/ImageView.jsx b/packages/volto/src/components/theme/View/ImageView.jsx
index 2144b359d86..678ca69a28d 100644
--- a/packages/volto/src/components/theme/View/ImageView.jsx
+++ b/packages/volto/src/components/theme/View/ImageView.jsx
@@ -38,6 +38,7 @@ const ImageView = ({ content }) => {
imageField="image"
alt={content.title}
responsive={true}
+ sizes="auto, (max-width: 940px) 100vw, 940px"
/>
{
+ const store = mockStore({
+ intl: { locale: 'en', messages: {} },
+ site: { data: { 'plone.image_scales': { preview: {}, listing: {} } } },
+ });
+
it('renders an empty image view widget component', () => {
- const component = renderer.create();
+ const component = renderer.create(
+
+
+ ,
+ );
const json = component.toJSON();
expect(json).toMatchSnapshot();
});
it('renders an image view widget component', () => {
const component = renderer.create(
- ,
+
+
+ ,
);
const json = component.toJSON();
expect(json).toMatchSnapshot();
@@ -19,15 +37,17 @@ describe('ImageWidget', () => {
it('renders an image view widget component with children', () => {
const component = renderer.create(
-
- {(child) => {child}}
- ,
+
+
+ {(child) => {child}}
+
+ ,
);
const json = component.toJSON();
expect(json).toMatchSnapshot();
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d1b666ac86d..13e34a9fd69 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -21008,7 +21008,7 @@ snapshots:
sirv: 3.0.1
tinyglobby: 0.2.14
tinyrainbow: 1.2.0
- vitest: 2.1.9(@types/node@24.9.1)(@vitest/ui@2.1.9)(jsdom@22.1.0)(less@3.11.1)(lightningcss@1.30.1)(sass@1.91.0)(terser@5.43.1)
+ vitest: 2.1.9(@types/node@24.9.1)(@vitest/ui@2.1.9)(jsdom@21.1.2)(less@3.11.1)(lightningcss@1.30.1)(sass@1.91.0)(terser@5.43.1)
optional: true
'@vitest/ui@2.1.9(vitest@3.2.4)':