Making an interactive browser-only React playground, from scratch
Back in times when I was working on a design system, I had to build a lightweight interactive demo environment to show off the capabilities of the library. Certainly, there are some canned solutions out there, but where is all fun in using something prefabricated? One can accept the challenge, and... Well, you know me.
The solution should have been 100% browser-only.
That was how this all started.
Right, so the heart of the project is the execution function. Whenever the code snippet gets updated, we need to have it compiled and executed.
In a nutshell, the code is the following:
import { requireModule } from './requireModule';import { compile } from './compile';type ResultType = {component: any;error: string;};const evalInContext = (code: string,require: (module: string) => any,exports: Record<string, any>,): (() => any) =>// eslint-disable-next-line no-new-funcnew Function('require', 'exports', code).bind(null, require, exports);export const execute = async (code: string): Promise<ResultType> => {try {const compiledCode = await compile(code);const exports: any = {};const evalCode = evalInContext(compiledCode, requireModule, exports);evalCode();return {component: exports.default,error: '',};} catch (error) {const { message, stack } = error as Error;return {component: null,error: stack ?? message,};}};
First of all, the code needs to be compiled into something that an average browser can understand. For that I used Typescript API.
Then, I evaluate the code. We all know eval() sucks, since it basically grasps the whole local context. That's highly insecure, so there is an alternative approach via new Function(). It is slightly better, because the code will only receive global lexical context, as if it was defined somewhere globally in a <script> tag.
Then I expect the snippet to have a default export to contain a react component.
There is one tricky thing: the imports. Speaking generally, if an application is packed with something like webpack, you have all your dependencies bundled together. So when an import occurs, the browser already has everything loaded or at least it knows how to get everything.
So, we need somehow cope with it. After the compilation the imports are transformed into a bunch of require() calls.
Then, we can define that require() function on our side and process it. In my case, I have it implemented in the following way:
import React from 'react';import * as MUIExports from '@mui/material';// todo: remove that sneaky shit-fix :)// @ts-expect-error A module without default export should have oneReact.default = React;const moduleMap: Record<string, Record<string, any>> = {react: React,'@mui/material': MUIExports,};export const requireModule = (moduleName: string) => {if (!(moduleName in moduleMap)) {return null;}return moduleMap[moduleName];};
Here I only allow react and @mui/material to be import-ed. Moreover, the version of react will be obviously the same as used in the application itself, and @mui/material - how it is defined in the package.json.
Also, the import is obviously executed in a synchronous manner.
This can be improved, but with at an extra cost.
We could have defined the map as something like:
const moduleMap: Record<string, Record<string, any>> = {react: () => React,'@mui/material': () => import('@mui/material'),};
So, the typescript API allows parsing the code into an AST. Then, we could have all the imports found before we actually compile, run dynamic import loading and only then resumed with the compilation. At least by doing so, the material ui wouldn't be bundled in advance and therefore loaded only on demand.
There is more to it. Some advanced solutions also offer loading arbitrary dependencies at runtime, using Unpkg. If you wish to implement something like this, bear in mind, that:
- You'll need to support package versioning, as you might not always want to load the latest version.
- When a library is loaded and executed, it may produce side effects (global variables, prototype patching, etc.). React and Styled-Components are two decent examples of making a mess. So, when an alternative version of the library is chosen, a whole sandbox must be wiped out clean and re-initialised. As far as I know, for now, the only way to do it is using iframes.
- Finally, you must pull all dependencies of every imported library along :) I think this would be the most tricky part.
Not so trivial anymore, huh? :)
If the snippet is a pair of lines long, you won't see any drop of performance. However, with an average snippet running the whole thing in the event loop can make the application really jittery. To prevent that, a worker must be used.
A brief recap on workers.
So, a worker is a piece of code that the browser will execute in a separate thread. You can think of it as a separate application running next to the in-page app.
You can spawn workers on-demand and communicate with them by sending JSON-based messages. You will also listen to the events from the worker to determine if the job was completed. No other variables are shared.
In my case, the code of the worker would be:
import { transpileModule, ModuleKind, JsxEmit } from 'typescript';type WorkerMessageType = {name: string;args?: string[];};const compile = async (code: string) => {const result = transpileModule(`import React from 'react';${code}`,{compilerOptions: {module: ModuleKind.CommonJS,allowJs: true,jsx: JsxEmit.React,},},);return result.outputText;};const actionMap: Record<string, (...args: string[]) => Promise<string>> = {compile,};self.onmessage = ({ data }: MessageEvent<WorkerMessageType>) => {const { name, args } = data;if (name in actionMap) {actionMap[name](...(args ?? [])).then((result) => {self.postMessage({name,result,});});}};
That particular worker is only capable of performing one task: compile the source code using typescript.
To run the task, I spawn the worker and send it a message:
type WorkerEventDataType = {name: string;result: string;};export const runTaskInWorker = async (actionName: string,...args: string[]) => {return new Promise<string>((resolve) => {const worker = new Worker(`${process.env.PUBLIC_URL}/worker.js`);worker.onmessage = ({ data }: MessageEvent<WorkerEventDataType>) => {const { name, result } = data;if (name === actionName) {resolve(result);}worker.terminate();};worker.postMessage({name: actionName,args,});});};
So, my compile() function eventually looks like this:
import { runTaskInWorker } from './runTaskInWorker';export const compile = async (code: string) => runTaskInWorker('compile', code);
I experimented for a while and came to the conclusion that the best way to compile the worker was to do it with a separate webpack config.
const path = require('path');const webpack = require('webpack');const src = path.resolve(__dirname, './src');const build = path.resolve(__dirname, './public');const buildProduction = path.resolve(__dirname, './build');module.exports = (env, argv) => {const development = argv.mode === 'development';return {mode: 'none',target: 'webworker',entry: './src/worker.ts',output: {filename: 'worker.js',path: development ? build : buildProduction,},resolve: {modules: ['node_modules', src],extensions: ['.js', '.json', '.jsx', '.ts', '.tsx'],},plugins: [new webpack.HotModuleReplacementPlugin(),new webpack.DefinePlugin({}),],module: {rules: [{test: /\.ts?$/,use: [{loader: 'ts-loader',options: {compilerOptions: {module: 'esnext',noEmit: false,},},},],},],},};};
Note that the target option is set to webworker.
Then I could have some additional scripts defined:
{"scripts": {"start": "react-scripts start","dev": "yarn start","dev:worker": "webpack --watch --config ./worker.webpack.config.js --mode=development","build": "yarn build:worker; yarn build:app","build:app": "react-scripts build","build:worker": "webpack build --config ./worker.webpack.config.js --mode=production",}}
Now comes the fun part. The main component be like
import { FC } from 'react';import { CircularProgress } from '@mui/material';import { PlaygroundPropsType } from './type';import {PlaygroundRoot,PlaygroundTop,PlaygroundBottom,PlaygroundError,} from './style';import { usePlayground } from './hooks/usePlayground';import { Editor } from '../Editor';import { ErrorBoundary } from '../ErrorBoundary';export const Playground: FC<PlaygroundPropsType> = (props) => {const {rootProps,editorProps,errorBoundaryProps,Component,compilationError,loading,} = usePlayground(props);return (<PlaygroundRoot {...rootProps}><PlaygroundTop><Editor {...editorProps} /></PlaygroundTop><PlaygroundBottom><ErrorBoundary {...errorBoundaryProps}>{!loading && Component && <Component />}{!loading && compilationError && (<PlaygroundError>{compilationError}</PlaygroundError>)}{loading && <CircularProgress />}</ErrorBoundary></PlaygroundBottom></PlaygroundRoot>);};
All component logic moved to a separate hook:
import { ComponentType, useCallback, useEffect, useState } from 'react';import { debounce } from 'throttle-debounce';import { PlaygroundPropsType } from '../type';import { execute } from '../../../lib/execute';const defaultValue = `import { Stack, Button } from '@mui/material';export default function BasicButtons() {return (<Stack spacing={2} direction="row"><Button variant="text">Text</Button><Button variant="contained">Contained</Button><Button variant="outlined">Outlined</Button></Stack>);}`;export const usePlayground = (props: PlaygroundPropsType) => {const [value, setValue] = useState(defaultValue);const [loading, setLoading] = useState(true);const [compilationResult, setCompilationResult] = useState<{component: ComponentType<unknown>;} | null>(null);const [compilationError, setCompilationError] = useState('');const onEditorValueChangeDebounced = useCallback(debounce(500, (nextValue: string) => {execute(nextValue).then(({ error, component }) => {if (error) {setCompilationError(error);setCompilationResult(null);} else {setCompilationError('');setCompilationResult({ component });}setLoading(false);});}),[],);useEffect(() => {onEditorValueChangeDebounced(defaultValue);}, [onEditorValueChangeDebounced]);return {rootProps: props,editorProps: {value: value,onValueChange: (nextValue: string) => {setLoading(true);setValue(nextValue);onEditorValueChangeDebounced(nextValue);},},errorBoundaryProps: {onError: (error: string | null) => {setCompilationResult(null);setCompilationError(error ?? 'An error occurred');},},Component: compilationResult ? compilationResult.component : null,compilationError,loading,};};
For the editor I used react-simple-code-editor:
import { FC } from 'react';import SimpleEditor from 'react-simple-code-editor';import { useEditor } from './hooks/useEditor';import { EditorRoot } from './style';import { EditorPropsType } from './type';const editorStyles = {fontFamily: '"Fira code", "Fira Mono", monospace',fontSize: 12,};export const Editor: FC<EditorPropsType> = ({ children, ...restProps }) => {const { rootProps, editorProps } = useEditor(restProps);return (<EditorRoot {...rootProps}><SimpleEditor {...editorProps} padding={10} style={editorStyles} /></EditorRoot>);};
...and the corresponding hook:
import { EditorPropsType } from '../type';import { highlight } from '../../../lib/highlight';const noop = () => {};export const useEditor = ({value,onValueChange,...restProps}: EditorPropsType) => {return {rootProps: restProps,editorProps: {value: value ?? '',onValueChange: onValueChange ?? noop,highlight: (code: string) => highlight(code, 'tsx'),},};};
For highlighting I used Prism.js:
import prism from 'prismjs';import 'prismjs/components/prism-css.js';import 'prismjs/components/prism-diff.js';import 'prismjs/components/prism-javascript.js';import 'prismjs/components/prism-json.js';import 'prismjs/components/prism-jsx.js';import 'prismjs/components/prism-markup.js';import 'prismjs/components/prism-tsx.js';import 'prismjs/themes/prism-coy.css';export const highlight = (code: string, language: string) => {let grammar;let prismLanguage = '';if (language === 'tsx' || language === 'ts') {grammar = prism.languages.tsx;prismLanguage = 'typescript';} else if (language === 'jsx' || language === 'js') {grammar = prism.languages.jsx;prismLanguage = 'javascript';}if (!grammar) {return code;}return prism.highlight(code, grammar, prismLanguage);};
A few words on possible types of errors that may occur.
👉 First of all, if there is a syntax error somewhere, the code won't compile and an exception will be thrown. That's type one error.
👉 Secondly, there might be a run-time error outside of the component definition. In this case, the evalInContext function will throw. That's type two.
👉 Lastly, there might be a run-time error inside of the component itself.
First two are covered by the try-catch statement of the execute() function. To catch that last one, I used ErrorBoundary:
import { Component, ReactNode } from 'react';type StateType = {hasError: boolean;errorMessage?: string;};type PropsType = {onError: (errorMessage: string) => void;children?: ReactNode;};export class ErrorBoundary extends Component<PropsType, StateType> {componentDidCatch({ message, stack }: Error) {this.props.onError(stack ?? message);}render() {return this.props.children;}}
That most definitely does the trick.
Pheww, that was a plenty of code snippets out there.
As you can see, no server-side code is needed to render a bunch of visitor-defined components, there is no rocket science or dark magic behind it. Of course, you need to arrange extra pre-cautions and prevent the snippet from being stored somewhere, otherwise it could turn into a stored XSS. If you do store a potentially harmful code, you must sanitise it in advance.
That's all for today, folks! The demo can be found here.
Hope this article was helpful!
Sergei Gannochenko
Golang, React, TypeScript, Docker, AWS, Jamstack.
19+ years in dev.