Building an infinite scroll extension for Adobe PWA Studio

March 05, 2023

    Today I’m going to build an Infinite scroll extension for Magento PWA studio. If you are new to Magento PWA studio this will be a good introduction to some key concepts such as:

    • React Hooks
    • PWA Studio's extensibility framework (Targets and Targetables)
    • Peregrine Talons

    Ok, let’s get started. First thing let's create a folder anywhere in our system and run the yarn project creation script yarn init. I like to keep my extension folder inside a blank PWA studio installation folder so I can just open the one folder in my IDE, makes it easier to view the PWA source files.

    Yarn init will create a new package.json file.

    {
      "name": "infinite-category-scroll",
      "version": "0.0.0",
      "description": "An add-on for PWA studio which implements infinite scroll on category galleries",
      "main": "index.js",
      "author": "hello@peterford.dev",
      "license": "MIT"
    }

    The next thing we are going to do is create an intercept file, an intercept file is used to edit the functionality of the PWA source code without the need to alter it directly. For this example we are going to be wrapping the useCategory hook in a custom function to override some of the behavior.

    src/targets/intercept.js

    module.exports = (targets) => {
        // Wrap the useCategory talon with this extension
        const peregrineTargets = targets.of("@magento/peregrine");
        const talonsTarget = peregrineTargets.talons;
    
        // Set the buildpack features required by this extension
        const builtins = targets.of("@magento/pwa-buildpack");
        builtins.specialFeatures.tap((featuresByModule) => {
            featuresByModule["@peterforddev/infinite-category-scroll"] = {
                // Wrapper modules must be ES Modules
                esModules: true,
                cssModules: true
            };
        });
    
        // Wrap the useCategory talon with this extension
        talonsTarget.tap((talonWrapperConfig) => {
            talonWrapperConfig.RootComponents.Category.useCategory.wrapWith('@peterforddev/infinite-category-scroll/src/targets/wrapUseCategory');
        });
    };

    In our intercept file we can define the talon which we want to wrap with our custom functionality, here you can see we are targeting Rootcomopents.Category.useCategory and we are wrapping it with a file called wrapUseCategory.js

    “Peregrine Talons are the logic component counterparts for Venia UI components. A Talon is a PWA Studio term for a custom React hook that provides data or performs side effects for a specific UI component. Since they are closely coupled to a specific UI component, these hooks are not re-usable.” Adobe PWA Docs

    We need to declare where this intercept file lives in our project in the package.json file.

    package.json

    {
      "name": "infinite-category-scroll",
      "version": "0.0.0",
      "description": "An add-on for PWA studio which implements infinite scroll on category galleries",
      "main": "index.js",
      "author": "hello@peterford.dev",
      "license": "MIT",
      "pwa-studio": {
        "targets": {
          "intercept": "src/targets/intercept"
        }
      }
    }

    Next we will create the wrapper file we defined previously, wrapUseCategory.js. A wrapper file has access to the function that it is wrapping, usually we would call this wrapped function inside the wrapper and add data to it, in our case as there is functionality inside useCategory that we want to alter we’re going to copy the code from useCategory into our own wrapper file. Whenever useCategory is called it will instead call our wrapUseCategory function.

    Let’s have a look at the pagination functionality that we have copied from the default useCategory file. We can see that the useCategory hook returns an object called pageControl, pageControl contains a method called setCurrentPage, setCurrentPage is called by the pagination component on user interaction. There is then an effect that is triggered once the current page number changes, this runs the getCategoryQuery, which updates the category data which is passed to the Category component causing it to re-render to display the new data.

    src/targets/wrapUseCategory.js

        // Run the category query immediately and whenever its variable values change.
    useEffect(() => {
        // Wait until we have the type map to fetch product data.
        if (!filterTypeMap.size || !pageSize) {
            return;
        }
    
        const filters = getFiltersFromSearch(search);
    
        // Construct the filter arg object.
        const newFilters = {};
        filters.forEach((values, key) => {
            newFilters[key] = getFilterInput(values, filterTypeMap.get(key));
        });
    
        // Use the category uid for the current category page regardless of the
        // applied filters. Follow-up in PWA-404.
        newFilters['category_uid'] = {eq: id};
    
        runQuery({
            variables: {
                currentPage: Number(currentPage),
                id: id,
                filters: newFilters,
                pageSize: Number(pageSize),
                sort: {[currentSort.sortAttribute]: currentSort.sortDirection}
            }
        });
    }, [
        currentPage,
        currentSort,
        filterTypeMap,
        id,
        pageSize,
        runQuery,
        search
    ]);

    An effect is triggered when any of the values in the dependency array are updated.

    What we are going to do is alter the code so the currentPage is updated once the user reaches the bottom of the category grid, this will trigger the query, however rather than replacing the category data on each render we want our new data to be appended to the current data to display as one long grid.

    First thing we are going to do is create a new ref using the react hook useRef, we are then going to return this ref from our wrapper function so we can use it in the category.js file.

    src/targets/wrapUseCategory.js

    // Create a ref that we return to the category component and we will add it to the end of the product list in the DOM
    const endOfProductListRef = useRef(null);
    return {
        error,
        categoryData,
        loading,
        metaDescription,
        pageControl,
        sortProps,
        pageSize,
        categoryNotFound,
        endOfProductListRef // add in our ref
    };

    Next we are going to create a new file called useOnScreen.js in here we are going to create a custom hook that checks if a given ref is present within the current viewport and returns us a boolean value.

    src/hooks/useOnScreen.js

    const {useMemo, useEffect, useState} = require('react')
    export default function useOnScreen(ref) {
    
        const [isIntersecting, setIntersecting] = useState(false)
    
        const observer = useMemo(() => new IntersectionObserver(
            ([entry]) => setIntersecting(entry.isIntersecting)
        ), [ref])
    
    
        useEffect(() => {
            observer.observe(ref.current)
            return () => observer.disconnect()
        }, [])
    
        return isIntersecting
    }
    

    The Intersection Observer API lets code register a callback function that is executed whenever an element they wish to monitor enters or exits another element (or the viewport) Mozilla - Intersection Observer API Definition

    We call our custom hook and store the value in a variable called isInView. We can now use isInView as a dependency in a useEffect function meaning we can have some code that runs only when the isInView value is updated.

    src/targets/wrapUseCategory.js

    // Update the current page when the end of the product list is in the viewport
    useEffect(() => {
        // only run if we are not on the last page
        if (isInView && currentPage < totalPages) {
            setCurrentPage(currentPage + 1);
        }
    }, [isInView]);

    The code above will update the page if the end of product list is in the viewport and if we are not on the last page of products, replacing the need for the user to click on the next page manually.

    Ok, so we have got to a point where our code will now run when the user scrolls to the bottom of the product list, however currently this is just updating the products in the list and is not appending the products to the end of a list as we would expect with infinite scroll functionality.

    To retain the product list between renders we are going to set the category data in a state. We then need to remove the original categoryData variable.

    Remove this code

    src/targets/wrapUseCategory.js

    const categoryData = categoryLoading && !data ? null : data; 

    Adding this

          // store category data in state so we can retain between renders
    const [categoryData, setCategoryData] = useState(null);

    We’re now creating a function handleUpdateProductData that we can run on completion of the category query, if the current page is one we can just return the data as this is either the initial page load, or the user has applied a filter which resets the page number and therefore we want to reset the product list to the filtered products.

    src/targets/wrapUseCategory.js

     const handleUpdateProductData = data => {
        // if current page is 1 , we can't just check for previous data as if the user as changed the filters there could already
        // be data in state but we want to reset not append the filtered data to the current.
        if (currentPage === 1) {
            setCategoryData(data)
        } else {
            setCategoryData(prev => {
                // if there is no previous data return the new data (happens on initial load && page doesn't equal 1)
                if (!prev) {
                    return data;
                }
                
                // create one array with all of the products from the previous and the new data
                const itemsJoined = prev.products.items.concat(data.products.items);
    
                // return the new data with the new products array
                return {
                    ...data,
                    products: {
                        items: itemsJoined,
                        totalPages: data.products.totalPages,
                        page_info: data.products.page_info,
                        total_count: data.products.total_count
                    }
                };
            });
        }
    
        // reset the initial render flag so next time we query we get the default amount of products
        setIsPageOneInitialRender(true)
    }

    Notice inside our setCategoryData function we pass in a variable called prev, this is the value of the state variable at the beginning of the current render. There is an edge case where if the user navigates directly to a category page that is not page one or if the user refreshes the category page not on page one then we want to just return data without modifying the results.

    If there is data inside prev then we want to update products.items array with the data from the previous data and the newly queried data.

    Now that we have our function we can pass it to the options object where the runQuery function is defined, there is an option to pass a callback that will run once the query has completed. The callback function accepts the data the query returns which we use inside handleUpdateProductData.

    src/targets/wrapUseCategory.js

    const [runQuery, queryResponse] = useLazyQuery(getCategoryQuery, {
        fetchPolicy: 'cache-and-network',
        nextFetchPolicy: 'cache-first',
        onCompleted: data => handleUpdateProductData(data)
    });

    Ok so we’re nearly there, we just need to cover for one more edge case and that is if a user loads / refreshes the page on a page other than number one, we need to make sure that the page loads all the current & previous pages products.

    To do this we are going to create a new state isPageOneInitialRender which will be a boolean value. On initial page load if the current page is anything other than 1 the initial state will be set to false, we will use this value to alter the category product query options on the first render of the page.

    When runQuery runs for the first time if isPageOneInitialRender is set to false we will query for the the products per page multiplied by the current page number, this will give us all the products up to the current page, we then set isPageOneInitialRender to true so on subsequent renders we only query for one more page’s worth of products at a time.

    src/targets/wrapUseCategory.js

     runQuery({
        variables: {
            currentPage: isPageOneInitialRender ? Number(currentPage) : 1,
            id: id,
            filters: newFilters,
            pageSize: Number(isPageOneInitialRender ? pageSize : currentPage * pageSize), // Multiply by initial page to get the correct number of products
            sort: {[currentSort.sortAttribute]: currentSort.sortDirection}
        }
    });

    A couple final clean up bits in this file, we will remove the function useScrollTopOnChange(currentPage) to stop the page scrolling to top on products change.

    Finally, we need to update the JSX of the category component, again we don’t want to edit the source code directly, fortunately PWA studio gives us the tools to be able to do this from inside our extension.

    src/targets/extend-intercept.js

    const {Targetables} = require('@magento/pwa-buildpack');
    const React = require("react");
    
    module.exports = targets => {
        const targetables = Targetables.using(targets);
    
    
        const CategoryRootComponent = targetables.reactComponent(
            '@magento/venia-ui/lib/RootComponents/Category/category.js'
        );
    
        const CategoryContentComponent = targetables.reactComponent(
            '@magento/venia-ui/lib/RootComponents/Category/categoryContent.js'
        );
    
        // add our ref to end of the CateogoryRootComponent
        CategoryRootComponent.appendJSX(
            `<Fragment>`,
            `<div ref={talonProps.endOfProductListRef} />`,
        );
    
        // import our custom PageIndicator component
        CategoryContentComponent.addImport(
            `import PageIndicator from '@peterforddev/infinite-category-scroll/src/components/pageIndicator';`
        )
    
        // remove original pagination and add our custom PageIndicator component
        CategoryContentComponent.replaceJSX(
            `<div className={classes.pagination}>{pagination}</div>`,
            `<PageIndicator currentCount={items.length} totalCount={totalCount}/>`
        )
    };
    

    TargetableReactComponent
    Presents a convenient API for consumers to add common transforms to React components and the JSX in them, in a semantic way. Adobe PWA Docs

    We target the required components by pathing their path to the reactComponent function provided by pwa buildpack. We can then use some of the provided methods to alter the relevant JSX inside the components.

    Finally, we need to add some code in the local-intercept.js file which exists in all scaffolded PWA studio projects.

    *your-pwa-project-root/local-intercept.js

    const infiniteScrollIntercept = require('@peterforddev/infinite-category-scroll/src/targets/extend-intercept');
    
    function localIntercept(targets) {
        infiniteScrollIntercept(targets);
    }
    
    module.exports = localIntercept;
    

    Summary

    So there we have our simple infinite scroll extension. Currently it only runs on category pages, in future I would like to update this to work on other pages such as the search page. I would also like to add a backend component to the extension so the users can toggle infinite scroll per category via the admin.

    I hope I’ve helped you understand a bit more about working with PWA studio, if you have any questions feel free to drop me an email.

    Useful Resources


    Peter Ford - UK Based front end developer specialising in React & Adobe Commerce / Magento. View my work on GitHub