From 785520fa2d94b4e7464ae1b94c746da53a6ad33c Mon Sep 17 00:00:00 2001
From: Remus Mate <rmate@seek.com.au>
Date: Fri, 17 Nov 2023 11:44:35 +1100
Subject: [PATCH] WIP

---
 @types/esbuild.wasm.d.ts               |  4 ++
 lib/makeWebpackConfig.js               |  7 ++++
 package.json                           |  2 +
 pnpm-lock.yaml                         | 17 +++++++-
 src/Playroom/CodeEditor/CodeEditor.tsx | 18 ++++-----
 src/Playroom/Frames/Frames.tsx         | 22 +++++------
 src/Playroom/Preview.tsx               | 16 ++++++--
 src/Playroom/RenderCode/RenderCode.js  |  8 ++++
 src/utils/compileJsx.ts                | 54 +++++++++++++++++++-------
 src/utils/cursor.ts                    |  2 +-
 10 files changed, 108 insertions(+), 42 deletions(-)
 create mode 100644 @types/esbuild.wasm.d.ts

diff --git a/@types/esbuild.wasm.d.ts b/@types/esbuild.wasm.d.ts
new file mode 100644
index 00000000..aeddc09c
--- /dev/null
+++ b/@types/esbuild.wasm.d.ts
@@ -0,0 +1,4 @@
+declare module 'esbuild-wasm/esbuild.wasm' {
+  const wasmURL: string;
+  export default wasmURL;
+}
diff --git a/lib/makeWebpackConfig.js b/lib/makeWebpackConfig.js
index 6ca8355b..453cb831 100644
--- a/lib/makeWebpackConfig.js
+++ b/lib/makeWebpackConfig.js
@@ -141,6 +141,13 @@ module.exports = async (playroomConfig, options) => {
           include: path.dirname(require.resolve('codemirror/package.json')),
           use: [MiniCssExtractPlugin.loader, require.resolve('css-loader')],
         },
+        {
+          test: /esbuild.wasm$/,
+          type: 'asset/resource',
+          generator: {
+            filename: 'esbuild-[hash][ext]',
+          },
+        },
       ],
     },
     optimization: {
diff --git a/package.json b/package.json
index 9892cc1a..98982792 100644
--- a/package.json
+++ b/package.json
@@ -83,6 +83,7 @@
     "css-loader": "^6.7.2",
     "current-git-branch": "^1.1.0",
     "dedent": "^0.7.0",
+    "esbuild-wasm": "^0.19.5",
     "fast-glob": "^3.2.12",
     "find-up": "^5.0.0",
     "fuzzy": "^0.1.3",
@@ -92,6 +93,7 @@
     "localforage": "^1.10.0",
     "lodash": "^4.17.21",
     "lz-string": "^1.4.4",
+    "memoize-one": "^6.0.0",
     "mini-css-extract-plugin": "^2.7.2",
     "parse-prop-types": "^0.3.0",
     "polished": "^4.2.2",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a5a2dc05..931e8c46 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -35,6 +35,7 @@ specifiers:
   current-git-branch: ^1.1.0
   cypress: ^12.0.2
   dedent: ^0.7.0
+  esbuild-wasm: ^0.19.5
   eslint: ^8.44.0
   eslint-config-seek: ^11.3.1
   fast-glob: ^3.2.12
@@ -49,6 +50,7 @@ specifiers:
   localforage: ^1.10.0
   lodash: ^4.17.21
   lz-string: ^1.4.4
+  memoize-one: ^6.0.0
   mini-css-extract-plugin: ^2.7.2
   parse-prop-types: ^0.3.0
   polished: ^4.2.2
@@ -101,6 +103,7 @@ dependencies:
   css-loader: 6.7.2_webpack@5.75.0
   current-git-branch: 1.1.0
   dedent: 0.7.0
+  esbuild-wasm: 0.19.5
   fast-glob: 3.2.12
   find-up: 5.0.0
   fuzzy: 0.1.3
@@ -110,6 +113,7 @@ dependencies:
   localforage: 1.10.0
   lodash: 4.17.21
   lz-string: 1.4.4
+  memoize-one: 6.0.0
   mini-css-extract-plugin: 2.7.2_webpack@5.75.0
   parse-prop-types: 0.3.0_prop-types@15.8.1
   polished: 4.2.2
@@ -4784,6 +4788,12 @@ packages:
       is-symbol: 1.0.4
     dev: true
 
+  /esbuild-wasm/0.19.5:
+    resolution: {integrity: sha512-7zmLLn2QCj93XfMmHtzrDJ1UBuOHB2CZz1ghoCEZiRajxjUvHsF40PnbzFIY/pmesqPRaEtEWii0uzsTbnAgrA==}
+    engines: {node: '>=12'}
+    hasBin: true
+    dev: false
+
   /esbuild/0.11.23:
     resolution: {integrity: sha512-iaiZZ9vUF5wJV8ob1tl+5aJTrwDczlvGP0JoMmnpC2B0ppiMCu8n8gmy5ZTGl5bcG081XBVn+U+jP+mPFm5T5Q==}
     hasBin: true
@@ -6985,7 +6995,6 @@ packages:
     resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
     engines: {node: '>=6'}
     hasBin: true
-    dev: true
 
   /jsonfile/4.0.0:
     resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==}
@@ -7158,7 +7167,7 @@ packages:
     dependencies:
       big.js: 5.2.2
       emojis-list: 3.0.0
-      json5: 2.2.1
+      json5: 2.2.3
     dev: false
 
   /localforage/1.10.0:
@@ -7311,6 +7320,10 @@ packages:
       fs-monkey: 1.0.3
     dev: false
 
+  /memoize-one/6.0.0:
+    resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
+    dev: false
+
   /meow/6.1.1:
     resolution: {integrity: sha512-3YffViIt2QWgTy6Pale5QpopX/IvU3LPL03jOTqp6pGj3VjesdO/U8CuHMKpnQr4shCNCM5fd5XFFvIIl6JBHg==}
     engines: {node: '>=8'}
diff --git a/src/Playroom/CodeEditor/CodeEditor.tsx b/src/Playroom/CodeEditor/CodeEditor.tsx
index a57ec53d..3df04653 100644
--- a/src/Playroom/CodeEditor/CodeEditor.tsx
+++ b/src/Playroom/CodeEditor/CodeEditor.tsx
@@ -12,8 +12,8 @@ import {
 import { formatCode as format, isMac } from '../../utils/formatting';
 import {
   closeFragmentTag,
-  compileJsx,
   openFragmentTag,
+  validateCode,
 } from '../../utils/compileJsx';
 
 import * as styles from './CodeEditor.css';
@@ -42,13 +42,13 @@ import {
 } from './keymaps/cursors';
 import { wrapInTag } from './keymaps/wrap';
 
-const validateCode = (editorInstance: Editor, code: string) => {
+const validateCodeInEditor = (editorInstance: Editor, code: string) => {
   editorInstance.clearGutter('errorGutter');
 
-  try {
-    compileJsx(code);
-  } catch (err) {
-    const errorMessage = err instanceof Error ? err.message : '';
+  const validOrError = validateCode(code);
+  if (validOrError !== true) {
+    const errorMessage =
+      validOrError instanceof Error ? validOrError.message : '';
     const matches = errorMessage.match(/\(([0-9]+):/);
     const lineNumber =
       matches && matches.length >= 2 && matches[1] && parseInt(matches[1], 10);
@@ -172,7 +172,7 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => {
       }
 
       editorInstanceRef.current.setValue(code);
-      validateCode(editorInstanceRef.current, code);
+      validateCodeInEditor(editorInstanceRef.current, code);
     }
   }, [code, previewCode]);
 
@@ -213,12 +213,12 @@ export const CodeEditor = ({ code, onChange, previewCode, hints }: Props) => {
     <ReactCodeMirror
       editorDidMount={(editorInstance) => {
         editorInstanceRef.current = editorInstance;
-        validateCode(editorInstance, code);
+        validateCodeInEditor(editorInstance, code);
         setCursorPosition(cursorPosition);
       }}
       onChange={(editorInstance, data, newCode) => {
         if (editorInstance.hasFocus() && !previewCode) {
-          validateCode(editorInstance, newCode);
+          validateCodeInEditor(editorInstance, newCode);
           debouncedChange(newCode);
         }
       }}
diff --git a/src/Playroom/Frames/Frames.tsx b/src/Playroom/Frames/Frames.tsx
index 79de0932..a24888e4 100644
--- a/src/Playroom/Frames/Frames.tsx
+++ b/src/Playroom/Frames/Frames.tsx
@@ -1,11 +1,7 @@
 import { useEffect, useRef, useState } from 'react';
 import flatMap from 'lodash/flatMap';
 import Iframe from './Iframe';
-import {
-  compileJsx,
-  openFragmentTag,
-  closeFragmentTag,
-} from '../../utils/compileJsx';
+import { compileJsx } from '../../utils/compileJsx';
 import type { PlayroomProps } from '../Playroom';
 import { Strong } from '../Strong/Strong';
 import { Text } from '../Text/Text';
@@ -31,16 +27,16 @@ export default function Frames({ code, themes, widths }: FramesProps) {
     }))
   );
 
-  const [renderCode, setRenderCode] = useState(
-    () => `${openFragmentTag}${closeFragmentTag}`
-  );
+  const [renderCode, setRenderCode] = useState('');
 
   useEffect(() => {
-    try {
-      const newCode = compileJsx(code);
-      setRenderCode(newCode);
-    } catch (e) {}
-  }, [code]);
+    (async () => {
+      try {
+        const newCode = await compileJsx(code);
+        setRenderCode(newCode);
+      } catch (e) {}
+    })();
+  });
 
   return (
     <div ref={scrollingPanelRef} className={styles.root}>
diff --git a/src/Playroom/Preview.tsx b/src/Playroom/Preview.tsx
index 74a49240..fb207779 100644
--- a/src/Playroom/Preview.tsx
+++ b/src/Playroom/Preview.tsx
@@ -1,4 +1,4 @@
-import type { ComponentType, ReactNode } from 'react';
+import { useState, type ComponentType, type ReactNode, useEffect } from 'react';
 import lzString from 'lz-string';
 
 import { useParams } from '../utils/params';
@@ -32,13 +32,23 @@ export default ({ themes, components, FrameComponent }: PreviewProps) => {
       );
 
       return {
-        code: compileJsx(result.code),
+        code: result.code,
         themeName: result.theme,
       };
     }
 
     return {};
   });
+  const [renderCode, setRenderCode] = useState('');
+
+  useEffect(() => {
+    (async () => {
+      try {
+        const newCode = await compileJsx(code || '');
+        setRenderCode(newCode);
+      } catch (e) {}
+    })();
+  });
 
   const resolvedTheme = themeName ? themes[themeName] : null;
 
@@ -49,7 +59,7 @@ export default ({ themes, components, FrameComponent }: PreviewProps) => {
           themeName={themeName || '__PLAYROOM__NO_THEME__'}
           theme={resolvedTheme}
         >
-          <RenderCode code={code} scope={components} />
+          <RenderCode code={renderCode} scope={components} />
         </FrameComponent>
       </div>
       <div className={styles.splashScreenContainer}>
diff --git a/src/Playroom/RenderCode/RenderCode.js b/src/Playroom/RenderCode/RenderCode.js
index 5a753f50..e47fbebe 100644
--- a/src/Playroom/RenderCode/RenderCode.js
+++ b/src/Playroom/RenderCode/RenderCode.js
@@ -10,6 +10,14 @@ import {
 } from '../../utils/compileJsx';
 
 export default function RenderCode({ code, scope }) {
+  // TODO: move these up
+  if (ReactCreateElementPragma in scope) {
+    throw new Error(`${ReactCreateElementPragma} not allowed in scope`);
+  }
+  if (ReactFragmentPragma in scope) {
+    throw new Error(`${ReactFragmentPragma} not allowed in scope`);
+  }
+
   return scopeEval(code, {
     ...(useScope() ?? {}),
     ...scope,
diff --git a/src/utils/compileJsx.ts b/src/utils/compileJsx.ts
index f67da928..a5490bec 100644
--- a/src/utils/compileJsx.ts
+++ b/src/utils/compileJsx.ts
@@ -1,4 +1,11 @@
 import { transform } from '@babel/standalone';
+import memoizeOne from 'memoize-one';
+import * as esbuild from 'esbuild-wasm';
+import esbuildWasmUrl from 'esbuild-wasm/esbuild.wasm';
+
+const init = esbuild.initialize({
+  wasmURL: `/${esbuildWasmUrl}`,
+});
 
 export const ReactFragmentPragma = 'R_F';
 export const ReactCreateElementPragma = 'R_cE';
@@ -6,24 +13,43 @@ export const ReactCreateElementPragma = 'R_cE';
 export const openFragmentTag = '<>';
 export const closeFragmentTag = '</>';
 
-export const compileJsx = (code: string) =>
-  transform(`${openFragmentTag}${code.trim()}${closeFragmentTag}`, {
-    presets: [
-      [
-        'react',
-        {
-          pragma: ReactCreateElementPragma,
-          pragmaFrag: ReactFragmentPragma,
-        },
+export const compileJsxWithBabel = memoizeOne((code: string) => {
+  const result = transform(
+    `${openFragmentTag}${code.trim()}${closeFragmentTag}`,
+    {
+      presets: [
+        [
+          'react',
+          {
+            pragma: ReactCreateElementPragma,
+            pragmaFrag: ReactFragmentPragma,
+          },
+        ],
       ],
-    ],
-  }).code;
+    }
+  );
+  return result.code;
+});
+
+export const compileJsx = memoizeOne(async (code: string) => {
+  await init;
+  const result = await esbuild.transform(
+    `${openFragmentTag}${code.trim()}${closeFragmentTag}`,
+    {
+      loader: 'jsx',
+      jsxFactory: ReactCreateElementPragma,
+      jsxFragment: ReactFragmentPragma,
+    }
+  );
+
+  return result.code;
+});
 
-export const validateCode = (code: string) => {
+export const validateCode = (code: string): true | Error => {
   try {
-    compileJsx(code);
+    compileJsxWithBabel(code);
     return true;
   } catch (err) {
-    return false;
+    return err as Error;
   }
 };
diff --git a/src/utils/cursor.ts b/src/utils/cursor.ts
index cbfe50bb..8045f766 100644
--- a/src/utils/cursor.ts
+++ b/src/utils/cursor.ts
@@ -35,4 +35,4 @@ export const isValidLocation = ({
           cursor,
           snippet: breakoutString,
         })
-      );
+      ) === true;