<aside> 🧑‍🎓 The critical data is used for rendering FCP, eliminate or make critical requests early. More on these concepts can be read in following guides:

How to get FCP and LCP readings?

How to identify critical request chain?

</aside>

Preloading of critical data, can:

To visualize it, consider the following request chain (unoptimized):

Untitled

Preloading critical data will have the following effect:

Untitled

To achieve this effect, the following actions are required:

1. Expose minimal data in the HTML document

FCP happens too early to rely on data requested from JavaScript. Therefore, it is suggested to inline data required for FCP right into the HTML document, on server side.

<aside> 🧠 To get fast FCP reading, the display of simple, yet contentful page is required. Specifically, the display of placeholders for the desired page type, with some entity-relevant content. Therefore, it is crucial to detect which type of page is opened early. Knowing a page type, we can start loading necessary data to render it. The necessary data could be, for example:

The template used to generate HTML document on server side, is located in public folder, in Magento theme mode, the public/index.php template is used. Modify it, to get critical data early. The following code to be extended with additional data (in addition to already provided page type):

window.actionName = {
  type: `<?= $this->getAction(); ?>`
};

Modify it, to look as follows:

window.actionName = {
  type: `<?= $this->getAction(); ?>`,
  id: parseInt(`<?= $this->getId(); ?>`) || null,
  sku: `<?= $this->getSku(); ?>` || null,
  name: `<?= $this->getName(); ?>`,
  page: <?= json_encode($this->getPage()); ?> || {},
  identifier: `<?= $this->getIdentifier(); ?>`,
  description: `<?= $this->getDescription(); ?>`
};

<aside> 🧠 During development (or in storefront mode) the public/index.html template is used. Feel free to hardcode desired window.actionName values in it. The real data will be put into the actionName only when the application is ran in Magento theme mode.

</aside>

The context of this in this template, is defined in ScandiPWA\\Router\\Controller\\Pwa. The ScandiPWA\\Router\\Controller\\Router is matching routes and is capable of obtaining necessary entity details to output into the HTML document. To provide necessary data, add setters to Pwa controller and call them with corresponding data from Router controller.

  1. Create View/Result/Page.php and populate with:

    <?php
    
    namespace Scandiweb\\Router\\View\\Result;
    
    use Magento\\Framework\\App\\Filesystem\\DirectoryList;
    use Magento\\Framework\\Exception\\FileSystemException;
    use Magento\\Framework\\Exception\\NoSuchEntityException;
    use Magento\\Framework\\Filesystem\\Driver\\File;
    use Magento\\Framework\\Locale\\Resolver;
    use Magento\\Framework\\Serialize\\Serializer\\Json;
    use Magento\\Framework\\Translate\\InlineInterface;
    use Magento\\Framework\\View\\Element\\Template\\Context;
    use Magento\\Framework\\View\\EntitySpecificHandlesList;
    use Magento\\Framework\\View\\Layout\\BuilderFactory;
    use Magento\\Framework\\View\\Layout\\GeneratorPool;
    use Magento\\Framework\\View\\Layout\\ReaderPool;
    use Magento\\Framework\\View\\LayoutFactory;
    use Magento\\Framework\\View\\Page\\Config\\RendererFactory;
    use Magento\\Framework\\View\\Page\\Layout\\Reader;
    use Magento\\Store\\Model\\StoreManagerInterface;
    use ScandiPWA\\Customization\\Controller\\AppIcon;
    use ScandiPWA\\Customization\\View\\Result\\Page as SourcePage;
    use Scandiweb\\Migrations\\Helper\\StoreHelper;
    
    /**
     * Class Page
     *
     * @package Scandiweb\\Customization\\View\\Result
     */
    class Page extends SourcePage
    {
        /**
         * @var string
         */
        protected $id;
    
        /**
         * @var string
         */
        protected $sku;
    
        /**
         * @var string
         */
        protected $name;
    
        /**
         * @var array|null
         */
        protected $page;
    
        /**
         * @var array|null
         */
        protected $post;
    
        /**
         * @var string|null
         */
        protected $imageUrl;
    
        /**
         * @var string
         */
        protected $description;
    
        /**
         * @var array|null
         */
        protected $storeConfig;
    
        /**
         * Page constructor.
         *
         * @param StoreManagerInterface $storeManager
         * @param Resolver $localeResolver
         * @param Context $context
         * @param LayoutFactory $layoutFactory
         * @param ReaderPool $layoutReaderPool
         * @param InlineInterface $translateInline
         * @param BuilderFactory $layoutBuilderFactory
         * @param GeneratorPool $generatorPool
         * @param RendererFactory $pageConfigRendererFactory
         * @param Reader $pageLayoutReader
         * @param DirectoryList $directoryList
         * @param Json $json
         * @param string $template
         * @param AppIcon $appIcon
         * @param bool $isIsolated
         * @param EntitySpecificHandlesList|null $entitySpecificHandlesList
         * @param null $action
         * @param array $rootTemplatePool
         */
        public function __construct(
            StoreManagerInterface $storeManager,
            Resolver $localeResolver,
            Context $context,
            LayoutFactory $layoutFactory,
            ReaderPool $layoutReaderPool,
            InlineInterface $translateInline,
            BuilderFactory $layoutBuilderFactory,
            GeneratorPool $generatorPool,
            RendererFactory $pageConfigRendererFactory,
            Reader $pageLayoutReader,
            DirectoryList $directoryList,
            Json $json,
            string $template,
            AppIcon $appIcon,
            $isIsolated = false,
            EntitySpecificHandlesList $entitySpecificHandlesList = null,
            $action = null,
            $rootTemplatePool = []
        ) {
            parent::__construct(
                $storeManager,
                $localeResolver,
                $context,
                $layoutFactory,
                $layoutReaderPool,
                $translateInline,
                $layoutBuilderFactory,
                $generatorPool,
                $pageConfigRendererFactory,
                $pageLayoutReader,
                $directoryList,
                $json,
                $template,
                $appIcon,
                $isIsolated,
                $entitySpecificHandlesList,
                $action,
                $rootTemplatePool
            );
    
            $this->id = '';
            $this->sku = '';
            $this->name = '';
            $this->page = null;
            $this->identifier = '';
            $this->description = '';
        }
    
        public function setId(string $id)
        {
            if($this->id === '') {
                $this->id = $id;
                return $this;
            }
    
            return '';
        }
    
        public function getId()
        {
            return $this->id;
        }
    
        public function setSku(string $sku)
        {
            if($this->sku === '') {
                $this->sku = $sku;
                return $this;
            }
    
            return '';
        }
    
        public function getSku()
        {
            return $this->sku;
        }
    
        public function setName(string $name)
        {
            if($this->name === '') {
                $this->name = $name;
                return $this;
            }
    
            return '';
        }
    
        public function getName()
        {
            return $this->name;
        }
    
        public function setPage($page)
        {
            if($this->page === null) {
                $this->page = $page;
                return $this;
            }
    
            return null;
        }
    
        public function getPage()
        {
            return $this->page;
        }
    
        public function setIdentifier(string $identifier)
        {
            if ($this->identifier === '') {
                $this->identifier = $identifier;
                return $this;
            }
    
            return '';
        }
    
        public function getIdentifier()
        {
            return $this->identifier;
        }
    
        public function setDescription(string $description)
        {
            if ($this->description === '') {
                $this->description = $description;
                return $this;
            }
    
            return '';
        }
    
        public function getDescription()
        {
            return $this->description;
        }
    }
    

<aside> 🎉 We got the data early right in the HTML document!

</aside>

2. Inject critical reducers into the app early

First, override a src/store/index.js. Inside, remove critical reducers from the getStaticReducers function, for example: ProductListReducer and ProductReducer. Make sure to remove an import too.

Next, add a logic to inject critical reducers early in src/index.js (entry-point). start by creating util in src/util/Store/index.js

export * from 'SourceUtil/Store';
export { default } from 'SourceUtil/Store';

export function injectReducers(store, reducers) {
    // Inject all the static reducers into the store
    Object.entries(reducers).forEach(
        ([name, reducer]) => {
            if (store.injectReducer) {
                store.injectReducer(name, reducer);
            }
        },
    );

    return store;
}

then use this util to inject priority reducers immediately:

import ProductReducer from 'Store/Product/Product.reducer';
import ProductListReducer from 'Store/ProductList/ProductList.reducer';
import { getStore } from 'Util/Store';

const store = getStore();

injectReducers(store, {
    ProductReducer,
    ProductListReducer
});

Also it is required to override App.component.js to adjust store configuration for app. Where is result it will be injecting reducers same was as early reducers.

import { App as SourceAppComponent } from 'SourceComponent/App/App.component';
import { getStaticReducers } from 'Store/index';
import getStore, { injectReducers } from 'Util/Store';

export class AppComponent extends SourceAppComponent {
    configureStore() {
        const store = getStore();

        injectReducers(store, getStaticReducers());

        this.reduxStore = store;
    }
}

export default AppComponent;

3. Preload full data from the first loaded chunk

Now, given the page type is known, we can preload necessary data required to render the page. To do, we need to check the page and execute fetch of the corresponding data.

To begin, add the following code right after imports in the src/index.js (entry-point):

switch (window.actionName.type) {
case 'CATEGORY':
	prefetchCategory();
	break;
case 'PRODUCT':
	prefetchProduct();
	break;
}

<aside> 🧠 Preloading of homepage data from JavaScript is not required, the data is already present in HTML document.

</aside>

Now, let’s focus on functions to preload the data for each page:

How to preload product page data?

Create a utility function alike within your application:

import ProductDispatcher from 'Store/Product/Product.dispatcher';
import getStore from 'Util/Store';

export const prefetchProduct = () => {
	ProductDispatcher.handleData(
		getStore().dispatch,
		{
      isSingleProduct: true,
      args: {
				filter: {
					// vvv can be either product id or sku
					// productID: window.actionName?.id
					productSKU: window.actionName?.sku
				}
			}
    }
	);
}

How to preload category page data?

Create a utility function alike within your application:

import ProductListDispatcher from 'Store/ProductList/ProductList.dispatcher';
import history from 'Util/History';
import getStore from 'Util/Store';
import { getQueryParam } from 'Util/Url';

export const prefetchCategory = () => {
	const { location } = history;

	const currentPage = +(getQueryParam('page', location) || 1);
	
	const selectedFiltersString = (getQueryParam('customFilters', location) || '').split(';');
	const customFilters = selectedFiltersString.reduce((acc, filter) => {
    if (!filter) {
      return acc;
    }
    const [key, value] = filter.split(':');

    return { ...acc, [key]: value.split(',') };
  }, {});

	const min = +getQueryParam('priceMin', location);
  const max = +getQueryParam('priceMax', location);

	ProductListDispatcher.handleData(
		getStore().dispatch,
		{
        isNext: false,
        isPlp: true,
        noAttributes: false,
        noVariants: false,
        args: {
            sort: {
                sortDirection: getQueryParam('sortDirection', location) || 'ASC',
                sortKey: getQueryParam('sortKey', location) || 'position'
            },
            filter: {
                priceRange: { min, max },
                customFilters,
                categoryIds: window.actionName.id
            },
            search: '',
            pageSize: 24,
            currentPage
        }
    }
	);
}

4. Adjust the application to utilize early available data

How to eliminate url rewrites request?

The URL rewrites state is kept in Redux. Initially, it is empty, therefore the URL rewrites request is made to populate it. Given the data from HTML document, it is possible for us to pre-populate the url rewrites state, to avoid making the initial request. To achieve it, replace the initial state of UrlRewrites.reducer to:

import { getInitialState as sourceGetInitialState } from 'SourceStore/UrlRewrites/UrlRewrites.reducer';

export const getInitialState 
= () => ({
	...sourceGetInitialState(),
	// vvv Assume last requested url rewrite was on the current page
	requestedUrl: location.pathname,
	// vvv Construct URL rewrite from minimal data from HTML document
  urlRewrite: {
		id: window.actionName.id,
		sku: window.actionName.sku,
		type: window.actionName.type, 
	},
});

export * from 'SourceStore/UrlRewrites/UrlRewrites.reducer';
import { connect } from 'react-redux';

import {
    mapDispatchToProps,
    mapStateToProps,
    UrlRewritesContainer as SourceUrlRewriteContainer
} from 'SourceRoute/UrlRewrites/UrlRewrites.container';

/** @namespace Scandipwa/Route/UrlRewrites/Container/UrlRewritesContainer */
export class UrlRewritesContainer extends SourceUrlRewriteContainer {
    __construct() {
        if (this.getIsLoading()) {
            this.requestUrlRewrite();
        }
    }
}

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

How to make use of minimal data in category?

// TODO: complete once we are sure about window.isPrefetchValueUsed

How to make use of minimal data in product?

// TODO: complete once we are sure about window.isPrefetchValueUsed

How to make use of minimal data on homepage?

The CMS page does not use Redux (until version 6.1.0). We need to replicate the CMS page load right when component is mounted. To achieve it, modify CmsPage.container like so:

import { connect } from 'react-redux';

import {
	mapStateToProps,
	mapDispatchToProps,
	CmsPageContainer as SourceCmsPageContainer
} 'SourceRoute/CmsPage/CmsPage.container';

export class CmsPageContainer extends SourceCmsPageContainer {
	requestPage() {
		const params = this.getRequestQueryParams();
    const { id, identifier = '' } = params;

		if (
      id === window.actionName.id
      || identifier.includes(window.actionName.page?.identifier)
    ) {
			// vvv Skip making a request, if we have preloaded CMS data
      this.onPageLoad({ cmsPage: window.actionName.page });
      return;
    }

		super.requestPage();
	}
}

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