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( - alt text, - ); - const json = component.toJSON(); - expect(json).toMatchSnapshot(); -}); + }, + ], + }, +}; -test('renders an image component with lazy loading', () => { - const component = renderer.create( - alt text, + }, + ], + }, +}; + +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( - alt text, - ); - 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( - alt text, - ); - 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( - no class attribute, - ); - 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`] = ` -alt text -`; - -exports[`renders an image component from a catalog brain using \`preview_image_link\` 1`] = ` -alt text -`; - -exports[`renders an image component from a string src 1`] = ` -alt text -`; - -exports[`renders an image component with fetchpriority high 1`] = ` -alt text -`; - -exports[`renders an image component with lazy loading 1`] = ` -alt text -`; - -exports[`renders an image component with responsive class 1`] = ` -alt text -`; - -exports[`should not render empty class attribute in img tag 1`] = ` -no class attribute -`; 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)':