diff --git a/packages/core/src/components/navigation/Navbar/Navbar.tsx b/packages/core/src/components/navigation/Navbar/Navbar.tsx index b53a7f8923..b71b4f74cf 100644 --- a/packages/core/src/components/navigation/Navbar/Navbar.tsx +++ b/packages/core/src/components/navigation/Navbar/Navbar.tsx @@ -167,7 +167,7 @@ function Navbar({ title={logo.link ? logo.link.title : homeLabel} prefetch={false} > - + )} diff --git a/packages/core/src/components/navigation/NavbarSlider/NavbarSlider.tsx b/packages/core/src/components/navigation/NavbarSlider/NavbarSlider.tsx index c8cf07e462..209c5c702d 100644 --- a/packages/core/src/components/navigation/NavbarSlider/NavbarSlider.tsx +++ b/packages/core/src/components/navigation/NavbarSlider/NavbarSlider.tsx @@ -76,7 +76,7 @@ function NavbarSlider({ title={logo.link ? logo.link.title : homeLabel} onClick={fadeOut} > - + diff --git a/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx b/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx index 7373cddc2e..f1f2f6509e 100644 --- a/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx +++ b/packages/core/src/components/templates/ProductListingPage/ProductListingPage.tsx @@ -15,12 +15,12 @@ import type { ServerManyProductsQueryQueryVariables, } from '@generated/graphql' import { ITEMS_PER_PAGE } from 'src/constants' +import { getCriticalProductImagePreload } from 'src/components/ui/Image/getCriticalProductImagePreload' import { useApplySearchState } from 'src/sdk/search/state' import type { PLPContentType } from 'src/server/cms/plp' import storeConfig from '../../../../discovery.config' -import { faststoreLoader } from 'src/components/ui/Image/loader' import ProductListing from './ProductListing' import { getStoreURL } from 'src/sdk/localization/useLocalizationConfig' @@ -131,15 +131,9 @@ export default function ProductListingPage({ // 30vw × 412 × 2 = 247px → browser picks 320 (first step ≥ 247 in the srcset). // Using 320 here makes the preload URL exactly match the srcset selection, // so the browser can reuse the preloaded response instead of fetching a second URL. - const rawLcpImageUrl: string | undefined = + const lcpImagePreload = getCriticalProductImagePreload( server?.search?.products?.edges?.[0]?.node?.image?.[0]?.url - const lcpImageUrl = rawLcpImageUrl - ? faststoreLoader({ - src: rawLcpImageUrl, - width: 320, - quality: 75, - }) - : undefined + ) return ( - {lcpImageUrl && ( + {lcpImagePreload && ( - + )} {/* SEO */} diff --git a/packages/core/src/components/ui/Image/getCriticalProductImagePreload.ts b/packages/core/src/components/ui/Image/getCriticalProductImagePreload.ts new file mode 100644 index 0000000000..0cb96b4f09 --- /dev/null +++ b/packages/core/src/components/ui/Image/getCriticalProductImagePreload.ts @@ -0,0 +1,27 @@ +import { faststoreLoader } from './loader' + +export interface CriticalProductImagePreload { + rel: 'preload' + as: 'image' + href: string + fetchPriority: 'high' +} + +export function getCriticalProductImagePreload( + imageUrl?: string +): CriticalProductImagePreload | null { + if (!imageUrl) { + return null + } + + return { + rel: 'preload', + as: 'image', + href: faststoreLoader({ + src: imageUrl, + width: 320, + quality: 75, + }), + fetchPriority: 'high', + } +} diff --git a/packages/core/src/sdk/performance/useTTI.ts b/packages/core/src/sdk/performance/useTTI.ts index f4a090719d..ad7c439cb1 100644 --- a/packages/core/src/sdk/performance/useTTI.ts +++ b/packages/core/src/sdk/performance/useTTI.ts @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react' -const TTI_TIMEOUT = 5000 // 5 seconds without long tasks as a criterion for Time To Interactive - https://web.dev/articles/tti +const TTI_TIMEOUT = 1000 // 5 seconds without long tasks as a criterion for Time To Interactive - https://web.dev/articles/tti /** * Polyfill for requestIdleCallback, which is not available for every browser diff --git a/packages/core/test/components/ui/Logo.browser.test.tsx b/packages/core/test/components/ui/Logo.browser.test.tsx new file mode 100644 index 0000000000..4f34204d5f --- /dev/null +++ b/packages/core/test/components/ui/Logo.browser.test.tsx @@ -0,0 +1,40 @@ +import { render } from '@testing-library/react' + +import Logo from 'src/components/ui/Logo/Logo' + +const imageSpy = vi.fn() + +vi.mock('src/components/ui/Image', () => ({ + Image: (props: Record) => { + imageSpy(props) + return
+ }, +})) + +describe('Logo', () => { + beforeEach(() => { + imageSpy.mockClear() + }) + + it('uses lazy loading by default', () => { + render() + + expect(imageSpy).toHaveBeenCalledTimes(1) + expect(imageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + loading: 'lazy', + }) + ) + }) + + it('keeps explicit loading overrides', () => { + render() + + expect(imageSpy).toHaveBeenCalledTimes(1) + expect(imageSpy).toHaveBeenCalledWith( + expect.objectContaining({ + loading: 'eager', + }) + ) + }) +}) diff --git a/packages/core/test/sdk/head/getCriticalProductImagePreload.test.ts b/packages/core/test/sdk/head/getCriticalProductImagePreload.test.ts new file mode 100644 index 0000000000..8c70fe6824 --- /dev/null +++ b/packages/core/test/sdk/head/getCriticalProductImagePreload.test.ts @@ -0,0 +1,29 @@ +import { getCriticalProductImagePreload } from 'src/components/ui/Image/getCriticalProductImagePreload' + +describe('getCriticalProductImagePreload', () => { + it('returns null when no image URL is provided', () => { + expect(getCriticalProductImagePreload()).toBeNull() + }) + + it('builds preload metadata for a valid image URL', () => { + expect(getCriticalProductImagePreload('/product-image.jpg')).toEqual({ + rel: 'preload', + as: 'image', + href: '/product-image.jpg', + fetchPriority: 'high', + }) + }) + + it('transforms VTEX IDs image URLs using the expected preload sizing', () => { + expect( + getCriticalProductImagePreload( + '/ids/1557582/image-6.jpg?v=638808503053270000' + ) + ).toEqual({ + rel: 'preload', + as: 'image', + href: '/ids/1557582-320-auto/image-6.webp?v=638808503053270000&quality=8', + fetchPriority: 'high', + }) + }) +})