diff --git a/docs/api.md b/docs/api.md index bf87977..8621258 100644 --- a/docs/api.md +++ b/docs/api.md @@ -9,16 +9,16 @@ sidebar_position: 4 This component provides the `context` for all the other ones. It also transpiles the user’s code! It supports these props, while passing any others through to the `children`: -| Name | PropType | Description | -| ------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| code | `PropTypes.string` | The code that should be rendered, apart from the user’s edits | -| scope | `PropTypes.object` | Accepts custom globals that the `code` can use | -| noInline | `PropTypes.bool` | Doesn’t evaluate and mount the inline code (Default: `false`). Note: when using `noInline` whatever code you write must be a single expression (function, class component or some `jsx`) that can be returned immediately. If you'd like to render multiple components, use `noInline={true}` | -| transformCode | `PropTypes.func` | Accepts and returns the code to be transpiled, affording an opportunity to first transform it | -| language | `PropTypes.string` | What language you're writing for correct syntax highlighting. (Default: `jsx`) | -| enableTypeScript | `PropTypes.bool` | Enables TypeScript support in transpilation. (Default: `true`) | -| disabled | `PropTypes.bool` | Disable editing on the `` (Default: `false`) | -| theme | `PropTypes.object` | A `prism-react-renderer` theme object. See more [here](https://github.com/FormidableLabs/prism-react-renderer#theming) | +| Name | PropType | Description | +| ---------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| code | `PropTypes.string` | The code that should be rendered, apart from the user’s edits | +| scope | `PropTypes.object` | Accepts custom globals that the `code` can use | +| noInline | `PropTypes.bool` | Doesn’t evaluate and mount the inline code (Default: `false`). Note: when using `noInline` whatever code you write must be a single expression (function, class component or some `jsx`) that can be returned immediately. If you'd like to render multiple components, use `noInline={true}` | +| transformCode | `PropTypes.func` | Accepts and returns the code to be transpiled, affording an opportunity to first transform it. Synchronous transforms participate in the initial server render, while asynchronous transforms defer preview output until hydration. | +| language | `PropTypes.string` | What language you're writing for correct syntax highlighting. (Default: `jsx`) | +| enableTypeScript | `PropTypes.bool` | Enables TypeScript support in transpilation. (Default: `true`) | +| disabled | `PropTypes.bool` | Disable editing on the `` (Default: `false`) | +| theme | `PropTypes.object` | A `prism-react-renderer` theme object. See more [here](https://github.com/FormidableLabs/prism-react-renderer#theming) | All subsequent components must be rendered inside a provider, since they communicate using one. @@ -27,6 +27,8 @@ The `noInline` option kicks the Provider into a different mode, where you can wr code and nothing gets evaluated and mounted automatically. Your example will need to call `render` with valid JSX elements. +`LiveProvider` can also render the initial preview during SSR as long as the example can be evaluated synchronously. That includes the default inline mode and `noInline` examples that call `render(...)` during evaluation. If `transformCode` returns a Promise, the preview stays empty on the server and is filled in after hydration. + ### `` This component renders the editor that displays the code. It is a wrapper around [`react-simple-code-editor`](https://github.com/satya164/react-simple-code-editor) and the code highlighted using [`prism-react-renderer`](https://github.com/FormidableLabs/prism-react-renderer). diff --git a/docs/usage.md b/docs/usage.md index c7178ab..d1df6e0 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -90,3 +90,31 @@ This means that while you may be used to destructuring `useState` when importing ); }; ``` + +### Server rendering + +`LiveProvider` can render the initial preview during SSR when the preview can be resolved synchronously. + +This works for: + +- Inline examples such as `Hello world` +- `noInline` examples that call `render(...)` during evaluation +- Synchronous `transformCode` functions + +If `transformCode` returns a Promise, React Live leaves the preview empty on the server and fills it in after hydration. + +```jsx +const code = `Hello from SSR`; + + + +; +``` + +```jsx +const code = `render(Hello from SSR)`; + + + +; +``` diff --git a/packages/react-live/src/components/Live/LiveProvider.ssr.test.js b/packages/react-live/src/components/Live/LiveProvider.ssr.test.js new file mode 100644 index 0000000..d5d6b36 --- /dev/null +++ b/packages/react-live/src/components/Live/LiveProvider.ssr.test.js @@ -0,0 +1,53 @@ +import React from "react"; +import ReactDOMServer from "react-dom/server"; + +import LivePreview from "./LivePreview.tsx"; +import LiveProvider from "./LiveProvider.tsx"; + +describe("LiveProvider SSR", () => { + it("renders inline previews during the initial server render", () => { + const html = ReactDOMServer.renderToStaticMarkup( + + + + ); + + expect(html).toBe("
Hello SSR!
"); + }); + + it("renders noInline previews during the initial server render", () => { + const html = ReactDOMServer.renderToStaticMarkup( + + + + ); + + expect(html).toBe("
Hello SSR!
"); + }); + + it("renders transformed code when transformCode is synchronous", () => { + const html = ReactDOMServer.renderToStaticMarkup( + `${code}`} + > + + + ); + + expect(html).toBe("
Hello SSR!
"); + }); + + it("defers the preview when transformCode resolves asynchronously", () => { + const html = ReactDOMServer.renderToStaticMarkup( + Promise.resolve(`${code}`)} + > + + + ); + + expect(html).toBe("
"); + }); +}); diff --git a/packages/react-live/src/components/Live/LiveProvider.tsx b/packages/react-live/src/components/Live/LiveProvider.tsx index 030dcbb..ae2118d 100644 --- a/packages/react-live/src/components/Live/LiveProvider.tsx +++ b/packages/react-live/src/components/Live/LiveProvider.tsx @@ -9,6 +9,8 @@ type ProviderState = { newCode?: string; }; +type TransformResult = string | Promise; + type Props = { code?: string; disabled?: boolean; @@ -17,7 +19,96 @@ type Props = { noInline?: boolean; scope?: Record; theme?: typeof themes.nightOwl; - transformCode?(code: string): void; + transformCode?(code: string): TransformResult; +}; + +type TranspileOptions = Pick< + Props, + "enableTypeScript" | "noInline" | "scope" | "transformCode" +>; + +const DEFAULT_STATE: ProviderState = { + error: undefined, + element: undefined, +}; + +const isPromiseLike = (value: TransformResult): value is Promise => { + return typeof (value as Promise)?.then === "function"; +}; + +const getErrorState = (error: Error): ProviderState => ({ + error: error.toString(), + element: undefined, +}); + +const getTranspileInput = ( + code: string, + { scope, enableTypeScript = true }: TranspileOptions +) => ({ + code, + scope, + enableTypeScript, +}); + +const getPreviewState = ( + newCode: string, + transformedCode: string, + options: TranspileOptions, + onError: (error: Error) => void +): ProviderState => { + if (typeof transformedCode !== "string") { + throw new Error("Code failed to transform"); + } + + const input = getTranspileInput(transformedCode, options); + + if (options.noInline) { + let nextState: ProviderState = { + error: undefined, + element: null, + newCode, + }; + + renderElementAsync( + input, + (element: ComponentType) => { + nextState = { error: undefined, element, newCode }; + }, + (error: Error) => { + nextState = getErrorState(error); + }, + onError + ); + + return nextState; + } + + return { + error: undefined, + element: generateElement(input, onError), + newCode, + }; +}; + +const getInitialState = ( + code: string, + options: TranspileOptions, + onError: (error: Error) => void +): ProviderState => { + try { + const transformResult = options.transformCode + ? options.transformCode(code) + : code; + + if (isPromiseLike(transformResult)) { + void transformResult.catch(() => undefined); + return DEFAULT_STATE; + } + + return getPreviewState(code, transformResult, options, onError); + } catch (error) { + return getErrorState(error as Error); + } }; function LiveProvider({ @@ -31,17 +122,29 @@ function LiveProvider({ transformCode, noInline = false, }: PropsWithChildren) { - const [state, setState] = useState({ - error: undefined, - element: undefined, - }); + const [state, setState] = useState(DEFAULT_STATE); + + const options: TranspileOptions = { + enableTypeScript, + noInline, + scope, + transformCode, + }; + + const onError = (error: Error) => setState(getErrorState(error)); + + const resolvedState = + state.element === undefined && + state.error === undefined && + state.newCode === undefined + ? getInitialState(code, options, onError) + : state; async function transpileAsync(newCode: string) { const errorCallback = (error: Error) => { setState((previousState) => ({ ...previousState, - error: error.toString(), - element: undefined, + ...getErrorState(error), })); }; @@ -55,30 +158,7 @@ function LiveProvider({ const transformResult = transformCode ? transformCode(newCode) : newCode; try { const transformedCode = await Promise.resolve(transformResult); - const renderElement = (element: ComponentType) => - setState({ error: undefined, element, newCode }); - - if (typeof transformedCode !== "string") { - throw new Error("Code failed to transform"); - } - - // Transpilation arguments - const input = { - code: transformedCode, - scope, - enableTypeScript, - }; - - if (noInline) { - setState((previousState) => ({ - ...previousState, - error: undefined, - element: null, - })); // Reset output for async (no inline) evaluation - renderElementAsync(input, renderElement, errorCallback); - } else { - renderElement(generateElement(input, errorCallback)); - } + setState(getPreviewState(newCode, transformedCode, options, onError)); } catch (error) { return errorCallback(error as Error); } @@ -88,11 +168,9 @@ function LiveProvider({ } } - const onError = (error: Error) => setState({ error: error.toString() }); - useEffect(() => { transpileAsync(code).catch(onError); - }, [code, scope, noInline, transformCode]); + }, [code, enableTypeScript, noInline, scope, transformCode]); const onChange = (newCode: string) => { transpileAsync(newCode).catch(onError); @@ -101,7 +179,7 @@ function LiveProvider({ return ( void, - errorCallback: (error: Error) => void + errorCallback: (error: Error) => void, + renderErrorCallback: (error: Error) => void = errorCallback // eslint-disable-next-line consistent-return ) => { const render = (element: ComponentType) => { if (typeof element === "undefined") { errorCallback(new SyntaxError("`render` must be called with valid JSX.")); } else { - resultCallback(errorBoundary(element, errorCallback)); + resultCallback(errorBoundary(element, renderErrorCallback)); } };