Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/bright-ravens-render.md
Original file line number Diff line number Diff line change
@@ -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.
5 changes: 5 additions & 0 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LiveEditor />` (Default: `false`) |
Expand All @@ -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.

### `<LiveEditor />`

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.
Expand Down
30 changes: 30 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<strong>Hello world</strong>`
- `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 = `<strong>Hello from SSR</strong>`;

<LiveProvider code={code} ssr>
<LivePreview />
</LiveProvider>;
```

```jsx
const code = `render(<strong>Hello from SSR</strong>)`;

<LiveProvider code={code} noInline ssr>
<LivePreview />
</LiveProvider>;
```
179 changes: 179 additions & 0 deletions packages/react-live/src/components/Live/LiveProvider.ssr.test.jsx
Original file line number Diff line number Diff line change
@@ -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(
<LiveProvider code="() => { throw new Error('server crash') }">
<LivePreview />
</LiveProvider>,
);

expect(html).toBe("<div></div>");
});

it("renders inline previews during the initial server render", () => {
const html = ReactDOMServer.renderToStaticMarkup(
<LiveProvider code="<strong>Hello SSR!</strong>" ssr>
<LivePreview />
</LiveProvider>,
);

expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
});

it("renders noInline previews during the initial server render", () => {
const html = ReactDOMServer.renderToStaticMarkup(
<LiveProvider code="render(<strong>Hello SSR!</strong>)" noInline ssr>
<LivePreview />
</LiveProvider>,
);

expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
});

it("renders transformed code when transformCode is synchronous", () => {
const transformCode = vi.fn((code) => `<strong>${code}</strong>`);
const html = ReactDOMServer.renderToStaticMarkup(
<LiveProvider code="Hello SSR!" transformCode={transformCode} ssr>
<LivePreview />
</LiveProvider>,
);

expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
expect(transformCode).toHaveBeenCalledOnce();
});

it("defers the preview when transformCode resolves asynchronously", () => {
const html = ReactDOMServer.renderToStaticMarkup(
<LiveProvider
code="Hello SSR!"
transformCode={(code) => Promise.resolve(`<strong>${code}</strong>`)}
ssr
>
<LivePreview />
</LiveProvider>,
);

expect(html).toBe("<div></div>");
});

it("does not repeat a synchronous transform after hydration", async () => {
const transformCode = vi.fn((code) => `<strong>${code}</strong>`);
const preview = (
<LiveProvider code="Hello SSR!" transformCode={transformCode} ssr>
<LivePreview />
</LiveProvider>
);
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("<div><strong>Hello SSR!</strong></div>");
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 = (
<LiveProvider code="Hello SSR!" transformCode={transformCode} ssr>
<LivePreview />
</LiveProvider>
);
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("<div></div>");

await act(async () => {
resolvers.forEach((resolve) => resolve("<strong>Hello SSR!</strong>"));
});

expect(container.innerHTML).toBe("<div><strong>Hello SSR!</strong></div>");
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 = (
<LiveProvider code="<strong>first</strong>" ssr>
<LivePreview />
</LiveProvider>
);
const container = document.createElement("div");
container.innerHTML = ReactDOMServer.renderToString(firstPreview);

let root;
await act(async () => {
root = hydrateRoot(container, firstPreview);
});

await act(async () => {
root.render(
<LiveProvider code="<strong>second</strong>" ssr>
<LivePreview />
</LiveProvider>,
);
});

expect(container.innerHTML).toBe("<div><strong>second</strong></div>");

await act(async () => root.unmount());
});

it("renders transform errors during the initial server render", () => {
const html = ReactDOMServer.renderToStaticMarkup(
<LiveProvider
code="Hello SSR!"
transformCode={() => {
throw new Error("Failed to transform");
}}
ssr
>
<LivePreview />
<LiveError />
</LiveProvider>,
);

expect(html).toBe("<div></div><pre>Error: Failed to transform</pre>");
});

it("renders noInline evaluation errors during the initial server render", () => {
const html = ReactDOMServer.renderToStaticMarkup(
<LiveProvider code="<strong>Hello SSR!</strong>" noInline ssr>
<LivePreview />
<LiveError />
</LiveProvider>,
);

expect(html).toContain("No-Inline evaluations must call `render`.");
});
});
37 changes: 35 additions & 2 deletions packages/react-live/src/components/Live/LiveProvider.test.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -167,12 +167,15 @@ describe("errors", () => {
*/
describe("transformCode", () => {
it("applies a synchronous transformCode function", async () => {
const transformCode = vi.fn((code) => `render(<div>${code}</div>)`);

renderLive({
code: "hello",
noInline: true,
transformCode: (code) => `render(<div>${code}</div>)`,
transformCode,
});
expect(await screen.findByText("hello")).toBeDefined();
expect(transformCode).toHaveBeenCalledOnce();
});

it("applies an asynchronous transformCode function", async () => {
Expand All @@ -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(
<LiveProvider code="first" transformCode={transformCode}>
<LivePreview />
</LiveProvider>,
);

rerender(
<LiveProvider code="second" transformCode={transformCode}>
<LivePreview />
</LiveProvider>,
);

await act(async () => {
resolvers.get("second")("<strong>second</strong>");
});
expect(screen.getByText("second")).toBeDefined();

await act(async () => {
resolvers.get("first")("<strong>first</strong>");
});
expect(screen.queryByText("first")).toBeNull();
expect(screen.getByText("second")).toBeDefined();
});

it("catches errors from a synchronous transformCode function", async () => {
renderLive({
code: "hello",
Expand Down
Loading