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
48 changes: 48 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- Default -->
<div></div>
<!-- With ssr -->
<div><strong>Hello from SSR</strong></div>
```

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 `<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>;
```
21 changes: 21 additions & 0 deletions packages/react-live/examples/ssr.mjs
Original file line number Diff line number Diff line change
@@ -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: "<strong>Hello from SSR</strong>", ssr },
createElement(LivePreview),
),
);

const clientOnly = preview(false);
const serverRendered = preview(true);
assert.equal(clientOnly, "<div></div>");
assert.equal(serverRendered, "<div><strong>Hello from SSR</strong></div>");

console.log("Default:", clientOnly);
console.log("With ssr:", serverRendered);
273 changes: 273 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,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) => `<strong>${code}</strong>`);
const preview = (
<StrictMode>
<LiveProvider code="hello" ssr transformCode={transformCode}>
<LivePreview />
</LiveProvider>
</StrictMode>
);
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 = (
<LiveProvider
code="hello"
ssr
transformCode={() => Promise.reject(new Error("transform failed"))}
>
<LivePreview />
<LiveError />
</LiveProvider>
);
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(["<div>", undefined])(
"reports invalid async transform output %s and recovers",
async (invalidCode) => {
const preview = (
<LiveProvider
code="hello"
ssr
transformCode={() => Promise.resolve(invalidCode)}
>
<LivePreview />
<LiveError />
</LiveProvider>
);
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(
<LiveProvider
code="fixed"
ssr
transformCode={() => Promise.resolve("<strong>fixed</strong>")}
>
<LivePreview />
<LiveError />
</LiveProvider>,
);
});
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(
<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.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 = (
<Wrapper>
<LiveProvider code="Hello SSR!" transformCode={transformCode} ssr>
<LivePreview />
</LiveProvider>
</Wrapper>
);
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(strict ? 3 : 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`.");
});
});
Loading