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));
}
};