Repository: single-spa/single-spa-layout (js)

Issue:
Layout should have a provision to handle application loading errors gracefully
Please refer to issue #47 where if there is a loading failure we continue to see the `loader` and the user will not know if there is a failure. 

The layout engine should have some provision to show an alternate message or alternate error handling component plus also unload the js file for the failing application. 



Pull request description:
Implement Error UIs for applications. Resolves #49.
See #49 - this allows you to specify an error UI to show for any application

Patch:
diff --git a/src/browser/constructApplications.js b/src/browser/constructApplications.js
index 9b5c96d..ea87ecc 100644
--- a/src/browser/constructApplications.js
+++ b/src/browser/constructApplications.js
@@ -2,6 +2,7 @@ import { applicationElementId } from "./constructLayoutEngine";
 import { mountRootParcel } from "single-spa";
 import { inBrowser } from "../utils/environment-helpers.js";
 import { find } from "../utils/find.js";
+import { htmlToParcelConfig } from "../utils/parcel-utils";
 
 /**
  * @typedef {{
@@ -169,17 +170,3 @@ function placeLoader(appName, appRoute, loadingPromise) {
     );
   });
 }
-
-function htmlToParcelConfig(str) {
-  return {
-    bootstrap: () => Promise.resolve(),
-    mount: (props) =>
-      Promise.resolve().then(() => {
-        props.domElement.innerHTML = str;
-      }),
-    unmount: (props) =>
-      Promise.resolve().then(() => {
-        props.domElement.innerHTML = "";
-      }),
-  };
-}
diff --git a/src/browser/constructLayoutEngine.js b/src/browser/constructLayoutEngine.js
index fd1d852..d41ba7c 100644
--- a/src/browser/constructLayoutEngine.js
+++ b/src/browser/constructLayoutEngine.js
@@ -1,5 +1,13 @@
-import { matchRoute, resolvePath } from "../isomorphic/matchRoute";
 import { inBrowser } from "../utils/environment-helpers";
+import {
+  addErrorHandler as singleSpaAddErrorHandler,
+  mountRootParcel,
+  removeErrorHandler,
+  getAppStatus as singleSpaGetAppStatus,
+  SKIP_BECAUSE_BROKEN,
+  LOAD_ERROR,
+} from "single-spa";
+import { htmlToParcelConfig } from "../utils/parcel-utils";
 
 /**
  * @typedef {{
@@ -21,9 +29,14 @@ export function constructLayoutEngine({
   routes: resolvedRoutes,
   applications,
   active = true,
+  // secretly used for tests, but not exposed in typescript or docs
+  // was too lazy to mock all of single-spa, including pathToActiveWhen
+  addErrorHandler = singleSpaAddErrorHandler,
+  getAppStatus = singleSpaGetAppStatus,
 }) {
   let isActive = false;
   let pendingRemovals = [];
+  let errorParcelByAppName = {};
 
   const baseWithoutSlash = resolvedRoutes.base.slice(
     0,
@@ -40,6 +53,11 @@ export function constructLayoutEngine({
       }
 
       if (inBrowser) {
+        window.addEventListener(
+          "single-spa:before-routing-event",
+          unmountErrorParcels
+        );
+
         window.addEventListener(
           "single-spa:before-mount-routing-event",
           arrangeDomElements
@@ -47,6 +65,8 @@ export function constructLayoutEngine({
 
         window.addEventListener("single-spa:routing-event", handleRoutingEvent);
 
+        addErrorHandler(errorHandler);
+
         arrangeDomElements();
       }
     },
@@ -58,6 +78,11 @@ export function constructLayoutEngine({
       }
 
       if (inBrowser) {
+        window.removeEventListener(
+          "single-spa:before-routing-event",
+          unmountErrorParcels
+        );
+
         window.removeEventListener(
           "single-spa:before-mount-routing-event",
           arrangeDomElements
@@ -67,6 +92,8 @@ export function constructLayoutEngine({
           "single-spa:routing-event",
           handleRoutingEvent
         );
+
+        removeErrorHandler(errorHandler);
       }
     },
   };
@@ -77,6 +104,42 @@ export function constructLayoutEngine({
 
   return layoutEngine;
 
+  function errorHandler(err) {
+    const applicationRoute = findApplicationRoute({
+      applicationName: err.appOrParcelName,
+      location: window.location,
+      routes: resolvedRoutes.routes,
+    });
+    if (applicationRoute && applicationRoute.error) {
+      const applicationDomContainer = document.getElementById(
+        applicationElementId(applicationRoute.name)
+      );
+      const parcelConfig =
+        typeof applicationRoute.error === "string"
+          ? htmlToParcelConfig(applicationRoute.error)
+          : applicationRoute.error;
+      errorParcelByAppName[applicationRoute.name] = mountRootParcel(
+        parcelConfig,
+        {
+          domElement: applicationDomContainer,
+        }
+      );
+    }
+  }
+
+  function unmountErrorParcels({ detail: { newAppStatuses } }) {
+    for (let appName in newAppStatuses) {
+      if (
+        errorParcelByAppName[appName] &&
+        brokenStatus(getAppStatus(appName)) &&
+        !brokenStatus(newAppStatuses[appName])
+      ) {
+        errorParcelByAppName[appName].unmount();
+        delete errorParcelByAppName[appName];
+      }
+    }
+  }
+
   function arrangeDomElements() {
     const path = location[resolvedRoutes.mode === "hash" ? "hash" : "pathname"];
 
@@ -196,6 +259,48 @@ function recurseRoutes({
   return previousSibling;
 }
 
+/**
+ * @typedef {{
+ * location: URL,
+ * routes: Array<import('../isomorphic/constructRoutes').RouteChild>,
+ * applicationName: string
+ * }} FindApplicationRouteInput
+ *
+ * @param {FindApplicationRouteInput} input
+ * @returns {import('./constructRoutes').Application}
+ */
+function findApplicationRoute({ applicationName, location, routes }) {
+  for (let i = 0; i < routes.length; i++) {
+    const route = routes[i];
+
+    if (route.type === "application") {
+      if (route.name === applicationName) {
+        return route;
+      }
+    } else if (route.type === "route") {
+      if (route.activeWhen(location)) {
+        const routeResult = findApplicationRoute({
+          applicationName,
+          location,
+          routes: route.routes,
+        });
+        if (routeResult) {
+          return routeResult;
+        }
+      }
+    } else if (route.routes) {
+      const routeResult = findApplicationRoute({
+        applicationName,
+        location,
+        routes: route.routes,
+      });
+      if (routeResult) {
+        return routeResult;
+      }
+    }
+  }
+}
+
 /**
  *
  * @param {Node} node
@@ -225,3 +330,7 @@ function removeNode(node) {
 export function applicationElementId(name) {
   return `single-spa-application:${name}`;
 }
+
+function brokenStatus(status) {
+  return status === SKIP_BECAUSE_BROKEN || status === LOAD_ERROR;
+}
diff --git a/src/isomorphic/constructRoutes.js b/src/isomorphic/constructRoutes.js
index 07973b7..7c91634 100644
--- a/src/isomorphic/constructRoutes.js
+++ b/src/isomorphic/constructRoutes.js
@@ -196,6 +196,21 @@ function elementToJson(element, htmlLayoutData) {
         );
       }
     }
+
+    const errorKey = getAttribute(element, "error");
+    if (errorKey) {
+      if (
+        htmlLayoutData.errors &&
+        htmlLayoutData.errors.hasOwnProperty(errorKey)
+      ) {
+        application.error = htmlLayoutData.errors[errorKey];
+      } else if (inBrowser) {
+        throw Error(
+          `Application error handler '${loaderKey}' was not defined in the htmlLayoutData`
+        );
+      }
+    }
+
     setProps(element, application, htmlLayoutData);
     return [application];
   } else if (element.nodeName.toLowerCase() === "route") {
@@ -341,7 +356,7 @@ function validateAndSanitize(routesConfig) {
       validateKeys(
         propertyName,
         route,
-        ["type", "name", "props", "loader"],
+        ["type", "name", "props", "loader", "error"],
         disableWarnings
       );
       if (route.props) {
diff --git a/src/server/renderServerResponseBody.js b/src/server/renderServerResponseBody.js
index 34fc309..aae6438 100644
--- a/src/server/renderServerResponseBody.js
+++ b/src/server/renderServerResponseBody.js
@@ -33,6 +33,7 @@ const GT_REGEX = />/g;
  * renderOptions: RenderOptions;
  * serverLayout: import('./constructServerLayout').serverLayout;
  * applicationProps: import('single-spa').AppProps;
+ * inRouterElement: boolean;
  * }} SerializeArgs
  *
  * @typedef {{
@@ -63,6 +64,7 @@ export function renderServerResponseBody(serverLayout, renderOptions) {
     renderOptions,
     serverLayout,
     applicationProps,
+    inRouterElement: false,
   });
 
   return {
@@ -76,7 +78,7 @@ export function renderServerResponseBody(serverLayout, renderOptions) {
  * @param {SerializeArgs} serializeArgs
  */
 function serializeChildNodes(args) {
-  const { node: parentNode } = args;
+  const { node: parentNode, inRouterElement } = args;
 
   let childNodes = treeAdapter.getChildNodes(parentNode);
 
@@ -88,7 +90,7 @@ function serializeChildNodes(args) {
     }
     let serialize;
 
-    if (isApplicationNode(node)) {
+    if (!inRouterElement && isApplicationNode(node)) {
       serialize = serializeApplication;
     } else if (isRouteNode(node)) {
       serialize = serializeRoute;
@@ -161,7 +163,11 @@ function serializeApplication({
     appStream = stringStream(appStream);
   }
 
+  output.add(stringStream(`<div id="single-spa-application:${props.name}">`));
+
   output.add(appStream);
+
+  output.add(stringStream(`</div>`));
 }
 
 function isRouterContent(node) {
@@ -253,7 +259,8 @@ function serializeElement(args) {
         ? treeAdapter.getTemplateContent(node)
         : node;
 
-    const newArgs = { ...args, node: childNodesHolder };
+    const inRouterElement = args.inRouterElement || tn === "single-spa-router";
+    const newArgs = { ...args, node: childNodesHolder, inRouterElement };
     serializeChildNodes(newArgs);
     output.add(stringStream(`</${tn}>`));
   }
diff --git a/src/utils/parcel-utils.js b/src/utils/parcel-utils.js
new file mode 100644
index 0000000..577025e
--- /dev/null
+++ b/src/utils/parcel-utils.js
@@ -0,0 +1,13 @@
+export function htmlToParcelConfig(str) {
+  return {
+    bootstrap: () => Promise.resolve(),
+    mount: (props) =>
+      Promise.resolve().then(() => {
+        props.domElement.innerHTML = str;
+      }),
+    unmount: (props) =>
+      Promise.resolve().then(() => {
+        props.domElement.innerHTML = "";
+      }),
+  };
+}


Test patch:
diff --git a/test/browser-only/__snapshots__/constructLayoutEngine-browser.test.js.snap b/test/browser-only/__snapshots__/constructLayoutEngine-browser.test.js.snap
index fe58110..19691e0 100644
--- a/test/browser-only/__snapshots__/constructLayoutEngine-browser.test.js.snap
+++ b/test/browser-only/__snapshots__/constructLayoutEngine-browser.test.js.snap
@@ -344,3 +344,46 @@ exports[`constructLayoutEngine browser can successfully rearrange dom elements d
   />
 </body>
 `;
+
+exports[`constructLayoutEngine browser error handling shows an error UI when an application goes into SKIP_BECAUSE_BROKEN status 1`] = `
+<body>
+  <div
+    id="single-spa-application:app1"
+  >
+    <div>
+      Oops, app1 is broken
+    </div>
+  </div>
+</body>
+`;
+
+exports[`constructLayoutEngine browser error handling works with a parcel provided for the error UI 1`] = `
+<body>
+  <div
+    id="single-spa-application:app1"
+  >
+    <div>
+      App 1 is broken (parcel)
+    </div>
+  </div>
+</body>
+`;
+
+exports[`constructLayoutEngine browser error handling works with error handlers defined in HTML 1`] = `
+<body>
+  <div
+    id="single-spa-application:header"
+  />
+  <div
+    class="main-content"
+  >
+    <div
+      id="single-spa-application:app1"
+    >
+      <div>
+        Oops! An error occurred!
+      </div>
+    </div>
+  </div>
+</body>
+`;
diff --git a/test/browser-only/constructLayoutEngine-browser.test.js b/test/browser-only/constructLayoutEngine-browser.test.js
index f0797e5..5d580ba 100644
--- a/test/browser-only/constructLayoutEngine-browser.test.js
+++ b/test/browser-only/constructLayoutEngine-browser.test.js
@@ -5,6 +5,7 @@ import {
 } from "../../src/single-spa-layout.js";
 import { screen } from "@testing-library/dom";
 import { parseFixture } from "../html-utils.js";
+import { triggerAppChange, getAppStatus } from "single-spa";
 
 describe(`constructLayoutEngine browser`, () => {
   /** @type {import('../../src/constructLayoutEngine').LayoutEngine} */
@@ -642,6 +643,278 @@ describe(`constructLayoutEngine browser`, () => {
     );
     expect(document.body).toMatchSnapshot();
   });
+
+  describe(`error handling`, () => {
+    it(`shows an error UI when an application goes into SKIP_BECAUSE_BROKEN status`, async () => {
+      /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
+      const routes = constructRoutes({
+        containerEl: "body",
+        base: "/",
+        mode: "history",
+        routes: [
+          {
+            type: "route",
+            path: "/app1",
+            routes: [
+              {
+                type: "application",
+                name: "app1",
+                error: "<div>Oops, app1 is broken</div>",
+              },
+            ],
+          },
+        ],
+      });
+
+      const applications = constructApplications({
+        routes,
+        loadApp: async (name) => {
+          return {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          };
+        },
+      });
+
+      const errorHandlers = [];
+
+      layoutEngine = constructLayoutEngine({
+        routes,
+        applications,
+        addErrorHandler(handler) {
+          errorHandlers.push(handler);
+        },
+      });
+
+      history.pushState(history.state, document.title, "/app1");
+
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-mount-routing-event")
+      );
+
+      errorHandlers.forEach((cb) =>
+        cb({
+          appOrParcelName: "app1",
+        })
+      );
+
+      await tick();
+
+      expect(document.body).toMatchSnapshot();
+    });
+
+    it(`works with a parcel provided for the error UI`, async () => {
+      /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
+      const routes = constructRoutes({
+        containerEl: "body",
+        base: "/",
+        mode: "history",
+        routes: [
+          {
+            type: "route",
+            path: "/app1",
+            routes: [
+              {
+                type: "application",
+                name: "app1",
+                error: {
+                  async bootstrap() {},
+                  async mount(props) {
+                    const div = document.createElement("div");
+                    div.textContent = `App 1 is broken (parcel)`;
+                    props.domElement.appendChild(div);
+                  },
+                  async unmount(props) {},
+                },
+              },
+            ],
+          },
+        ],
+      });
+
+      const applications = constructApplications({
+        routes,
+        loadApp: async (name) => {
+          return {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          };
+        },
+      });
+
+      const errorHandlers = [];
+
+      layoutEngine = constructLayoutEngine({
+        routes,
+        applications,
+        addErrorHandler(handler) {
+          errorHandlers.push(handler);
+        },
+      });
+
+      history.pushState(history.state, document.title, "/app1");
+
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-mount-routing-event")
+      );
+
+      errorHandlers.forEach((cb) =>
+        cb({
+          appOrParcelName: "app1",
+        })
+      );
+
+      await tick();
+
+      expect(document.body).toMatchSnapshot();
+    });
+
+    it(`works with error handlers defined in HTML`, async () => {
+      const { routerElement } = parseFixture("error-handlers.html");
+      const data = {
+        errors: {
+          headerError: {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          },
+          mainContentError: `<div>Oops! An error occurred!</div>`,
+        },
+      };
+
+      const routes = constructRoutes(routerElement, data);
+      const applications = constructApplications({
+        routes,
+        async loadApp({ name }) {
+          throw Error();
+        },
+      });
+
+      const errorHandlers = [];
+
+      layoutEngine = constructLayoutEngine({
+        routes,
+        applications,
+        addErrorHandler(handler) {
+          errorHandlers.push(handler);
+        },
+      });
+
+      history.pushState(history.state, document.title, "/app1");
+
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-mount-routing-event")
+      );
+
+      errorHandlers.forEach((cb) =>
+        cb({
+          appOrParcelName: "app1",
+        })
+      );
+
+      await tick();
+
+      expect(document.body).toMatchSnapshot();
+    });
+
+    it(`unmounts the error parcel when the application gets unloaded`, async () => {
+      let parcelWasMounted = false,
+        parcelWasUnmounted = false;
+
+      /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
+      const routes = constructRoutes({
+        containerEl: "body",
+        base: "/",
+        mode: "history",
+        routes: [
+          {
+            type: "route",
+            path: "/app1",
+            routes: [
+              {
+                type: "application",
+                name: "app1",
+                error: {
+                  async bootstrap() {},
+                  async mount(props) {
+                    const div = document.createElement("div");
+                    div.textContent = `App 1 is broken (parcel)`;
+                    props.domElement.appendChild(div);
+                    parcelWasMounted = true;
+                  },
+                  async unmount(props) {
+                    parcelWasUnmounted = true;
+                  },
+                },
+              },
+            ],
+          },
+        ],
+      });
+
+      const applications = constructApplications({
+        routes,
+        loadApp: async (name) => {
+          return {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          };
+        },
+      });
+
+      const errorHandlers = [];
+
+      layoutEngine = constructLayoutEngine({
+        routes,
+        applications,
+        addErrorHandler(handler) {
+          errorHandlers.push(handler);
+        },
+        getAppStatus() {
+          return "SKIP_BECAUSE_BROKEN";
+        },
+      });
+
+      history.pushState(history.state, document.title, "/app1");
+
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-mount-routing-event")
+      );
+
+      errorHandlers.forEach((cb) =>
+        cb({
+          appOrParcelName: "app1",
+        })
+      );
+
+      await tick();
+
+      expect(parcelWasMounted).toBe(true);
+      expect(parcelWasUnmounted).toBe(false);
+
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-mount-routing-event")
+      );
+
+      // indicate that the application has been unloaded
+      window.dispatchEvent(
+        new CustomEvent("single-spa:before-routing-event", {
+          detail: {
+            newAppStatuses: {
+              app1: "MOUNTED",
+            },
+          },
+        })
+      );
+
+      await tick();
+
+      expect(parcelWasUnmounted).toBe(true);
+    });
+  });
 });
 
 function tick() {
diff --git a/test/constructRoutes.test.js b/test/constructRoutes.test.js
index e400a7d..43c78ee 100644
--- a/test/constructRoutes.test.js
+++ b/test/constructRoutes.test.js
@@ -212,7 +212,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig.routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props, loader`
+        `Invalid routesConfig.routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props, loader, error`
       );
 
       console.warn.mockReset();
@@ -228,7 +228,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig.routes[0]: received invalid properties 'routes', but valid properties are type, name, props, loader`
+        `Invalid routesConfig.routes[0]: received invalid properties 'routes', but valid properties are type, name, props, loader, error`
       );
     });
 
@@ -268,7 +268,7 @@ describe("constructRoutes", () => {
       });
       expect(console.warn).toHaveBeenCalled();
       expect(console.warn.mock.calls[0][0].message).toEqual(
-        `Invalid routesConfig.routes[0].routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props, loader`
+        `Invalid routesConfig.routes[0].routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props, loader, error`
       );
     });
 
@@ -628,6 +628,28 @@ describe("constructRoutes", () => {
         ],
       });
     });
+
+    it(`handles the 'error' property on applications`, () => {
+      expect(
+        constructRoutes({
+          routes: [
+            {
+              type: "application",
+              name: "app1",
+              error: `<div>Oops, looks like app1 is broken</div>`,
+            },
+          ],
+        })
+      ).toMatchObject({
+        routes: [
+          {
+            type: "application",
+            name: "app1",
+            error: `<div>Oops, looks like app1 is broken</div>`,
+          },
+        ],
+      });
+    });
   });
 
   describe("Loaders defined in HTML", () => {
@@ -709,6 +731,28 @@ describe("constructRoutes", () => {
       });
     });
 
+  describe(`error handlers defined in HTML`, () => {
+    it(`can parse and apply error handlers from HTML files`, () => {
+      const { document, routerElement } = parseFixture("error-handlers.html");
+      const data = {
+        errors: {
+          headerError: {
+            async bootstrap() {},
+            async mount() {},
+            async unmount() {},
+          },
+          mainContentError: `<div>Oops! An error occurred!</div>`,
+        },
+      };
+      const routes = constructRoutes(routerElement, data);
+
+      const headerApp = findApplication(routes.routes, "header");
+      expect(headerApp).toBeDefined();
+      expect(headerApp.error).toBe(data.errors.headerError);
+      expect(headerApp.props).not.toBeDefined();
+    });
+  });
+
   describe("router config defined in HTML", () => {
     const { document, routerElement } = parseFixture("router-config.html");
     const routes = constructRoutes(routerElement);
diff --git a/test/fixtures/error-handlers.html b/test/fixtures/error-handlers.html
new file mode 100644
index 0000000..dbe20bd
--- /dev/null
+++ b/test/fixtures/error-handlers.html
@@ -0,0 +1,18 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Document</title>
+  </head>
+  <body>
+    <single-spa-router mode="history" base="/">
+      <application name="header" error="headerError"></application>
+      <div class="main-content">
+        <route path="app1">
+          <application name="app1" error="mainContentError"></application>
+        </route>
+      </div>
+    </single-spa-router>
+  </body>
+</html>
diff --git a/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap b/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
index c2277ea..3900446 100644
--- a/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
+++ b/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
@@ -14,12 +14,18 @@ exports[`renderServerResponseBody allows for strings to be returned from renderA
   </head>
   <body>
     <div id="single-spa-application:header">
+      <button>
+        App header
+      </button>
     </div>
     <div class="main-content">
       <button>
         Top level content
       </button>
       <div id="single-spa-application:app1">
+        <button>
+          App app1
+        </button>
       </div>
       Some raw text
     </div>
@@ -33,14 +39,14 @@ exports[`renderServerResponseBody allows for strings to be returned from renderA
     <single-spa-router mode="history"
                        base="/"
     >
-      <div id="single-spa-application:undefined">
-      </div>
+      <application name="header">
+      </application>
       <div class="main-content">
         <button>
           Top level content
         </button>
-        <div id="single-spa-application:undefined">
-        </div>
+        <application name="app1">
+        </application>
         Some raw text
       </div>
       <span>
@@ -69,12 +75,18 @@ exports[`renderServerResponseBody dom-elements.html fixture renders the app1 rou
   </head>
   <body>
     <div id="single-spa-application:header">
+      <button>
+        App header
+      </button>
     </div>
     <div class="main-content">
       <button>
         Top level content
       </button>
       <div id="single-spa-application:app1">
+        <button>
+          App app1
+        </button>
       </div>
       Some raw text
     </div>
@@ -88,14 +100,14 @@ exports[`renderServerResponseBody dom-elements.html fixture renders the app1 rou
     <single-spa-router mode="history"
                        base="/"
     >
-      <div id="single-spa-application:undefined">
-      </div>
+      <application name="header">
+      </application>
       <div class="main-content">
         <button>
           Top level content
         </button>
-        <div id="single-spa-application:undefined">
-        </div>
+        <application name="app1">
+        </application>
         Some raw text
       </div>
       <span>
diff --git a/test/node-only/renderServerResponseBody-node.test.js b/test/node-only/renderServerResponseBody-node.test.js
index 40c30fb..79f0a0b 100644
--- a/test/node-only/renderServerResponseBody-node.test.js
+++ b/test/node-only/renderServerResponseBody-node.test.js
@@ -28,7 +28,7 @@ describe(`renderServerResponseBody`, () => {
           const { name } = props;
           const appStream = new stream.Readable({
             read() {
-              appStream.push(`<div id="single-spa-application:${name}"></div>`);
+              appStream.push(`<button>App ${name}</button>`);
               appStream.push(null);
             },
           });
@@ -414,7 +414,7 @@ describe(`renderServerResponseBody`, () => {
         urlPath: "/app1",
         renderApplication(props) {
           const { name } = props;
-          return `<div id="single-spa-application:${name}"></div>`;
+          return `<button>App ${name}</button>`;
         },
       }
     );