Front-end application State Transfer troubleshooting case for server-side rendering based on Angular Universal engine

The author’s previous nugget article, a SAP development engineer’s 2022 year-end summary: Forty years old, mentioned that my current team is responsible for developing an e-commerce Storefront application based on the Angular framework.

The Storefront is an open source Angular application based on Angular and Bootstrap and built for SAP Commerce Cloud.

Figure 1: Spartacus Storefront’s home page

We all know that in the field of e-commerce, Search Engine Optimization (SEO) is crucial for any Storefront. It can make the e-commerce website more easily indexed by search engines.

However, so far, many search engine crawlers have not been able to fully parse the HTML content rendered on the browser side by single page applications (SPA-Single Page Application) such as Angular when parsing and indexing website content. Therefore, in the field of e-commerce, using Angular + Universal engines to enable server-side rendering of applications has almost become a standard feature, and Spartacus, which our team is responsible for developing, is no exception.

Recently, I have dealt with several cases of customer feedback at work regarding the handling of State Transfer failures in Angular applications under server-side rendering. I have specially excerpted one of them for the reference of the majority of Angular development colleagues.

What is Angular Universal

Angular Universal is Angular’s server-side rendering (SSR) solution.

Traditional Angular applications are single-page applications (SPA), and all view rendering is completed on the client side. When a user visits a SPA website, the server simply sends a JavaScript file containing the entire application code, and then runs this JavaScript file in the user’s browser to generate web page content. This means that users may encounter a blank page when initially accessing a web page, and need to wait for the JavaScript file to be downloaded, parsed, and run before they can see the complete web page content.

In contrast, server-side rendering applications render on the server, complete the generation of HTML for the static content of the web page, and then send this HTML to the user. In this way, users can see the complete web page content in the early stages of accessing the web page without waiting for JavaScript files to be downloaded, parsed and run. This method can increase the loading speed of the first screen, improve user experience, and is also more friendly to search engine optimization (SEO).

Angular Universal is a server-side rendering solution provided by Angular. It generates static HTML by running an Angular application on the server, and then sends this HTML to the user. When the user receives this HTML in the browser, Angular takes over the web page and upgrades it into a complete SPA. The following picture is a screenshot of the official Angular Universal documentation:

Figure 2: Angular Universal official documentation

The picture below is the effect of the Spartacus application without server-side rendering enabled. In the Chrome developer tools Network tab, we can observe that the cx-storefront element only has loading… This placeholder symbol.

Figure 3: Spartacus homepage rendering request in CSR (Client Side Render) mode

Let’s compare the effect after Spartacus turns on server-side rendering. Obviously, the HTML content in the green highlighted area in the figure below is the static content that is rendered on the server side and returned to the client.

Figure 4: Spartacus home page rendering request in SSR (Server Side Render) mode

The entry logic for Spartacus server-side rendering is defined in the server.ts file:

import {<!-- --> APP_BASE_HREF } from '@angular/common';
import {<!-- --> ngExpressEngine as engine } from '@nguniversal/express-engine';
import {<!-- -->
  defaultSsrOptimizationOptions,
  NgExpressEngineDecorator,
  SsrOptimizationOptions,
} from '@spartacus/setup/ssr';
import {<!-- --> Express } from 'express';
import {<!-- --> existsSync } from 'fs';
import {<!-- --> join } from 'path';
import 'zone.js/node';
import {<!-- --> AppServerModule } from './src/main.server';

const express = require('express');

const ssrOptions: SsrOptimizationOptions = {<!-- -->
  timeout:Number(
    process.env['SSR_TIMEOUT']  defaultSsrOptimizationOptions.timeout
  ),
};

const ngExpressEngine = NgExpressEngineDecorator.get(engine, ssrOptions);

export function app() {<!-- -->
  const server: Express = express();
  const distFolder = join(process.cwd(), 'dist/storefrontapp');
  const indexHtml = existsSync(join(distFolder, 'index.original.html'))
    ? 'index.original.html'
    : 'index';

  server.set('trust proxy', 'loopback');

  server.engine(
    'html',
    ngExpressEngine({<!-- -->
      bootstrap: AppServerModule,
    })
  );

  server.set('view engine', 'html');
  server.set('views', distFolder);

  server.get(
    '*.*',
    express.static(distFolder, {<!-- -->
      maxAge: '1y',
    })
  );

  server.get('*', (req, res) => {<!-- -->
    res.render(indexHtml, {<!-- -->
      req,
      providers: [{<!-- --> provide: APP_BASE_HREF, useValue: req.baseUrl }],
    });
  });

  return server;
}

function run() {<!-- -->
  const port = process.env['PORT'] || 4000;

  // Start up the Node server
  const server = app();
  server.listen(port, () => {<!-- -->
    console.log(`Node Express server listening on http://localhost:${<!-- -->port}`);
  });
}

// Webpack will replace 'require' with '__webpack_require__'
// '__non_webpack_require__' is a proxy to Node 'require'
// The below code is to ensure that the server is run only when not requiring the bundle.
declare const __non_webpack_require__: NodeRequire;
const mainModule = __non_webpack_require__.main;
const moduleFilename = (mainModule & amp; & amp; mainModule.filename) || '';
if (moduleFilename === __filename || moduleFilename.includes('iisnode')) {<!-- -->
  run();
}

export * from './src/main.server';

The code block highlighted in line 62 in the figure below is the entry and core of the Angular Universal engine rendering HTML pages on the server side.

Figure 5: Spartacus calls the Angular Universal engine to render the entry code on the server side.

Why Angular server-side rendering applications require State Transfer

To answer this question, we first need to figure out what State Transfer is.

In Angular Universal, State Transfer mainly refers to the process of transferring server-side state to the client after server-side rendering is completed. This prevents the client from re-fetching and calculating data that has already been fetched and calculated on the server side, thereby improving application performance.

Specifically, State Transfer is implemented through the TransferState service. The TransferState service provides a way to share state between the server and client. On the server side, you can store some data into TransferState, and then on the client side, you can get that data out of TransferState.

For example, let’s say your Angular app needs to get some data from the server and display it in a view. Without Angular Universal, when a user opens a web page, the browser first needs to download and run the JavaScript code, then the JavaScript code will send a request to the server to obtain data, and finally the data will be displayed in the view. This process can be slow because of the need to wait for the JavaScript code to download and run, and for the server to respond to data requests.

However, if you use Angular Universal and the TransferState service, the process will be much faster. When the server receives the user’s request, it will run the Angular application and send a data request to the server, then store the obtained data into TransferState and generate a view, and finally combine the view and TransferState is sent to the client together. When the client receives the response from the server, it does not need to send a data request to the server. Instead, it takes the data directly from TransferState and then displays the data in the view. This greatly reduces the time it takes to load a page for the first time.

That’s an overview of how State Transfer works in Angular Universal. Below we look at a practical example of this mechanism at work in Spartacus.

Taking the Spartacus product category page as an example, the relative url is:

/electronics-spa/en/USD/Open-Catalogue/Cameras/Digital-Cameras/c/575

When rendering in CSR mode, the size of the returned requested page is less than 1KB. The reason has been mentioned before. There is only one placeholder of loading... in the cx-storefront element, and its content is It is filled in the browser after Angular client Bootstrap.

Figure 6: Return results of the Spartacus product category page in CSR mode

Let’s look at the behavior of the same page with Spartacus enabled for server-side rendering. The entire request size reached 288 kb.

Figure 7: Return results of the Spartacus product category page in SSR mode

The reason is that the State Transfer introduced in this chapter contributes a large part of the data size.

We search based on the keyword app-state in the HTML response returned by the server to the browser in SSR mode and find a with the id of spartacus-app-state >script tag element.

Figure 8: State Transfer data contained in HTML after Spartacus server-side rendering

The type of this script element is application/json. The value contained in it is the business data obtained from the API server by calling the AJAX request when the Angular application is rendered on the server side. These data are serialized into JSON format.

If we look for some product business data on the UI, we can find the corresponding State data in the spartacus-app-state script element. The picture below is an example:

Figure 9: Spartacus CSR extracts state data from the spartacus-app-state script element

Faults in actual business

Although the result returned by the product Category page from the server side, as can be seen from Figure 9 above, already contains all the product business data in the script element of spartacus-app-state, but when the Angular application When the client bootstraps and re-renders, we can still observe a repeated product search API request in the Network panel of Chrome developer tools:

Figure 10: Unnecessary Product search API request in Angular client

Obviously this request is unnecessary and should be avoided:

Let’s observe the context in which the client initiated this request in the debugger:

It is found that the request is initiated in the ProductSearchService Service class.

Therefore, we can fix this fault by extending the Service class.

We write the following TypeScript code:

export class CustomProductSearchService extends ProductSearchService {<!-- -->
  transferState = inject(TransferState, {<!-- --> optional: true });
  isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
  isHydrated = false;
  results$ = new Subject<ProductSearchPage>();
  override search(query: string | undefined, searchConfig?: SearchConfig) {<!-- -->
    if (this.isBrowser & amp; & amp; !this.isHydrated) {<!-- -->
      const state = this.transferState?.get(CX_KEY, {<!-- -->} as StateWithProduct)!;
      const results = state[PRODUCT_FEATURE].search.results;
      this.results$.next(results);
      this.isHydrated = true;
      return;
    }
    super.search(query, searchConfig);
  }
  override getResults() {<!-- -->
    return merge(super.getResults(), this.results$);
  }
}

const CX_KEY = makeStateKey<StateWithProduct>('cx-state');

 Figure 13: Fix the implementation code for client-side rendering to issue redundant API requests
  1. The idea of fixing this fault is to first extend the Spartacus standard ProductSearchService service class in Angular, and then override its search method.

 transferState = inject(TransferState, {<!-- --> optional: true });

This line injects a service called TransferState that is used to transfer state between server-side rendering (SSR) and the browser. TransferState is part of Angular Universal. The { optional: true } parameter means that if the TransferState service cannot be found, no error will be reported.

 isBrowser = isPlatformBrowser(inject(PLATFORM_ID));

This line detects whether the current code is running in a browser environment. PLATFORM_ID is a token provided by Angular, which will be replaced by a specific platform ID at runtime. isPlatformBrowser is a function. If the current Angular application is running in the browser environment, then this function will return true.

 isHydrated = false;

This line declares a Boolean flag isHydrated, initialized to false. This flag is used to track whether state has been restored from TransferState.

 results$ = new Subject<ProductSearchPage>();

This line creates a new RxJS Subject. Subject is a special type of Observable in RxJS. It can emit new values and push these values to all subscriber.

  1. Overload the search method of the standard service class.
 override search(query: string | undefined, searchConfig?: SearchConfig) {<!-- -->

This line is the declaration of the search method, which is a method that overrides the method of the same name in the parent class. This method accepts a query string and an optional search configuration object as parameters.

 if (this.isBrowser & amp; & amp; !this.isHydrated) {<!-- -->

This line checks whether the current code is running in a browser environment and has not yet restored state from TransferState.

 const state = this.transferState?.get(CX_KEY, {<!-- -->} as StateWithProduct)!;

This line gets the status from TransferState. CX_KEY is the key of the state. If this key is not found in TransferState, an empty object will be returned.

 const results = state[PRODUCT_FEATURE].search.results;

This line gets the search results from the restored state.

 this.results$.next(results);

This ensures that when the initial page loads, there is no need to request data again and the server-side rendered data is used directly. Otherwise, in other cases, the search method of the parent class ProductSearchService will be called to perform product search.

Finally, CX_KEY is a key used to identify state transfers and is shared between the server and client to ensure that states are transferred and matched correctly. This key is created by the makeStateKey method and is used to uniquely identify a specific state. During server-side rendering, this key is used to find and extract state, which is then applied during client-side rendering.

When the page is first loaded, the search method of CustomProductSearchService is executed on the server side, extracting the product search results from the server-side rendered state.

These search results will be sent to the results$ Subject.

When the page is loaded on the client, the getResults method of CustomProductSearchService is called, merging the results rendered by the server and the results requested by the client to ensure the consistency of the search results.

In this way, when users browse the page in the browser, they do not need to request data again, but directly use the server-side rendering results.

The core idea of this code is to provide data as early as possible in the case of server-side rendering through the state transfer mechanism to speed up page loading and improve user experience. When rendering on the client side, maintain state consistency to ensure that users get consistent data. This is important for Angular apps that require SEO support, as it ensures that search engine crawlers are able to fetch the complete page content.

Summary

This article first introduces the necessity of introducing Angular Universal to implement server-side rendering in the field of e-commerce web application development, and then introduces State Transfer, an industry best practice that avoids repeatedly calling AJAX to obtain business data from the server during client-side rendering. Finally, Through a case of State Transfer implementation failure in an actual project, the analysis of such failures and detailed ideas for solving the problem are introduced. I hope it can be a reference for the majority of Angular development colleagues.