How to optimize PLP?

<aside> ❗ This guide suggests that most of the basic optimization is already done, by following the next guides:

How to preload critical chunks?

How to split preload and rendering?

How to inline main chunk?

</aside>

Before starting to optimize PLP it is required to take analyze of page to determine its critical elements and what should be considered as high priority elements.

By default, in most cases LCP element on PLP is considered to be one of visible on viewport products cards image.

Untitled

Untitled

To verify, it can be tested with performance insights tool, to check which element will be considered as LCP on page.

Untitled

Now when LCP element is known, it is possible to determine possible solution for critical rendering path to this element. At the moment critical rendering path is with requests/code that slow down rendering on LCP.

At the moment, critical rendering path looks like this:

Untitled

Looking at requests waterfall, it is possible to make points such as:

The main goal would be to resolve all of those points and get result such as:

Untitled

How to unblock product card image rendering?

As it is defined, product cards won’t be rendered before category information won’t be populated in redux. As shown below, product cards are rendered after filters, category details and so on.

Peek 2023-04-14 09-57.gif

This is because of logic written to prevent rendering of products in CategoryProductList.container.js file.

To resolve such issue, it is required to extend existing logic to prevent setting isLoading status to true if current category is not loaded and it is first visit user visit.

Therefore, next code should be added:

import { connect } from 'react-redux';

import {
    CategoryProductListContainer as SourceCategoryProductListContainer,
    mapDispatchToProps,
    mapStateToProps,
} from 'SourceComponent/CategoryProductList/CategoryProductList.container';

export class CategoryProductListContainer extends SourceCategoryProductListContainer {
    getIsLoading() {
        const {
            filter,
            isLoading,
            isMatchingListFilter,
            isCurrentCategoryLoaded,
        } = this.props;

        /**
         * In case the wrong category was passed down to the product list,
         * show the loading animation, it will soon change to proper category.
        */
        if (filter.categoryIds === -1) {
            return true;
        }

        /**
				* Next line is changed to adjust condition 
				* for setting category load status on first user visit
				*/
        if (!isCurrentCategoryLoaded && !window.isPrefetchValueUsed) {
            return true;
        }

        if (!navigator.onLine) {
            return false;
        }

        // if the filter expected matches the last requested filter
        if (isMatchingListFilter) {
            return false;
        }

        return isLoading;
    }
}

export default connect(mapStateToProps, mapDispatchToProps)(CategoryProductListContainer);

As result, product cards render won’t be blocked by loading category information!

Peek 2023-04-14 09-52.gif

How to deprioritize everything except LCP element on Category Page?

Deprioritizing everything expect LCP element means that it is required to remove all requests and code that are not necessary for LCP elements to be rendered. In Category page that could be all requests except preloaded ProductList and code that is not necessary for critical rendering path, hence in waterfall it can looks such way:

Untitled

Here all shaded blocks should be removed from critical rendering path and category chunk should be decreased in size.

How to deprioritize Category Page unnecessary for LCP rendering code?

To minimize Category chunk and reduce all not necessary code that is not considered to be part or critical rendering path, it should be deprioritized.

As it was defined previously, for rendering LCP elements it is required only to render images as soon as possible.

Untitled

This means, that on Category page, almost everything can be deprioritized!

Lets check how looks Category chunk in bundle analyzer and determined its size before deprioritization:

Untitled

So at the moment, size of Category chunk in Gzip is 72.33 KB!

To deprioritize unnecessary components it is required to copy source Category Page component which is located in node_modules/@scandipwa/scandipwa/route/CategoryPage and put into the projects theme extends src/route/CategoryPage folder, it can be renamed to CategoryPage.source.component.js file to avoid file duplicates. This is needed to change existing inline imports of components to deprioritize them with lowPriorityLazy method.

Now, in CategoryPage.source.component.js change importing of components from inlining them to lowPriorityLazy .

for example, component CategorySort was imported into CategoryPage.source.component.js using inline import:

import CategorySort from 'Component/CategorySort';

This import should be changed with deprioritization and webpack chunk name prefers to be category-misc:

export const CategorySort = lowPriorityLazy(() => import(
    /* webpackMode: "lazy", webpackChunkName: "category-misc" */ 'Component/CategorySort'
));

Now when component is lazy loaded with low priority it is required to wrap it with Suspense component in extended file. Create CategoryPage.component.js file where CategoryPage class will be extended from previously copied source component and extending method which is responsible for rendering CategorySort component.

import { Suspense } from 'react';

/**
* Importing CategoryPage class and CategorySort component
* from copy of local source file
*/
import {
	CategoryPage as SourceCategoryPage,
  CategorySort,
} from './CategoryPage.source.component';

export class CategoryPage extends SourceCategoryPage {
	renderCategorySort(): ReactElement {
        const {
            ...
        } = this.props;

        ...

				// Here CategorySort component is wrapped with Suspense
        return (
          <Suspense fallback={ null } >
						<CategorySort
              ...
            />
					</Suspense>
        );
    }
}

export default CategoryPage;

This way should be done for all components that are not considered to be in critical rendering path (Including CategoryProductList component).

In result there should be a blank page, which is expected, since all of the components in CategoryPage component are loaded with low priority.

After deoprioritizing everything on a page, bundle analyzer shows less size code for Category chunk.

Untitled

Now it is required to actually show LCP elements on a page which can be done with fallback provided for Suspense on ProductList component. This means that fake product list will be shown, while real is downloading in category-misc chunk.

To implement fallback, it is required to pass pages state from ProductList reducer to CategoryPage component.

import { withReducers } from 'Util/DynamicReducer';
import CategoryReducer from 'Store/Category/Category.reducer';
import {
    CategoryPageContainer as SourceCategoryPageContainer,
    mapDispatchToProps as sourceMapStateToProps,
    mapStateToProps,
} from 'SourceRoute/CategoryPage/CategoryPage.container';

export const mapStateToProps = (state) => ({
    ...sourceMapStateToProps(state),
    pages: state.ProductListReducer.pages
});

export class CategoryPageContainer extends SourceCategoryPageContainer {
		containerProps() {
				const { pages } = this.props;
				
				return {
					...super.containerProps(),
					pages
				}
		}
}

export default withReducers({
    CategoryReducer,
})(
    connect(mapStateToProps, mapDispatchToProps)(CategoryPageContainer),
);

Now when information about products on category is passed to component, it will be possible to create fallback. Therefor in extended CategoryPage.component.js file next should be done:

import { Suspense } from 'react';

import Image from 'Component/Image';

/**
* Importing CategoryPage class and CategorySort component
* from copy of local source file
*/
import {
	CategoryPage as SourceCategoryPage,
  CategorySort,
} from './CategoryPage.source.component';

export class CategoryPage extends SourceCategoryPage {

renderCategoryProductCardsPlaceholder = this.renderCategoryProductCardsPlaceholder.bind(this);

/**
* Overriding to wrap in Suspense CategoryProductList component
* and add fallback for product cards as to render images only
*/
renderCategoryProductList() {
        const {
					...
        } = this.props;

        ...

        return (
            <div
              block="CategoryPage"
              elem="ProductListWrapper"
              mods={ { isPrerendered: isSSR() || isCrawler() } }
            >
                { this.renderItemsCount(true) }
                <Suspense fallback={ this.renderCategoryProductListFallback() }>
	                <CategoryProductList
	                  ...
	                />
                </Suspense>
            </div>
        );
    }

	/**
	* Wrapper for CategoryProductList fallback
	*/
	renderCategoryProductListFallback() {
		const { pages = {} } = this.props;
	        const [items] = Object.values(pages);
	
	        if (!items?.length) {
	            return null;
	        }
	
	        return (
	            <div block="CategoryPage" elem="CategoryProductListWrapperFallback">
	                <ul block="CategoryPage" elem="CategoryProductListFallback">
	                     { items.map(this.renderCategoryProductCardsPlaceholder) }
	                </ul>
	            </div>
	        );
		}

	renderCategoryProductCardsPlaceholder(item) {
	        const { name, id, small_image: { url } } = item;
	
	        return (
	            <li block="CategoryPage" elem="ProductCardFallback" key={ id }>
	                <Image
	                  src={ url }
	                  alt={ name }
	                  ratio="custom"
	                  onImageLoad={ () => {
											window.isPriorityLoaded = true;
										} }
	                  onError={ () => {
											window.isPriorityLoaded = true;
										} }
	                />
	            </li>
	        );
	    }
}

export default CategoryPage;

After fallback creation it is required to add styles to fallback elements is CategoryPage.style.extended.scss. Styles should be added same as on real product list grid per design.

<aside> ❗ It is important that fallback image and image that will be rendered after CategoryProductList component load are exactly same size, otherwise second image will be considered as LCP element!

</aside>

When fallback is created it should render product images as first elements on page as shown here:

Peek 2023-04-17 08-30.gif

How to deprioritize Category Page requests?

By default, in ScandiPWA there is 3 requests on Category Pages:

Product Filters request should be already deprioritized with lowPriorityLoad method for importing CategoryProductList component.

Considering that Product list information request is preloaded, default request from ProductList.container.js file should not be requested.

For removing duplicate request for Product list it is required to extend ProductList.container.js file, where next logic should be added:

import { connect } from 'react-redux';
import { withRouter } from 'react-router-dom';

import {
    mapDispatchToProps,
    mapStateToProps,
    ProductListContainer as SourceProductListContainer,
} from 'SourceComponent/ProductList/ProductList.container';

/** @namespace Scandipwa/Component/ProductList/Container */
export class ProductListContainer extends SourceProductListContainer {
    requestPage(currentPage = 1, isNext = false) {
        const {
            sort,
            search,
            filter,
            pageSize,
            requestProductList,
            requestProductListInfo,
            noAttributes,
            noVariants,
            isWidget,
            device,
            isSearch,
        } = this.props;
				
				// Next variable is declared to determine if it is a preloaded page or not
        const isPrefetched = window?.isPrefetchValueUsed && !isWidget && !isSearch;

        /**
         * In case the wrong category was passed down to the product list,
         * prevent it from being requested.
         */
        if (filter.categoryIds === -1) {
            return;
        }

        /**
         * Do not request page if there are no filters
         */
        if (!search && !this.isEmptyFilter()) {
            return;
        }

        // TODO: product list requests filters alongside the page
        // TODO: sometimes product list is requested more then once
        // TODO: the product list should not request itself, when coming from PDP

        const options = {
            isNext,
            noAttributes,
            noVariants,
            args: {
                sort: sort ?? undefined,
                filter,
                search,
                pageSize,
                currentPage,
            },
        };

        const infoOptions = {
            args: {
                filter,
                search,
            },
        };

				/** 
				* Next condition is added to prevent product list request
				* in case if it was already preloaded
				*/
        if (!isPrefetched) {
            requestProductList(options);
        }

        if (!isWidget) {
            requestProductListInfo(infoOptions);

            if (!device.isMobile) {
                scrollToTop();
            }
        }
    }
}

export default withRouter(connect(mapStateToProps, mapDispatchToProps)(ProductListContainer));

For deprioritizing Category information request in CategoryPage.container.js file it is required to adjust requestCategory method where request will be called as callback after waitForPriorityLoad method promise resolve:

import { withReducers } from 'Util/DynamicReducer';
import CategoryReducer from 'Store/Category/Category.reducer';
import {
    CategoryPageContainer as SourceCategoryPageContainer,
    mapDispatchToProps as sourceMapStateToProps,
    mapStateToProps,
} from 'SourceRoute/CategoryPage/CategoryPage.container';

export const mapStateToProps = (state) => ({
    ...sourceMapStateToProps(state),
    pages: state.ProductListReducer.pages
});

export class CategoryPageContainer extends SourceCategoryPageContainer {
	requestCategory() {
        const {
            categoryIds,
            isSearchPage,
            requestCategory,
        } = this.props;

        const {
            currentCategoryIds,
        } = this.state;

        /**
         * Prevent non-existent category from being requested
         */
        if (categoryIds === -1) {
            return;
        }

        /**
         * Do not request a category again! We are still waiting for
         * a requested category to load!
         */
        if (categoryIds === currentCategoryIds) {
            return;
        }

        /**
         * Update current category to track if it is loaded or not - useful,
         * to prevent category from requesting itself multiple times.
         */
        this.setState({
            currentCategoryIds: categoryIds,
            breadcrumbsWereUpdated: false,
        });

				// Next lines are added to deprioritize category information request
        waitForPriorityLoad().then(
            /** @namespace Scandipwa/Route/CategoryPage/Container/CategoryPageContainer/requestCategory/waitForPriorityLoad/then */
            () => {
                requestCategory({
                    isSearchPage,
                    categoryIds,
                });
            }
        );
    }

		containerProps() {
				const { pages } = this.props;
				
				return {
					...super.containerProps(),
					pages
				}
		}
}

export default withReducers({
    CategoryReducer,
})(
    connect(mapStateToProps, mapDispatchToProps)(CategoryPageContainer),
);

After deprioritizing requests, the waterfall should look like this:

Untitled

How to reduce loading out of view port product card images?

LCP elements on a page will be considered only those, that are visible on view port of user. Other images that are out of view port slowing down potential LCP elements downloading, hence it is required to deprioritize them.

To make so, it is needed to define how much products will be visible on screen depending on its width.

Therefor it is required to extend CategoryPage.config.js file and add next code:

export * from 'SourceRoute/CategoryPage/CategoryPage.config';

// Those values should be changed depending on projects design
export const CATEGORY_MOBILE_PRODUCT_IMAGE_COUNT = 2;
export const CATEGORY_TABLET_PRODUCT_IMAGE_COUNT = 3;
export const CATEGORY_DESKTOP_PRODUCT_IMAGE_COUNT = 4;

export const getCategoryProductCardsPlaceholderCount = () => {
    const { screen: { width } } = window;

    if (width < 1024) {
        return CATEGORY_MOBILE_PRODUCT_IMAGE_COUNT;
    }

    if (width < 1280) {
        return CATEGORY_TABLET_PRODUCT_IMAGE_COUNT;
    }

    return CATEGORY_DESKTOP_PRODUCT_IMAGE_COUNT;
};

Now this method can be reused in CategoryPage.component.js file, where method renderCategoryProductCardsPlaceholders will be rendering only provided count of images:

renderCategoryProductCardsPlaceholders(item, index) {
        const { name, id, small_image: { url } } = item;
				const productCardsPlaceholdersCount = getCategoryProductCardsPlaceholderCount();
				const isPlaceholder = index >= productCardsPlaceholdersCount;

        return (
            <li block="CategoryPage" elem="ProductCardPlaceholder" key={ id }>
                <Image
                  src={ !isPlaceholder ? url : '' }
                  alt={ name }
                  ratio="custom"
                  onLoad={ () => {
										window.isPriorityLoaded = true;
									} }
                  isPlaceholder={ isPlaceholder }
                />
            </li>
        );
    }

After adjusting fallback render method, all images that are not visible on view port will be shown as placeholders and waterfall should be like this:

Untitled

How to preload category LCP images?

Images on CategoryPage are starting to download only when code from category and render chunks downloaded even if Product List request is finished earlier. To safe more time on showing LCP elements it is possible to start downloading them as soon as product list information comes to redux. It can be done by injecting preload links with image when products information is populating in redux.

To make so, it is required to extend store/ProductList/ProductList.redux.js file and on UPDATE_PRODUCT_LIST_ITEMS add preload links injection where will be reused method getCategoryProductCardsPlaceholderCount for preloading only visible on view port images:

import { getCategoryProductCardsPlaceholderCount } from 'Route/CategoryPage/CategoryPage.config';
import { getInitialState } from 'SourceStore/ProductList/ProductList.reducer';
import { ProductListActionType } from 'Store/ProductList/ProductList.type';
import { getIndexedProducts } from 'Util/Product';
import { getSmallImage } from 'Util/Product/Extract';

export * from 'SourceStore/ProductList/ProductList.reducer';

export const ProductListReducer = (
    state = getInitialState(),
    action,
) => {
    ...

    case ProductListActionType.UPDATE_PRODUCT_LIST_ITEMS:
        const products = getIndexedProducts(initialItems);

        // Preloading images for product cards on PLP
        if (window.isPrefetchValueUsed) {
            const preloadProductImage = (imageUrl) => {
                const link = document.createElement('link');
                link.rel = 'preload';
                link.as = 'image';
                link.href = imageUrl;

                document.head.appendChild(link);
            };

            products.slice(0, getCategoryProductCardsPlaceholderCount()).forEach((item) => {
                preloadProductImage(getSmallImage(item));
            });
        }

        return {
            ...state,
            currentArgs,
            isLoading: false,
            totalItems,
            totalPages,
            pages: { [currentPage]: products },
        };

    ...
};

export default ProductListReducer;

In result potential LCP images will be loaded after Product List information request done:

Untitled

<aside> 🎉 This is how to reduce time of LCP element rendering on Category page!

</aside>