Repository: single-spa/single-spa-layout
Pull request #28 (2020-06-01 18:13:01)

Add support for application loaders.
See #8


Patch:
diff --git a/src/constructApplications.js b/src/constructApplications.js
index aeb8372..71e35bd 100644
--- a/src/constructApplications.js
+++ b/src/constructApplications.js
@@ -1,3 +1,7 @@
+import { applicationElementId } from "./constructLayoutEngine";
+import { mountRootParcel } from "single-spa";
+import { inBrowser } from "./environment-helpers";
+
 /**
  * @typedef {{
  * routes: import('./constructRoutes').ResolvedRoutesConfig;
@@ -17,6 +21,7 @@
  * @typedef {{
  * props: object;
  * activeWhen: import('single-spa').ActivityFn;
+ * loader?: string | import('single-spa').ParcelConfig;
  * }} AppRoute
  *
  * @param {ApplicationOptions} applicationOptions
@@ -35,7 +40,7 @@ export function constructApplications({ routes, loadApp }) {
    * activeWhen: import('single-spa').Activity
    * }>}
    */
-  const partialApplications = Object.keys(applicationMap).map((name) => {
+  return Object.keys(applicationMap).map((name) => {
     /** @type {AppRoute} */
     const appRoutes = applicationMap[name];
     return {
@@ -47,13 +52,21 @@ export function constructApplications({ routes, loadApp }) {
         return appRoute ? appRoute.props : {};
       },
       activeWhen: appRoutes.map((appRoute) => appRoute.activeWhen),
+      app: () => {
+        let appRoute;
+        if (inBrowser) {
+          appRoute = appRoutes.find((appRoute) =>
+            appRoute.activeWhen(window.location)
+          );
+        }
+
+        const loadPromise = loadApp({ name });
+        return appRoute && appRoute.loader
+          ? placeLoader(name, appRoute, loadPromise)
+          : loadPromise;
+      },
     };
   });
-
-  return partialApplications.map((partialApp) => ({
-    ...partialApp,
-    app: loadApp,
-  }));
 }
 
 /**
@@ -74,6 +87,7 @@ function recurseRoutes(applicationMap, activeWhen, props, routes) {
       applicationMap[route.name].push({
         activeWhen,
         props: mergeProps(props, route.props),
+        loader: route.loader,
       });
     } else if (route.type === "route") {
       recurseRoutes(
@@ -96,3 +110,71 @@ function topLevelActiveWhen() {
   // All applications not under routes are active
   return true;
 }
+
+let applicationEl;
+
+function placeLoader(appName, appRoute, loadingPromise) {
+  return Promise.resolve().then(() => {
+    // We need the application container element to place the loader into
+    const htmlId = applicationElementId(appName);
+    let applicationElement = document.getElementById(htmlId);
+    let hidElement;
+
+    if (!applicationElement) {
+      applicationElement = document.createElement("div");
+      applicationElement.id = htmlId;
+      // Wait for layout engine to place this dom element in correct location
+      // before it's visible
+      applicationElement.style.display = "none";
+      hidElement = true;
+
+      document.body.appendChild(applicationElement);
+    }
+
+    const parcelConfig =
+      typeof appRoute.loader === "string"
+        ? htmlToParcelConfig(appRoute.loader)
+        : appRoute.loader;
+    const parcel = mountRootParcel(parcelConfig, {
+      name: `application-loader:${appName}`,
+      domElement: applicationElement,
+    });
+
+    applicationEl = applicationElement;
+
+    return Promise.all([parcel.mountPromise, loadingPromise]).then((app) =>
+      parcel.unmount().then(() => {
+        if (hidElement) {
+          applicationElement.style.removeProperty("display");
+          if (applicationElement.getAttribute("style") === "") {
+            applicationElement.removeAttribute("style");
+          }
+        }
+
+        return app;
+      })
+    );
+  });
+}
+
+function htmlToParcelConfig(str) {
+  const doc = new DOMParser().parseFromString(str, "text/html");
+  let nodes = doc.body.childNodes;
+  let appendedNodes = [];
+
+  return {
+    bootstrap: () => Promise.resolve(),
+    mount: (props) =>
+      Promise.resolve().then(() => {
+        nodes.forEach((node) => {
+          appendedNodes.push(props.domElement.appendChild(node.cloneNode()));
+        });
+      }),
+    unmount: (props) =>
+      Promise.resolve().then(() => {
+        appendedNodes.forEach((node) => {
+          props.domElement.removeChild(node);
+        });
+      }),
+  };
+}
diff --git a/src/constructLayoutEngine.js b/src/constructLayoutEngine.js
index f1aca73..42f854b 100644
--- a/src/constructLayoutEngine.js
+++ b/src/constructLayoutEngine.js
@@ -215,6 +215,6 @@ function removeNode(node) {
   }
 }
 
-function applicationElementId(name) {
+export function applicationElementId(name) {
   return `single-spa-application:${name}`;
 }
diff --git a/src/constructRoutes.js b/src/constructRoutes.js
index 852cf61..90fdec3 100644
--- a/src/constructRoutes.js
+++ b/src/constructRoutes.js
@@ -53,6 +53,7 @@ import { resolvePath } from "./matchRoute.js";
  * type: string;
  * name: string;
  * props?: object;
+ * loader?: string | import('single-spa').ParcelConfig;
  * }} Application
  *
  * @param {RoutesConfig} routesConfig
@@ -135,7 +136,7 @@ function elementToJson(element) {
       type: "application",
       name: getAttribute(element, "name"),
     };
-    setProps(element, application, ["name"]);
+    setProps(element, application, ["name", "loader"]);
     return [application];
   } else if (element.nodeName.toLowerCase() === "route") {
     const route = {
@@ -256,7 +257,7 @@ function validateAndSanitize(routesConfig) {
       validateKeys(
         propertyName,
         route,
-        ["type", "name", "props"],
+        ["type", "name", "props", "loader"],
         disableWarnings
       );
       if (route.props) {


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 a2f8f15..fe58110 100644
--- a/test/browser-only/__snapshots__/constructLayoutEngine-browser.test.js.snap
+++ b/test/browser-only/__snapshots__/constructLayoutEngine-browser.test.js.snap
@@ -227,6 +227,37 @@ exports[`constructLayoutEngine browser can process the nested-default-route fixt
 </body>
 `;
 
+exports[`constructLayoutEngine browser can show a loader properly 1`] = `<body />`;
+
+exports[`constructLayoutEngine browser can show a loader properly 2`] = `
+<body>
+  <div
+    id="single-spa-application:app1"
+    style="display: none;"
+  >
+    <img
+      src="loading.gif"
+    />
+  </div>
+</body>
+`;
+
+exports[`constructLayoutEngine browser can show a loader properly 3`] = `
+<body>
+  <div
+    id="single-spa-application:app1"
+  />
+</body>
+`;
+
+exports[`constructLayoutEngine browser can show a loader properly 4`] = `
+<body>
+  <div
+    id="single-spa-application:app1"
+  />
+</body>
+`;
+
 exports[`constructLayoutEngine browser can successfully construct a layout engine and respond to routing events 1`] = `
 <body>
   <div
diff --git a/test/browser-only/constructLayoutEngine-browser.test.js b/test/browser-only/constructLayoutEngine-browser.test.js
index be1f6ac..79d73a7 100644
--- a/test/browser-only/constructLayoutEngine-browser.test.js
+++ b/test/browser-only/constructLayoutEngine-browser.test.js
@@ -557,4 +557,61 @@ describe(`constructLayoutEngine browser`, () => {
     );
     expect(document.body).toMatchSnapshot();
   });
+
+  it(`can show a loader properly`, async () => {
+    /** @type {import('../../src/constructRoutes').ResolvedRoutesConfig} */
+    const routes = constructRoutes({
+      containerEl: "body",
+      base: "/",
+      mode: "history",
+      routes: [
+        {
+          type: "route",
+          path: "/app1",
+          routes: [
+            {
+              type: "application",
+              name: "app1",
+              loader: `<img src="loading.gif">`,
+            },
+          ],
+        },
+      ],
+    });
+
+    const applications = constructApplications({
+      routes,
+      loadApp: (name) => {
+        return new Promise((resolve) => {
+          setTimeout(resolve, 5);
+        });
+      },
+    });
+
+    layoutEngine = constructLayoutEngine({
+      routes,
+    });
+
+    expect(document.body).toMatchSnapshot();
+
+    history.pushState(history.state, document.title, "/app1");
+    const loadPromise = applications[0].app();
+
+    await tick();
+    expect(document.body).toMatchSnapshot();
+
+    await loadPromise;
+    expect(document.body).toMatchSnapshot();
+
+    window.dispatchEvent(
+      new CustomEvent("single-spa:before-mount-routing-event")
+    );
+    expect(document.body).toMatchSnapshot();
+  });
 });
+
+function tick() {
+  return new Promise((resolve) => {
+    setTimeout(resolve);
+  });
+}
diff --git a/test/constructApplications.test.js b/test/constructApplications.test.js
index 93824f8..604c713 100644
--- a/test/constructApplications.test.js
+++ b/test/constructApplications.test.js
@@ -3,6 +3,7 @@ import {
   constructRoutes,
 } from "../src/single-spa-layout.js";
 import { parseFixture } from "./html-utils.js";
+import { inBrowser } from "../src/environment-helpers.js";
 
 describe(`constructApplications`, () => {
   it(`can handle a medium complexity case`, () => {
@@ -199,4 +200,65 @@ describe(`constructApplications`, () => {
       )
     ).toBe(true);
   });
+
+  it(`places a loader in the dom while loading the code`, async () => {
+    const routes = constructRoutes({
+      routes: [
+        {
+          type: "application",
+          name: "app1",
+          loader: `<img src="loading.gif">`,
+        },
+      ],
+    });
+
+    const loadApp = (name) =>
+      new Promise((resolve) => {
+        setTimeout(resolve, 5);
+      });
+
+    const applications = constructApplications({ routes, loadApp });
+    let appEl;
+
+    if (inBrowser) {
+      appEl = document.getElementById(`single-spa-application:app1`);
+      expect(appEl).toBeNull();
+    }
+
+    // begin loading the app
+    const loadPromise = applications[0].app();
+
+    await tick();
+
+    if (inBrowser) {
+      appEl = document.getElementById(`single-spa-application:app1`);
+      expect(appEl).toMatchInlineSnapshot(`
+        <div
+          id="single-spa-application:app1"
+          style="display: none;"
+        >
+          <img
+            src="loading.gif"
+          />
+        </div>
+      `);
+    }
+
+    await loadPromise;
+
+    if (inBrowser) {
+      appEl = document.getElementById(`single-spa-application:app1`);
+      expect(appEl).toMatchInlineSnapshot(`
+        <div
+          id="single-spa-application:app1"
+        />
+      `);
+    }
+  });
 });
+
+function tick() {
+  return new Promise((resolve) => {
+    setTimeout(resolve);
+  });
+}
diff --git a/test/constructRoutes.test.js b/test/constructRoutes.test.js
index a3a6765..5ef0ff3 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`
+        `Invalid routesConfig.routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props, loader`
       );
 
       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`
+        `Invalid routesConfig.routes[0]: received invalid properties 'routes', but valid properties are type, name, props, loader`
       );
     });
 
@@ -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`
+        `Invalid routesConfig.routes[0].routes[0]: received invalid properties 'somethingElse', but valid properties are type, name, props, loader`
       );
     });
 
@@ -606,5 +606,27 @@ describe("constructRoutes", () => {
       ).toBe(true);
       expect(activeWhen(new URL("http://localhost/users/new"))).toBe(false);
     });
+
+    it(`handles 'loader' property on applications`, () => {
+      expect(
+        constructRoutes({
+          routes: [
+            {
+              type: "application",
+              name: "app1",
+              loader: `<img src="loading.gif">`,
+            },
+          ],
+        })
+      ).toMatchObject({
+        routes: [
+          {
+            type: "application",
+            name: "app1",
+            loader: `<img src="loading.gif">`,
+          },
+        ],
+      });
+    });
   });
 });
diff --git a/test/single-spa-layout.test-d.ts b/test/single-spa-layout.test-d.ts
index 200d79b..d018aab 100644
--- a/test/single-spa-layout.test-d.ts
+++ b/test/single-spa-layout.test-d.ts
@@ -23,6 +23,13 @@ expectError(constructRoutes([]));
 
 expectError(constructRoutes({}));
 
+const loaderParcelConfig = {
+  async bootstrap() {},
+  async mount() {},
+  async unmount() {},
+  async update() {},
+};
+
 const routes = constructRoutes({
   routes: [
     {
@@ -30,6 +37,8 @@ const routes = constructRoutes({
       path: "/app1",
       routes: [
         { type: "application", name: "app1" },
+        { type: "application", name: "app2", loader: `<img src="loader">` },
+        { type: "application", name: "app3", loader: loaderParcelConfig },
         { type: "route", default: true },
       ],
     },
@@ -39,6 +48,10 @@ const routes = constructRoutes({
 (routes.routes[0] as ResolvedUrlRoute).activeWhen(window.location);
 expectType<boolean | undefined>((routes.routes[1] as ResolvedUrlRoute).default);
 
+expectType<string | singleSpa.ParcelConfig | undefined>(
+  (routes.routes[0] as import("../src/constructRoutes").Application).loader
+);
+
 const parse5Doc = parse(
   `<single-spa-router></single-spa-router>`
 ) as DefaultTreeDocument;