Repository: single-spa/single-spa-layout
Pull request #78 (2020-10-09 11:07:04)

initial code for PR#77
This is the initial code for PR #77 

tsukhu: Figured out the main issues
1) The assets being added in the body instead of the head .. 
Needed to update the `CustomParser.js` and add the `assets` tag there . This was causing it to be pushed to the body
2) The addition of the assets twice was a test case issue fixed that.

Working on the types and the test cases now

joeldenning: Looks good! There is one more test case I'd like to see - one where `renderApplication` returns an object with content + assets promises, but the assets promise resolves **after** the content promise. I want to verify that the order of the HTML is still correct in that case. Roughly like this:

```js
sendLayoutHTTPResponse({
  renderApplication() {
    return {
      assets() {
        return new Promise(resolve => {
          setTimeout(() => resolve('<link rel="stylesheet">'), 200);
        }
      },
      content() {
        return `<button>App</button>`
      }
    }
  }
})
```

I'll merge this PR as-is though and add this test myself before publishing a new version.

Patch:
diff --git a/src/server/CustomParser.js b/src/server/CustomParser.js
index d7f13c8..fa55653 100644
--- a/src/server/CustomParser.js
+++ b/src/server/CustomParser.js
@@ -6,7 +6,7 @@ export default class CustomParser extends Parser {
     if (
       this.insertionMode === "IN_HEAD_MODE" &&
       token.type === "START_TAG_TOKEN" &&
-      token.tagName === "fragment"
+      (token.tagName === "fragment" || token.tagName === "assets")
     ) {
       // parse5 doesn't allow for unknown elements inside of <head> - it pushes them to <body>
       // This overrides that behavior to allow for fragments in <head>
diff --git a/src/server/sendLayoutHTTPResponse.js b/src/server/sendLayoutHTTPResponse.js
index 0ae4964..0c93769 100644
--- a/src/server/sendLayoutHTTPResponse.js
+++ b/src/server/sendLayoutHTTPResponse.js
@@ -22,13 +22,21 @@ const LT_REGEX = /</g;
 const GT_REGEX = />/g;
 
 /**
+ *
+ * @typedef {import('stream').Readable | string} RenderResult
+ *
+ * @typedef {{
+ *  assets: RenderResult | Promise<RenderResult>;
+ *  content: RenderResult | Promise<RenderResult>;
+ * }} ApplicationRenderResult
+ *
  * @typedef {{
  * res: import('http').ServerResponse;
  * serverLayout: import('./constructServerLayout').ServerLayout;
  * urlPath: string;
- * renderApplication(appToRender: AppToRender) => import('stream').Readable | string | Promise<string> | Promise<import('stream').Readable>;
+ * renderApplication(appToRender: AppToRender) => RenderResult | Promise<RenderResult> | ApplicationRenderResult | Promise<ApplicationRenderResult>;
  * retrieveApplicationHeaders(appToRender AppToRender) => object;
- * renderFragment?(name: string) => import('stream').Readable | string | Promise<string> | Promise<import('stream').Readable>;
+ * renderFragment?(name: string) => RenderResult | Promise<RenderResult>;
  * retrieveProp(name: string) => Promise<any> | any;
  * assembleFinalHeaders(appHeaders: AppHeaders[]) => object;
  * }} RenderOptions
@@ -45,6 +53,7 @@ const GT_REGEX = />/g;
  *
  * @typedef {{
  * node: import('parse5').Element;
+ * assetsStream: import('merge2').Merge2Stream;
  * bodyStream: import('merge2').Merge2Stream;
  * renderOptions: RenderOptions;
  * serverLayout: import('./constructServerLayout').serverLayout;
@@ -64,12 +73,17 @@ export async function sendLayoutHTTPResponse(renderOptions) {
     applicationPropPromises = {},
     headerPromises = {};
 
+  const assetsStream = merge2({
+    pipeError: true,
+  });
+
   const bodyStream = merge2({
     pipeError: true,
   });
 
   serializeChildNodes({
     node: renderOptions.serverLayout.parsedDocument,
+    assetsStream,
     bodyStream,
     renderOptions,
     propPromises,
@@ -119,6 +133,8 @@ function serializeChildNodes(args) {
       serialize = serializeRoute;
     } else if (isRouterContent(node)) {
       serialize = serializeRouterContent;
+    } else if (isAssetsNode(node)) {
+      serialize = serializeAssets;
     } else if (isFragmentNode(node)) {
       serialize = serializeFragment;
     } else if (treeAdapter.isElementNode(node)) {
@@ -169,6 +185,7 @@ function serializeRoute(args) {
  */
 function serializeApplication({
   node,
+  assetsStream,
   bodyStream,
   renderOptions,
   applicationPropPromises,
@@ -210,16 +227,51 @@ function serializeApplication({
     propsPromise,
   });
 
-  const appStream = renderResultToStream(
-    () => renderOptions.renderApplication({ appName, propsPromise }),
-    `Application ${appName}`
-  );
-
+  let renderResult, contentStream, assetStream;
+  try {
+    renderResult = renderOptions.renderApplication({ appName, propsPromise });
+
+    if (typeof renderResult.then === "function") {
+      contentStream = merge2();
+      assetStream = merge2();
+      renderResult.then(
+        (value) => {
+          const streams = valueToAppStreams(appName, value);
+          contentStream.add(streams.contentStream);
+          assetStream.add(streams.assetStream);
+        },
+        (err) => {
+          contentStream.add(renderError(appName, err));
+          assetStream.add(renderError(appName, err));
+        }
+      );
+    } else {
+      const streams = valueToAppStreams(appName, renderResult);
+      contentStream = streams.contentStream;
+      assetStream = streams.assetStream;
+    }
+  } catch (err) {
+    contentStream = renderError(appName, err);
+    assetStream = renderError(appName, err);
+  }
+  assetsStream.add(assetStream);
   bodyStream.add(stringStream(`<div id="single-spa-application:${appName}">`));
+  bodyStream.add(contentStream);
+  bodyStream.add(stringStream(`</div>`));
+}
 
-  bodyStream.add(appStream);
+function valueToAppStreams(name, value) {
+  let contentStream, assetStream;
 
-  bodyStream.add(stringStream(`</div>`));
+  if (value) {
+    contentStream = valueToStream(value.content || value, name);
+    assetStream = valueToStream(value.assets || "", name);
+  } else {
+    contentStream = renderError(name, Error(`returned nothing`));
+    assetStream = renderError(name, Error(`returned nothing`));
+  }
+
+  return { contentStream, assetStream };
 }
 
 function isRouterContent(node) {
@@ -253,30 +305,40 @@ function isFragmentNode(node) {
   );
 }
 
+function isAssetsNode(node) {
+  return (
+    treeAdapter.isElementNode(node) && treeAdapter.getTagName(node) === "assets"
+  );
+}
+
 /**
  *
  * @param {SerializeArgs} args
  */
 function serializeLayoutData({ propPromises, bodyStream }) {
-  bodyStream.add(
-    renderResultToStream(async () => {
-      const propEntries = await Promise.all(
-        Object.entries(propPromises).map(([propName, propValuePromise]) => {
-          return propValuePromise.then((value) => {
-            return [propName, value];
-          });
-        })
-      );
-      const props = propEntries.reduce((acc, [propName, value]) => {
-        acc[propName] = value;
-        return acc;
-      }, {});
-
-      return `<script>window.singleSpaLayoutData = ${JSON.stringify({
-        props,
-      })}</script>`;
-    }, `Serialize layout props`)
-  );
+  const getLayoutData = async () => {
+    const propEntries = await Promise.all(
+      Object.entries(propPromises).map(([propName, propValuePromise]) => {
+        return propValuePromise.then((value) => {
+          return [propName, value];
+        });
+      })
+    );
+    const props = propEntries.reduce((acc, [propName, value]) => {
+      acc[propName] = value;
+      return acc;
+    }, {});
+
+    return `<script>window.singleSpaLayoutData = ${JSON.stringify({
+      props,
+    })}</script>`;
+  };
+
+  try {
+    bodyStream.add(valueToStream(getLayoutData()));
+  } catch (err) {
+    bodyStream.add(renderError(`Serialize layout props`, err));
+  }
 }
 
 /**
@@ -291,12 +353,25 @@ function serializeFragment({ node, bodyStream, renderOptions }) {
     throw Error(`<fragment> has unknown name`);
   }
 
-  bodyStream.add(
-    renderResultToStream(
-      () => renderOptions.renderFragment(attr.value),
+  let fragmentStream;
+  try {
+    fragmentStream = valueToStream(
+      renderOptions.renderFragment(attr.value),
       `Fragment ${attr.value}`
-    )
-  );
+    );
+  } catch (err) {
+    fragmentStream = renderError(`Fragment ${attr.name}`, err);
+  }
+
+  bodyStream.add(fragmentStream);
+}
+
+/**
+ *
+ * @param {SerializeArgs} serializeArgs
+ */
+function serializeAssets({ assetsStream, bodyStream }) {
+  bodyStream.add(assetsStream);
 }
 
 /**
@@ -441,33 +516,27 @@ export function stringStream(str) {
   return readable;
 }
 
-function renderResultToStream(render, name) {
-  let appStream;
-
-  try {
-    appStream = render();
-  } catch (err) {
-    appStream = renderError(name, err);
-  }
+function valueToStream(value, name) {
+  let stream;
 
-  if (appStream && typeof appStream.then === "function") {
-    const promise = appStream;
-    appStream = merge2();
+  if (value && typeof value.then === "function") {
+    const promise = value;
+    stream = merge2();
     promise.then(
       (result) => {
-        appStream.add(
-          typeof result === "string" ? stringStream(result) : result
-        );
+        stream.add(typeof result === "string" ? stringStream(result) : result);
       },
       (err) => {
-        appStream.add(renderError(name, err));
+        stream.add(renderError(name, err));
       }
     );
-  } else if (typeof appStream === "string") {
-    appStream = stringStream(appStream);
+  } else if (typeof value === "string") {
+    stream = stringStream(value);
+  } else {
+    stream = value;
   }
 
-  return appStream;
+  return stream;
 }
 
 function renderError(name, err) {


Test patch:
diff --git a/test/fixtures/dom-elements-with-assets.html b/test/fixtures/dom-elements-with-assets.html
new file mode 100644
index 0000000..13b73bc
--- /dev/null
+++ b/test/fixtures/dom-elements-with-assets.html
@@ -0,0 +1,28 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>Document</title>
+    <assets></assets>
+  </head>
+  <body>
+    <single-spa-router mode="history" base="/">
+      <application name="header"></application>
+      <div class="main-content">
+        <route path="app1">
+          <button>Top level content</button>
+          <application name="app1"></application>
+          <!-- Comments and raw text should work -->
+          Some raw text
+        </route>
+      </div>
+      <span>
+        <!-- The footer -->
+        <footer>
+          <a href="/about">About</a>
+        </footer>
+      </span>
+    </single-spa-router>
+  </body>
+</html>
diff --git a/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
index 6716b63..e89626b 100644
--- a/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
+++ b/test/node-only/__snapshots__/sendLayoutHTTPResponse-node.test.js.snap
@@ -192,6 +192,82 @@ exports[`sendLayoutHTTPResponse response body allows for strings to be returned
 </html>
 `;
 
+exports[`sendLayoutHTTPResponse response body allows for strings to be returned from renderApplication with assets 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>
+    <style id="jss-server-side">
+      .button {
+            background-color: #4CAF50; /* Green */
+            border: none;
+            color: white;
+            padding: 15px 32px;
+            text-align: center;
+            text-decoration: none;
+            display: inline-block;
+            font-size: 16px;
+          }
+    </style>
+  </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>
+    <span>
+      <footer>
+        <a href="/about">
+          About
+        </a>
+      </footer>
+    </span>
+    <script>
+      window.singleSpaLayoutData = {"props":{}}
+    </script>
+    <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[`sendLayoutHTTPResponse response body dom-elements.html fixture renders the app1 route correctly 1`] = `
 <!DOCTYPE html>
 <html lang="en">
diff --git a/test/node-only/sendLayoutHTTPResponse-node.test.js b/test/node-only/sendLayoutHTTPResponse-node.test.js
index 3a5043a..20e7cfe 100644
--- a/test/node-only/sendLayoutHTTPResponse-node.test.js
+++ b/test/node-only/sendLayoutHTTPResponse-node.test.js
@@ -432,6 +432,58 @@ describe(`sendLayoutHTTPResponse`, () => {
       expect(await responseBodyPromise).toMatchSnapshot();
     });
 
+    it(`allows for strings to be returned from renderApplication with assets`, async () => {
+      const html = fs
+        .readFileSync(
+          path.resolve(
+            process.cwd(),
+            "./test/fixtures/dom-elements-with-assets.html"
+          ),
+          "utf-8"
+        )
+        .toString();
+
+      const serverLayout = constructServerLayout({
+        html,
+      });
+
+      await sendLayoutHTTPResponse({
+        res,
+        serverLayout,
+        urlPath: "/app1",
+        assembleFinalHeaders() {
+          return {};
+        },
+        renderApplication({ appName, propsPromise }) {
+          const css = `
+          .button {
+            background-color: #4CAF50; /* Green */
+            border: none;
+            color: white;
+            padding: 15px 32px;
+            text-align: center;
+            text-decoration: none;
+            display: inline-block;
+            font-size: 16px;
+          }
+          `;
+          return {
+            assets: `${
+              appName === "app1"
+                ? `<style id="jss-server-side">${css}</style>`
+                : ``
+            }`,
+            content: `<button>App ${appName}</button>`,
+          };
+        },
+        retrieveApplicationHeaders({ appName, propsPromise }) {
+          return {};
+        },
+      });
+
+      expect(await responseBodyPromise).toMatchSnapshot();
+    });
+
     it(`allows for promises to be returned from renderApplication`, async () => {
       const html = fs
         .readFileSync(
diff --git a/test/single-spa-layout.test-d.ts b/test/single-spa-layout.test-d.ts
index 4416780..2b148f8 100644
--- a/test/single-spa-layout.test-d.ts
+++ b/test/single-spa-layout.test-d.ts
@@ -167,3 +167,46 @@ expectType<Promise<any>>(
     },
   })
 );
+
+expectType<Promise<any>>(
+  sendLayoutHTTPResponse({
+    res,
+    serverLayout,
+    urlPath: "/app1",
+    assembleFinalHeaders() {
+      return {};
+    },
+    renderApplication(arg) {
+      expectType<string>(arg.appName);
+      const css = `
+      .button {
+        background-color: #4CAF50; /* Green */
+        border: none;
+        color: white;
+        padding: 15px 32px;
+        text-align: center;
+        text-decoration: none;
+        display: inline-block;
+        font-size: 16px;
+      }
+      `;
+      return {
+        assets: `${
+          arg.appName === "app1"
+            ? `<style id="jss-server-side">${css}</style>`
+            : ``
+        }`,
+        content: `<button>App ${arg.appName}</button>`,
+      };
+    },
+    retrieveApplicationHeaders(arg) {
+      return { hi: "there" };
+    },
+    retrieveProp(name) {
+      return "hi";
+    },
+    renderFragment(name) {
+      return "hi";
+    },
+  })
+);