Skip to main content

How To Use Single Page Application (SPA) with PageBuilder Engine

PageBuilder Engine 2.8 now supports Single Page Application (SPA) navigation, meaning readers will be able to navigate between pages without a full reload of the one they are on currently. When a user navigates to another page on the site in “SPA mode”, PageBuilder Engine will fetch the page in the background and replace the contents of the existing document with the next one, rather than replacing the document itself. This approach improves the reader’s experience by decreasing latency between page loads, as well as allowing for new types of experiences like “persistent elements” that remain on the page between navigation events.

SPA support is an opt-in feature that is enabled by two triggers, both of which are located inside your site's Feature Pack. Once you have opted in to SPA by making both changes (outlined below) in your deployed code, your readers will be able to navigate between pages through the "SPA rendering workflow" instead of the "traditional rendering workflow" that requires full page loads for each new piece of content.

Enabling the SPA Workflow

There are 2 steps required to opt-in to PageBuilder Engine's SPA workflow:

Step 1: Turning on the SPA Service Worker

Under the hood, PageBuilder Engine uses a Service Worker to implement its SPA functionality. To enable SPA for your site, you will need to set an environment variable named FUSION_SERVICE_WORKER in your code that initializes the service worker. Locally, this can be placed in your .env file, and otherwise for remote deployments (for instance, anything other than local development), Environment Variables should be stored in the /environment directory of your Feature Pack.

In local .env file:

FUSION_SERVICE_WORKER=true

/environment/index.json

{
  ...
  "FUSION_SERVICE_WORKER": true,
  ...
}

PageBuilder Engine will not register the service worker correctly unless this variable is explicitly defined as true - any value other than true will cause the service worker to be unregistered.

Step 2: Making an Output Type "SPA enabled"

Once you have initialized the service worker, you'll need to denote which Output Types you want to use the SPA rendering workflow. Because the SPA rendering workflow is enabled at the Output Type level, any pages that are rendered with an Output Type marked as "SPA enabled" will use that workflow, and any pages rendered with non-SPA enabled Output Types will use the "traditional" rendering workflow. If a reader is navigating BETWEEN pages rendered with different Output Types, PB Engine will always use the traditional rendering workflow - in other words, SPA rendering only works when navigating between pages of the SAME Output Type, even if both Output Types are SPA enabled.

You can also choose to make an output type SPA enabled only for specific sites. To do this, you will need to set

.spa = [site1_id, site2_id, ...]

/components/output-types/spa-output.jsx

import React from 'react'

const SPAOutput = (props) => (
  <html lang="en">
    ...
  </html>
);

// The line below denote this Output Type as "SPA enabled"
SPAOutput.spa = true;

export default SPAOutput

The Output Type above is denoted as "SPA enabled" by the line SPAOutput.spa = true; - as long as the exported Output Type object has a property named spa that is set to true, any page rendered within that Output Type will attempt to use the SPA rendering workflow to move between pages.

At this point, we've enabled the PageBuilder Engine service worker and denoted our Output Type as "SPA enabled", so as we navigate between pages using the SPAOutput type, we'll see the pages don't fully reload - instead, only the assets necessary to render the next page are downloaded, resulting in a faster render.

Exiting SPA Navigation

Due to the way service workers intercept events and how the mono-bundle works, there are a few things that can break your readers out of the SPA rendering flow.

  1. Clicking on a link (or setting window.location) to a page with a different output type or a non-spa output type/site.

  2. Clearing cache for the site/manually refreshing the page.

  3. Manually entering a new URL in the navigation bar.

  4. Clicking on a link with a href value that is the same as the current URL.

  5. Promoting a new deployment. We are looking at ways around this limitation as a future improvement.

Removing the Service Worker

If you have deployed a SPA enabled bundle and decide you don't like it, you should make sure you are rolling back to either a 2.7.4+ or 2.8.x bundle. Both of these contain the required code to unregister the service worker for readers as they continue to browse your site. Even though the unregistration is done after a navigate event (as opposed to immediately when they revisit the site), the site will render using the traditional flow and readers should not notice the service worker removing itself in the background.

Note

If you are using the browser's developer tools to check if the service worker is uninstalled, be aware that many browsers show it as deleted in the application tab. Navigation will use the traditional render flow, but you may see the service worker listed for the site until you have restarted your browser.

Creating "Persistent" Elements on the Page

One of the benefits of the SPA workflow is it enables us to create new types of experiences for our readers, since PageBuilder Engine has more control over the document fetching and rendering process. "Persistent" elements are one of those features. Persistent elements are able to remain on the page between SPA-navigation events and preserve their state, so that they are not re-rendered on the subsequent page. This allows for interactive sites that preserve some data between pages in the UI (for example, a shopping cart feature that shows your purchases without having to fetch them from a server), as well as for multimedia experiences where audio or video can be played between page loads without interruption.

Adding a Persistent Element

For now, the only type of valid persistent element is a Static component. Because Static components are not meant to be re-rendered client side, they are perfect candidates for persistence, since they will not need to be reloaded on subsequent page loads. This does introduce a limitation in how client-side interactivity is handled for persistent elements - in order for your persistent elements to have any JavaScript functionality (for instance, click handlers, timeout logic, etc.), that code must be handled outside the normal React tree. Because Static components are essentially ignored by React, if you wish to add interactivity to them you'll need to add your own script (either through inline <script> tag or an external src) to do so.

Below, we'll outline what a simple persistent audio player might look like in code:

import PropTypes from 'prop-types'
import React from 'react'
import Static from 'fusion:static'

const AudioPlayer = ({ customFields }) =>
  <div>
    ...
    <Static id={ customFields.id } persistent>
      <audio controls>
        <source src={customFields.url} type={`audio/${customFields.type || 'mpeg'}`} />
        Your browser does not support the audio element.
      </audio>
    </Static>
    ...
  </div>

AudioPlayer.propTypes = {
  customFields: PropTypes.shape({
    id: PropTypes.string.isRequired,
    url: PropTypes.string.isRequired,
    type: PropTypes.string
  })
}

export default AudioPlayer

This component takes in 3 custom field inputs from PageBuilder Editor: the id of the element, the url of the audio file, and an optional type param for denoting the type of audio. The id field here is important because PageBuilder Engine uses this ID to compare persistent elements across page loads - if 2 persistent elements have the same ID, PageBuilder Engine assumes they are the same element and will persist the initial element in place of the element on the next page. If the elements have different IDs, even if they have the same content, PageBuilder Engine assumes the elements are different and will not persist them. Without this ID, it would be impossible to have multiple persistent elements of the same type on the page - but as long as you use different IDs for different elements, it's possible to have multiple different persistent "threads" of elements as you navigate between pages.

Tip

You can still use Static components without making them persistent, even if SPA is enabled. Non-persistent static components will use the new page's HTML without a client-side re-render. Similarly, persistent static elements with an id that doesn't exist on the current page will use the new page's data.

Warning

Due to an HTML restriction, iframes can not be persistent.

Adding Interactivity and State for Persistent Elements

While static components cannot have React managed event listeners, you can manually add event listeners and even have persistent state. Here's an example bundle for you to experiment with. Follow the steps below to run the bundle.

  1. Install the cli by running npm i.

  2. Start PageBuilder Engine by running npx fusion start.

  3. Wait until PageBuilder Engine is done compiling by running docker stats in another terminal and waiting for the webpack container's CPU usage to drop to 0%.

  4. Go to http://localhost/pf/test1/.

  5. Add a few videos by typing the URL in the input and clicking the Submit button. You can use https://interactiveexamples.mdn.mozilla.net/media/cc0videos/flower.mp4 and https://ia600300.us.archive.org/17/items/BigBuckBunny_124/Content/big_buck_bunny_720p_surround.mp4 as example urls.

  6. Click play to start the first video.

  7. As the video plays, you can try clicking the link and watching the URL change without interrupting the video playback.

  8. You can also refresh the page to see the state of the playlist is persisted.

  9. Finally, skip to the end of a video (or watch all the way through), and you'll see the playlist update and the next video begin to play.

Rules and limits of SPA Usage

  • Navigating between Output Types uses traditional rendering workflow.

  • Currently, We do not support you to have your own worker when SPA is enabled.

  • Persistent components must be static for now, meaning they cannot use React managed event listeners to manage interactivity.

  • Persistent components must share the same ID to work properly across page loads.

  • You can only test SPA navigation on CDN-enabled domain or your local development environment. SPA navigation doesn't work when the site is rendered on internal domains (okta-protected) and you will see an error in the dev console saying the service worker couldn’t be loaded on the internal domain.

  • If you need a specific element to be replaced, you need to set the attribute data-replace=”true”. For example, say that you have the element <link id=“canonical” rel=“canonical” href={`${websiteDomain}${canonicalUrl}`} /> where the href value needs to match the current page’s URL. In this case, you can set <link id=“canonical” rel=“canonical” href={`${websiteDomain}${canonicalUrl}`} data-replace=”true” /> and the previous link tag will be replaced with the one from the new page.

For tablet/mobile iOS devices only (desktop and Android devices are not impacted)

  • As of Engine release 3.2.3, the following search themes blocks will break persistence: header-nav-chain-block and search-results-list-block.

  • As of Engine release 3.2.3, the following deprecated themes blocks will break persistence: header-nav-block.

  • Navigation triggered through window.location will break persistence. You should check your custom features and libraries used in your custom features if they are doing JavaScript-triggered browser navigation (through window.location).

  • Usage of event.preventDefault() when clicking anchor links will disable SPA navigation.

  • If a user connects their phone to a laptop, opens the developer tools, and manually uninstalls the service worker, they can get to a bad state where anchor links will no longer trigger navigation. This is fixed after a page refresh.

For all mobile devices

If a user pops out the player using picture-in-picture capabilities of the mobile OS (both persistent and non-persistent players) and SPA navigation occurs, the pop out player will continue playing until the user manually closes it.

PageBuilder Engine Provided SPA Events

Since SPA does not trigger a full page render, you may have to do some additional work to support ads and analytics. When using SPA, PageBuilder Engine provides two events to which you can attach listeners. Both are fired at the window level. The SPA rendering flow:

  1. Service worker fetches the new page and determines it supports SPA

  2. LoadingSpa event is fired several times throughout the rendering process

  3. BeforeSpaRender event is fired

  4. SPA render completes

  5. AfterSpaRender event is fired

In most cases, you can add cleanup code to an event listener on the BeforeSpaRender event and code calling your global scripts to an event listener on the AfterSpaRender event. In order to ensure only new scripts/styles are loaded from the next page, you can add children to the following props in the Output Type and PageBuilder Engine will generate an id if one isn't provided:

  • CssLinks - for css links in the head

  • Styles - for inline css styles in the head

  • Libs - for scripts in the head

  • Fusion - for scripts in the body

You can listen to the LoadingSpa event to create your own loader or progress bar as it will fire events with the current loading percentage of the page as an integer (0-100). Below is an example of an event listener to process this event:

useEffect(() => {
  const listener = (event) => setPercent(event.detail);
  window.addEventListener('LoadingSpa', listener);
  return () => window.removeEventListener('LoadingSpa', listener);
}, []);