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..f55cbf39 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -90,3 +90,51 @@ This means that while you may be used to destructuring `useState` when importing ); }; ``` + +### Server rendering + +For documentation pages, this makes preview content available in the initial HTML, +before the browser downloads or runs JavaScript. Server rendering stays disabled +by default. The same example produces: + +```html + +
+ +
Hello from SSR
+``` + +To reproduce this with the built package from the repository root: + +```sh +npm run build:lib +node packages/react-live/examples/ssr.mjs +``` + +`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/examples/ssr.mjs b/packages/react-live/examples/ssr.mjs new file mode 100644 index 00000000..ad1f7675 --- /dev/null +++ b/packages/react-live/examples/ssr.mjs @@ -0,0 +1,21 @@ +import assert from "node:assert/strict"; +import { createElement } from "react"; +import { renderToString } from "react-dom/server"; +import { LivePreview, LiveProvider } from "../dist/index.mjs"; + +const preview = (ssr) => + renderToString( + createElement( + LiveProvider, + { code: "Hello from SSR", ssr }, + createElement(LivePreview), + ), + ); + +const clientOnly = preview(false); +const serverRendered = preview(true); +assert.equal(clientOnly, "
"); +assert.equal(serverRendered, "
Hello from SSR
"); + +console.log("Default:", clientOnly); +console.log("With ssr:", serverRendered); 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..0ecf355d --- /dev/null +++ b/packages/react-live/src/components/Live/LiveProvider.ssr.test.jsx @@ -0,0 +1,273 @@ +import React, { StrictMode } 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("reuses the synchronous preview during StrictMode effect replay", 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(() => { + root = hydrateRoot(container, preview, { onRecoverableError }); + }); + // StrictMode invokes the client initializer twice; the server invokes it once. + expect(transformCode).toHaveBeenCalledTimes(3); + expect(container.textContent).toBe("hello"); + expect(onRecoverableError).not.toHaveBeenCalled(); + await act(() => root.unmount()); + }); + + it("reports rejected transforms after hydration", async () => { + const preview = ( + Promise.reject(new Error("transform failed"))} + > + + + + ); + const container = document.createElement("div"); + container.innerHTML = ReactDOMServer.renderToString(preview); + let root; + await act(async () => { + root = hydrateRoot(container, preview); + }); + expect(container.querySelector("pre").textContent).toBe( + "Error: transform failed", + ); + await act(() => root.unmount()); + }); + + it.each(["
", undefined])( + "reports invalid async transform output %s and recovers", + async (invalidCode) => { + const preview = ( + Promise.resolve(invalidCode)} + > + + + + ); + const container = document.createElement("div"); + container.innerHTML = ReactDOMServer.renderToString(preview); + let root; + await act(async () => { + root = hydrateRoot(container, preview); + }); + expect(container.querySelector("pre").textContent).toContain( + invalidCode === undefined ? "Code failed to transform" : "SyntaxError", + ); + await act(async () => { + root.render( + Promise.resolve("fixed")} + > + + + , + ); + }); + expect(container.querySelector("strong").textContent).toBe("fixed"); + expect(container.querySelector("pre")).toBeNull(); + await act(async () => root.unmount()); + }, + ); + + 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.each([false, true])( + "renders an async transform after hydration (StrictMode: %s)", + async (strict) => { + const resolvers = []; + const transformCode = vi.fn( + () => + new Promise((resolve) => { + resolvers.push(resolve); + }), + ); + const Wrapper = strict ? StrictMode : React.Fragment; + 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(strict ? 3 : 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..66b49443 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"; @@ -20,6 +20,41 @@ const renderLive = (props) => ); describe("rendering code", () => { + it.each([false, true])( + "renders a delayed noInline result (ssr: %s)", + async (ssr) => { + renderLive({ + noInline: true, + ssr, + code: "Promise.resolve().then(() => render(delayed))", + }); + expect(await screen.findByText("delayed")).toBeDefined(); + }, + ); + + it("ignores delayed noInline callbacks from an older edit", async () => { + let complete; + const scope = { + capture: (callback) => { + complete = callback; + }, + }; + const { rerender } = renderLive({ + noInline: true, + scope, + code: "capture(() => render(old))", + }); + await act(async () => {}); + rerender( + + + , + ); + await screen.findByText("new"); + act(() => complete()); + expect(document.querySelector("strong").textContent).toBe("new"); + }); + it("renders inline JSX", async () => { renderLive({ code: "Hello World!" }); expect(await screen.findByText("Hello World!")).toBeDefined(); @@ -167,12 +202,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 +222,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..ee33464d 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?: Promise; + code?: string; + options?: TranspileOptions; +}; + type Props = { code?: string; disabled?: boolean; @@ -16,8 +37,108 @@ 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 evaluateCode = ( + newCode: string, + transformedCode: string, + options: TranspileOptions, + onState: (state: ProviderState) => void, + onError: (error: Error) => void, + onRenderError = onError, +) => { + if (typeof transformedCode !== "string") { + throw new Error("Code failed to transform"); + } + + const input = { + code: transformedCode, + scope: options.scope, + enableTypeScript: options.enableTypeScript ?? true, + }; + const onElement = (element: ComponentType) => + onState({ error: undefined, element, newCode }); + + if (options.noInline) { + onState({ + error: undefined, + element: null, + newCode, + }); + renderElementAsync(input, onElement, onError, onRenderError); + } else { + onElement(generateElement(input, onRenderError)); + } +}; + +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: Promise.resolve(transformResult).then< + PendingTransform, + PendingTransform + >( + (value) => ({ status: "fulfilled", value }), + (reason) => ({ status: "rejected", reason }), + ), + }; + } + + let state = DEFAULT_STATE; + evaluateCode( + code, + transformResult, + options, + (nextState) => { + state = nextState; + }, + (error) => { + state = getErrorState(error); + }, + onError, + ); + return { state, code, options }; + } catch (error) { + return { state: getErrorState(error as Error), code, options }; + } }; function LiveProvider({ @@ -28,91 +149,119 @@ 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], + ); - // - 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 [state, setState] = useState(DEFAULT_STATE); + const onError = useCallback( + (error: Error) => setState(getErrorState(error)), + [], + ); + const [initialPreview] = useState(() => + ssr ? getInitialPreview(code, options, onError) : EMPTY_PREVIEW, + ); + const reusableInitialPreview = useRef(initialPreview); + const latestTranspileId = useRef(0); + + const resolvedState = state === DEFAULT_STATE ? initialPreview.state : state; + + const transpileAsync = useCallback( + async (newCode: string, pending?: Promise) => { + const transpileId = ++latestTranspileId.current; - if (typeof transformedCode !== "string") { - throw new Error("Code failed to transform"); + try { + const result = pending ? await pending : undefined; + if (result?.status === "rejected") { + throw result.reason; } + const transformedCode = + result?.status === "fulfilled" + ? result.value + : await Promise.resolve( + transformCode ? transformCode(newCode) : newCode, + ); - // 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)); + if (transpileId !== latestTranspileId.current) { + return; } + + evaluateCode( + newCode, + transformedCode, + options, + (nextState) => { + if (transpileId === latestTranspileId.current) { + setState(nextState); + } + }, + (error) => { + if (transpileId === latestTranspileId.current) { + onError(error); + } + }, + ); } catch (error) { - return errorCallback(error as Error); + if (transpileId === latestTranspileId.current) { + onError(error as Error); + } } - } catch (e) { - errorCallback(e as Error); - return Promise.resolve(); - } - } - - const onError = (error: Error) => setState({ error: error.toString() }); + }, + [onError, options, transformCode], + ); useEffect(() => { - transpileAsync(code).catch(onError); - }, [code, scope, noInline, transformCode]); + const canReuseInitialPreview = + reusableInitialPreview.current === initialPreview && + initialPreview.code === code && + initialPreview.options === options; + if (!canReuseInitialPreview) { + reusableInitialPreview.current = EMPTY_PREVIEW; + } + // Keep the snapshot through StrictMode effect replay. A delayed noInline + // render has no server preview, so it must run with live client callbacks. + if ( + !canReuseInitialPreview || + initialPreview.pending || + initialPreview.state.element === null + ) { + void transpileAsync( + code, + canReuseInitialPreview ? initialPreview.pending : undefined, + ); + } + return () => { + latestTranspileId.current += 1; + }; + }, [code, initialPreview, options, transpileAsync]); - const onChange = (newCode: string) => { - transpileAsync(newCode).catch(onError); - }; + const onChange = useCallback( + (newCode: string) => { + reusableInitialPreview.current = EMPTY_PREVIEW; + void transpileAsync(newCode); + }, + [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)); } };