diff --git a/.changeset/bright-ravens-render.md b/.changeset/bright-ravens-render.md new file mode 100644 index 00000000..3220d09f --- /dev/null +++ b/.changeset/bright-ravens-render.md @@ -0,0 +1,11 @@ +--- +"react-live": minor +--- + +Add opt-in server rendering for synchronous `LiveProvider` previews. + +Set the new `ssr` prop to render inline examples, `noInline` examples that call +`render(...)`, and synchronous `transformCode` output during the initial server +render. Asynchronous transforms continue after hydration. Server rendering is +disabled by default so existing examples cannot unexpectedly execute on the +server. diff --git a/docs/api.md b/docs/api.md index 1bac7db9..13220dce 100644 --- a/docs/api.md +++ b/docs/api.md @@ -15,6 +15,7 @@ It supports these props, while passing any others through to the `children`: | 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 | +| ssr | `PropTypes.bool` | Opt in to rendering synchronous previews on the server. Use only with code and scope that are safe to execute during server rendering. (Default: `false`) | | 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`) | @@ -27,6 +28,10 @@ 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. +Set `ssr` to render the initial preview on the server when the example can be evaluated synchronously. That includes the default inline mode, `noInline` examples that call `render(...)` during evaluation, and synchronous `transformCode` functions. If `transformCode` returns a Promise, the preview stays empty on the server and is filled in after hydration. + +Server rendering is opt-in because example code executes during the server render. Only enable it for trusted code that does not depend on browser APIs, produce non-deterministic output, or perform side effects. Errors thrown while the resulting preview component renders follow React's server-rendering behavior and can abort the surrounding server render. + ### `` This component renders the editor that displays the code. It uses [`use-editable`](https://github.com/kitten/use-editable) for editing and [`prism-react-renderer`](https://github.com/FormidableLabs/prism-react-renderer) for syntax highlighting. diff --git a/docs/usage.md b/docs/usage.md index c7178aba..b3852a76 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -90,3 +90,33 @@ This means that while you may be used to destructuring `useState` when importing ); }; ``` + +### Server rendering + +`LiveProvider` can render the initial preview during SSR when `ssr` is enabled and 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. + +Server rendering executes the example during the server render. Only enable it for trusted, deterministic code that does not use browser APIs or perform side effects. A rendering error inside the resulting preview follows React's server-rendering behavior and can abort the surrounding server render. + +```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.jsx b/packages/react-live/src/components/Live/LiveProvider.ssr.test.jsx new file mode 100644 index 00000000..f40c8481 --- /dev/null +++ b/packages/react-live/src/components/Live/LiveProvider.ssr.test.jsx @@ -0,0 +1,179 @@ +import React from "react"; +import ReactDOMServer from "react-dom/server"; +import { act } from "@testing-library/react"; +import { hydrateRoot } from "react-dom/client"; + +import LiveError from "./LiveError"; +import LivePreview from "./LivePreview"; +import LiveProvider from "./LiveProvider"; + +describe("LiveProvider SSR", () => { + it("keeps server rendering disabled by default", () => { + const html = ReactDOMServer.renderToStaticMarkup( + + + , + ); + + expect(html).toBe("
"); + }); + + 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 transformCode = vi.fn((code) => `${code}`); + const html = ReactDOMServer.renderToStaticMarkup( + + + , + ); + + expect(html).toBe("
Hello SSR!
"); + expect(transformCode).toHaveBeenCalledOnce(); + }); + + it("defers the preview when transformCode resolves asynchronously", () => { + const html = ReactDOMServer.renderToStaticMarkup( + Promise.resolve(`${code}`)} + ssr + > + + , + ); + + expect(html).toBe("
"); + }); + + it("does not repeat a synchronous transform after hydration", async () => { + const transformCode = vi.fn((code) => `${code}`); + const preview = ( + + + + ); + const container = document.createElement("div"); + container.innerHTML = ReactDOMServer.renderToString(preview); + const onRecoverableError = vi.fn(); + + let root; + await act(async () => { + root = hydrateRoot(container, preview, { onRecoverableError }); + }); + + expect(container.innerHTML).toBe("
Hello SSR!
"); + expect(transformCode).toHaveBeenCalledTimes(2); + expect(onRecoverableError).not.toHaveBeenCalled(); + + await act(async () => root.unmount()); + }); + + it("renders an asynchronous transform after hydration", async () => { + const resolvers = []; + const transformCode = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const preview = ( + + + + ); + const container = document.createElement("div"); + container.innerHTML = ReactDOMServer.renderToString(preview); + const onRecoverableError = vi.fn(); + + let root; + await act(async () => { + root = hydrateRoot(container, preview, { onRecoverableError }); + }); + + expect(container.innerHTML).toBe("
"); + + await act(async () => { + resolvers.forEach((resolve) => resolve("Hello SSR!")); + }); + + expect(container.innerHTML).toBe("
Hello SSR!
"); + expect(onRecoverableError).not.toHaveBeenCalled(); + expect(transformCode).toHaveBeenCalledTimes(2); + + await act(async () => root.unmount()); + }); + + it("transpiles later code updates after hydrating an SSR preview", async () => { + const firstPreview = ( + + + + ); + const container = document.createElement("div"); + container.innerHTML = ReactDOMServer.renderToString(firstPreview); + + let root; + await act(async () => { + root = hydrateRoot(container, firstPreview); + }); + + await act(async () => { + root.render( + + + , + ); + }); + + expect(container.innerHTML).toBe("
second
"); + + await act(async () => root.unmount()); + }); + + it("renders transform errors during the initial server render", () => { + const html = ReactDOMServer.renderToStaticMarkup( + { + throw new Error("Failed to transform"); + }} + ssr + > + + + , + ); + + expect(html).toBe("
Error: Failed to transform
"); + }); + + it("renders noInline evaluation errors during the initial server render", () => { + const html = ReactDOMServer.renderToStaticMarkup( + + + + , + ); + + expect(html).toContain("No-Inline evaluations must call `render`."); + }); +}); diff --git a/packages/react-live/src/components/Live/LiveProvider.test.jsx b/packages/react-live/src/components/Live/LiveProvider.test.jsx index 87c1be0c..731b1ec1 100644 --- a/packages/react-live/src/components/Live/LiveProvider.test.jsx +++ b/packages/react-live/src/components/Live/LiveProvider.test.jsx @@ -1,4 +1,4 @@ -import { render, screen } from "@testing-library/react"; +import { act, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import LiveProvider from "./LiveProvider"; @@ -167,12 +167,15 @@ describe("errors", () => { */ describe("transformCode", () => { it("applies a synchronous transformCode function", async () => { + const transformCode = vi.fn((code) => `render(
${code}
)`); + renderLive({ code: "hello", noInline: true, - transformCode: (code) => `render(
${code}
)`, + transformCode, }); expect(await screen.findByText("hello")).toBeDefined(); + expect(transformCode).toHaveBeenCalledOnce(); }); it("applies an asynchronous transformCode function", async () => { @@ -184,6 +187,36 @@ describe("transformCode", () => { expect(await screen.findByText("hello")).toBeDefined(); }); + it("ignores stale asynchronous transform results", async () => { + const resolvers = new Map(); + const transformCode = (code) => + new Promise((resolve) => { + resolvers.set(code, resolve); + }); + const { rerender } = render( + + + , + ); + + rerender( + + + , + ); + + await act(async () => { + resolvers.get("second")("second"); + }); + expect(screen.getByText("second")).toBeDefined(); + + await act(async () => { + resolvers.get("first")("first"); + }); + expect(screen.queryByText("first")).toBeNull(); + expect(screen.getByText("second")).toBeDefined(); + }); + it("catches errors from a synchronous transformCode function", async () => { renderLive({ code: "hello", diff --git a/packages/react-live/src/components/Live/LiveProvider.tsx b/packages/react-live/src/components/Live/LiveProvider.tsx index 030dcbb5..f211b976 100644 --- a/packages/react-live/src/components/Live/LiveProvider.tsx +++ b/packages/react-live/src/components/Live/LiveProvider.tsx @@ -1,4 +1,12 @@ -import { useEffect, useState, ComponentType, PropsWithChildren } from "react"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + ComponentType, + PropsWithChildren, +} from "react"; import LiveContext from "./LiveContext"; import { generateElement, renderElementAsync } from "../../utils/transpile"; import { themes } from "prism-react-renderer"; @@ -9,6 +17,19 @@ type ProviderState = { newCode?: string; }; +type TransformResult = string | PromiseLike; + +type PendingTransform = + | { status: "fulfilled"; value: string } + | { status: "rejected"; reason: unknown }; + +type InitialPreview = { + state: ProviderState; + pending?: PromiseLike; + code?: string; + options?: TranspileOptions; +}; + type Props = { code?: string; disabled?: boolean; @@ -16,8 +37,113 @@ type Props = { language?: string; noInline?: boolean; scope?: Record; + ssr?: boolean; 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 EMPTY_PREVIEW: InitialPreview = { state: DEFAULT_STATE }; + +const isPromiseLike = ( + value: TransformResult, +): value is PromiseLike => { + return typeof (value as PromiseLike)?.then === "function"; +}; + +const getErrorState = (error: unknown): ProviderState => ({ + error: String(error), + 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 getInitialPreview = ( + code: string, + options: TranspileOptions, + onError: (error: Error) => void, +): InitialPreview => { + try { + const transformResult = options.transformCode + ? options.transformCode(code) + : code; + + if (isPromiseLike(transformResult)) { + return { + state: DEFAULT_STATE, + code, + options, + pending: transformResult.then( + (value) => ({ status: "fulfilled", value }), + (reason) => ({ status: "rejected", reason }), + ), + }; + } + + return { + state: getPreviewState(code, transformResult, options, onError), + code, + options, + }; + } catch (error) { + return { state: getErrorState(error as Error), code, options }; + } }; function LiveProvider({ @@ -28,91 +154,125 @@ function LiveProvider({ enableTypeScript = true, disabled = false, scope, + ssr = false, transformCode, noInline = false, }: PropsWithChildren) { - const [state, setState] = useState({ - error: undefined, - element: undefined, - }); - - async function transpileAsync(newCode: string) { - const errorCallback = (error: Error) => { - setState((previousState) => ({ - ...previousState, - error: error.toString(), - element: undefined, - })); - }; + const options: TranspileOptions = useMemo( + () => ({ enableTypeScript, noInline, scope, transformCode }), + [enableTypeScript, noInline, scope, transformCode], + ); + + const [state, setState] = useState(DEFAULT_STATE); + const onError = useCallback( + (error: Error) => setState(getErrorState(error)), + [], + ); + const [initialPreview] = useState(() => + ssr ? getInitialPreview(code, options, onError) : EMPTY_PREVIEW, + ); + const isFirstEffect = useRef(true); + const latestTranspileId = useRef(0); + + const resolvedState = state === DEFAULT_STATE ? initialPreview.state : state; + + const setTransformedCode = useCallback( + (newCode: string, transformedCode: string) => { + setState(getPreviewState(newCode, transformedCode, options, onError)); + }, + [onError, options], + ); + + const transpileAsync = useCallback( + async (newCode: string) => { + const transpileId = ++latestTranspileId.current; - // - transformCode may be synchronous or asynchronous. - // - transformCode may throw an exception or return a rejected promise, e.g. - // if newCode is invalid and cannot be transformed. - // - Not using async-await to since it requires targeting ES 2017 or - // importing regenerator-runtime... in the next major version of - // react-live, should target ES 2017+ - try { - const transformResult = transformCode ? transformCode(newCode) : newCode; try { - const transformedCode = await Promise.resolve(transformResult); - const renderElement = (element: ComponentType) => - setState({ error: undefined, element, newCode }); + const transformedCode = await Promise.resolve( + transformCode ? transformCode(newCode) : newCode, + ); - if (typeof transformedCode !== "string") { - throw new Error("Code failed to transform"); + if (transpileId !== latestTranspileId.current) { + return; } - // Transpilation arguments - const input = { - code: transformedCode, - scope, - enableTypeScript, + setTransformedCode(newCode, transformedCode); + } catch (error) { + if (transpileId === latestTranspileId.current) { + onError(error as Error); + } + } + }, + [onError, setTransformedCode, transformCode], + ); + + useEffect(() => { + if (isFirstEffect.current) { + isFirstEffect.current = false; + const canReuseInitialPreview = + initialPreview.code === code && initialPreview.options === options; + + if (canReuseInitialPreview && initialPreview.pending) { + let isCurrent = true; + const transpileId = ++latestTranspileId.current; + + initialPreview.pending.then((result) => { + if (!isCurrent || transpileId !== latestTranspileId.current) { + return; + } + + if (result.status === "fulfilled") { + setTransformedCode(code, result.value); + } else { + onError( + result.reason instanceof Error + ? result.reason + : new Error(String(result.reason)), + ); + } + }); + + return () => { + isCurrent = false; }; + } - 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)); - } - } catch (error) { - return errorCallback(error as Error); + if (canReuseInitialPreview && initialPreview.state !== DEFAULT_STATE) { + return; } - } catch (e) { - errorCallback(e as Error); - return Promise.resolve(); } - } - const onError = (error: Error) => setState({ error: error.toString() }); - - useEffect(() => { transpileAsync(code).catch(onError); - }, [code, scope, noInline, transformCode]); + }, [ + code, + initialPreview, + onError, + options, + setTransformedCode, + transpileAsync, + ]); - const onChange = (newCode: string) => { - transpileAsync(newCode).catch(onError); - }; + const onChange = useCallback( + (newCode: string) => { + transpileAsync(newCode).catch(onError); + }, + [onError, transpileAsync], + ); - return ( - - {children} - + const value = useMemo( + () => ({ + ...resolvedState, + code, + language, + theme, + disabled, + onError, + onChange, + }), + [code, disabled, language, onChange, onError, resolvedState, theme], ); + + return {children}; } export default LiveProvider; diff --git a/packages/react-live/src/utils/transpile/index.ts b/packages/react-live/src/utils/transpile/index.ts index 83df8913..a95ebdea 100644 --- a/packages/react-live/src/utils/transpile/index.ts +++ b/packages/react-live/src/utils/transpile/index.ts @@ -48,13 +48,14 @@ export const renderElementAsync = ( { code = "", scope = {}, enableTypeScript = true }: GenerateOptions, resultCallback: (sender: ComponentType) => 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)); } };