Repository: single-spa/single-spa-layout
Pull request #72 (2020-09-20 20:48:49)

Better error handling and promises.
This allows `renderFragment` and `renderApplication` to return promises that resolve with strings or streams. It also improves error handling to log to stderr but continue rendering an empty string.


Patch:
diff --git a/src/server/renderServerResponseBody.js b/src/server/renderServerResponseBody.js
index aae6438..4b02f47 100644
--- a/src/server/renderServerResponseBody.js
+++ b/src/server/renderServerResponseBody.js
@@ -157,11 +157,10 @@ function serializeApplication({
 
   applicationProps.push(props);
 
-  let appStream = renderOptions.renderApplication(props);
-
-  if (typeof appStream === "string") {
-    appStream = stringStream(appStream);
-  }
+  const appStream = renderResultToStream(
+    () => renderOptions.renderApplication(props),
+    `Application ${props.name}`
+  );
 
   output.add(stringStream(`<div id="single-spa-application:${props.name}">`));
 
@@ -212,13 +211,12 @@ function serializeFragment({ node, output, renderOptions }) {
     throw Error(`<fragment> has unknown name`);
   }
 
-  let fragmentStream = renderOptions.renderFragment(attr.value);
-
-  if (typeof fragmentStream === "string") {
-    fragmentStream = stringStream(fragmentStream);
-  }
-
-  output.add(fragmentStream);
+  output.add(
+    renderResultToStream(
+      () => renderOptions.renderFragment(attr.value),
+      `Fragment ${attr.value}`
+    )
+  );
 }
 
 /**
@@ -363,3 +361,38 @@ export function stringStream(str) {
   readable.push(null);
   return readable;
 }
+
+function renderResultToStream(render, name) {
+  let appStream;
+
+  try {
+    appStream = render();
+  } catch (err) {
+    appStream = renderError(name, err);
+  }
+
+  if (appStream && typeof appStream.then === "function") {
+    const promise = appStream;
+    appStream = merge2();
+    promise.then(
+      (result) => {
+        appStream.add(
+          typeof result === "string" ? stringStream(result) : result
+        );
+      },
+      (err) => {
+        appStream.add(renderError(name, err));
+      }
+    );
+  } else if (typeof appStream === "string") {
+    appStream = stringStream(appStream);
+  }
+
+  return appStream;
+}
+
+function renderError(name, err) {
+  console.error(`${name} failed to render.`);
+  console.error(err);
+  return stringStream("");
+}


Test patch:
diff --git a/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap b/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
index 3900446..120b813 100644
--- a/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
+++ b/test/node-only/__snapshots__/renderServerResponseBody-node.test.js.snap
@@ -1,5 +1,127 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`renderServerResponseBody allows for promises that resolve with streams to be returned from renderApplication 1`] = `
+<!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>
+    <div id="single-spa-application:header">
+      <button>
+        App header from stream
+      </button>
+    </div>
+    <div class="main-content">
+      <button>
+        Top level content
+      </button>
+      <div id="single-spa-application:app1">
+        <button>
+          App app1 from stream
+        </button>
+      </div>
+      Some raw text
+    </div>
+    <span>
+      <footer>
+        <a href="/about">
+          About
+        </a>
+      </footer>
+    </span>
+    <single-spa-router mode="history"
+                       base="/"
+    >
+      <application name="header">
+      </application>
+      <div class="main-content">
+        <button>
+          Top level content
+        </button>
+        <application name="app1">
+        </application>
+        Some raw text
+      </div>
+      <span>
+        <footer>
+          <a href="/about">
+            About
+          </a>
+        </footer>
+      </span>
+    </single-spa-router>
+  </body>
+</html>
+`;
+
+exports[`renderServerResponseBody allows for promises to be returned from renderApplication 1`] = `
+<!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>
+    <div id="single-spa-application:header">
+      <button>
+        App header from promise
+      </button>
+    </div>
+    <div class="main-content">
+      <button>
+        Top level content
+      </button>
+      <div id="single-spa-application:app1">
+        <button>
+          App app1 from promise
+        </button>
+      </div>
+      Some raw text
+    </div>
+    <span>
+      <footer>
+        <a href="/about">
+          About
+        </a>
+      </footer>
+    </span>
+    <single-spa-router mode="history"
+                       base="/"
+    >
+      <application name="header">
+      </application>
+      <div class="main-content">
+        <button>
+          Top level content
+        </button>
+        <application name="app1">
+        </application>
+        Some raw text
+      </div>
+      <span>
+        <footer>
+          <a href="/about">
+            About
+          </a>
+        </footer>
+      </span>
+    </single-spa-router>
+  </body>
+</html>
+`;
+
 exports[`renderServerResponseBody allows for strings to be returned from renderApplication 1`] = `
 <!DOCTYPE html>
 <html lang="en">
@@ -265,3 +387,113 @@ exports[`renderServerResponseBody multiple-fragments.html fixture preserves corr
   </body>
 </html>
 `;
+
+exports[`renderServerResponseBody renders an empty string when renderApplication throws an error 1`] = `
+<!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>
+    <div id="single-spa-application:header">
+    </div>
+    <div class="main-content">
+      <button>
+        Top level content
+      </button>
+      <div id="single-spa-application:app1">
+      </div>
+      Some raw text
+    </div>
+    <span>
+      <footer>
+        <a href="/about">
+          About
+        </a>
+      </footer>
+    </span>
+    <single-spa-router mode="history"
+                       base="/"
+    >
+      <application name="header">
+      </application>
+      <div class="main-content">
+        <button>
+          Top level content
+        </button>
+        <application name="app1">
+        </application>
+        Some raw text
+      </div>
+      <span>
+        <footer>
+          <a href="/about">
+            About
+          </a>
+        </footer>
+      </span>
+    </single-spa-router>
+  </body>
+</html>
+`;
+
+exports[`renderServerResponseBody renders an empty string when returned promise from renderApplication rejects 1`] = `
+<!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>
+    <div id="single-spa-application:header">
+    </div>
+    <div class="main-content">
+      <button>
+        Top level content
+      </button>
+      <div id="single-spa-application:app1">
+      </div>
+      Some raw text
+    </div>
+    <span>
+      <footer>
+        <a href="/about">
+          About
+        </a>
+      </footer>
+    </span>
+    <single-spa-router mode="history"
+                       base="/"
+    >
+      <application name="header">
+      </application>
+      <div class="main-content">
+        <button>
+          Top level content
+        </button>
+        <application name="app1">
+        </application>
+        Some raw text
+      </div>
+      <span>
+        <footer>
+          <a href="/about">
+            About
+          </a>
+        </footer>
+      </span>
+    </single-spa-router>
+  </body>
+</html>
+`;
diff --git a/test/node-only/renderServerResponseBody-node.test.js b/test/node-only/renderServerResponseBody-node.test.js
index 79f0a0b..cceb857 100644
--- a/test/node-only/renderServerResponseBody-node.test.js
+++ b/test/node-only/renderServerResponseBody-node.test.js
@@ -432,4 +432,154 @@ describe(`renderServerResponseBody`, () => {
 
     readable.read();
   });
+
+  it(`allows for promises to be returned from renderApplication`, (done) => {
+    const html = fs
+      .readFileSync(
+        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
+        "utf-8"
+      )
+      .toString();
+
+    const layout = constructServerLayout({
+      html,
+    });
+
+    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
+      layout,
+      {
+        urlPath: "/app1",
+        renderApplication(props) {
+          const { name } = props;
+          return Promise.resolve(`<button>App ${name} from promise</button>`);
+        },
+      }
+    );
+
+    let finalHtml = "";
+
+    readable.on("data", (chunk) => {
+      finalHtml += chunk;
+    });
+
+    readable.on("end", () => {
+      expect(finalHtml).toMatchSnapshot();
+      done();
+    });
+
+    readable.read();
+  });
+
+  it(`allows for promises that resolve with streams to be returned from renderApplication`, (done) => {
+    const html = fs
+      .readFileSync(
+        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
+        "utf-8"
+      )
+      .toString();
+
+    const layout = constructServerLayout({
+      html,
+    });
+
+    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
+      layout,
+      {
+        urlPath: "/app1",
+        renderApplication(props) {
+          const { name } = props;
+          return Promise.resolve().then(() => {
+            return Readable.from(`<button>App ${name} from stream</button>`);
+          });
+        },
+      }
+    );
+
+    let finalHtml = "";
+
+    readable.on("data", (chunk) => {
+      finalHtml += chunk;
+    });
+
+    readable.on("end", () => {
+      expect(finalHtml).toMatchSnapshot();
+      done();
+    });
+
+    readable.read();
+  });
+
+  it(`renders an empty string when returned promise from renderApplication rejects`, (done) => {
+    const html = fs
+      .readFileSync(
+        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
+        "utf-8"
+      )
+      .toString();
+
+    const layout = constructServerLayout({
+      html,
+    });
+
+    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
+      layout,
+      {
+        urlPath: "/app1",
+        renderApplication(props) {
+          const { name } = props;
+          return Promise.reject(Error(`render failed`));
+        },
+      }
+    );
+
+    let finalHtml = "";
+
+    readable.on("data", (chunk) => {
+      finalHtml += chunk;
+    });
+
+    readable.on("end", () => {
+      expect(finalHtml).toMatchSnapshot();
+      done();
+    });
+
+    readable.read();
+  });
+
+  it(`renders an empty string when renderApplication throws an error`, (done) => {
+    const html = fs
+      .readFileSync(
+        path.resolve(process.cwd(), "./test/fixtures/dom-elements.html"),
+        "utf-8"
+      )
+      .toString();
+
+    const layout = constructServerLayout({
+      html,
+    });
+
+    const { bodyStream: readable, applicationProps } = renderServerResponseBody(
+      layout,
+      {
+        urlPath: "/app1",
+        renderApplication(props) {
+          const { name } = props;
+          throw Error(`render failed`);
+        },
+      }
+    );
+
+    let finalHtml = "";
+
+    readable.on("data", (chunk) => {
+      finalHtml += chunk;
+    });
+
+    readable.on("end", () => {
+      expect(finalHtml).toMatchSnapshot();
+      done();
+    });
+
+    readable.read();
+  });
 });