Skip to content

Commit 188edc8

Browse files
committed
Add opt-in LiveProvider SSR support
1 parent 876a2a9 commit 188edc8

7 files changed

Lines changed: 492 additions & 73 deletions

File tree

‎.changeset/bright-ravens-render.md‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
---
2+
"react-live": minor
3+
---
4+
5+
Add opt-in server rendering for synchronous `LiveProvider` previews.
6+
7+
Set the new `ssr` prop to render inline examples, `noInline` examples that call
8+
`render(...)`, and synchronous `transformCode` output during the initial server
9+
render. Asynchronous transforms continue after hydration. Server rendering is
10+
disabled by default so existing examples cannot unexpectedly execute on the
11+
server.

‎docs/api.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ It supports these props, while passing any others through to the `children`:
1515
| scope | `PropTypes.object` | Accepts custom globals that the `code` can use |
1616
| 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}` |
1717
| transformCode | `PropTypes.func` | Accepts and returns the code to be transpiled, affording an opportunity to first transform it |
18+
| 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`) |
1819
| language | `PropTypes.string` | What language you're writing for correct syntax highlighting. (Default: `jsx`) |
1920
| enableTypeScript | `PropTypes.bool` | Enables TypeScript support in transpilation. (Default: `true`) |
2021
| disabled | `PropTypes.bool` | Disable editing on the `<LiveEditor />` (Default: `false`) |
@@ -27,6 +28,10 @@ The `noInline` option kicks the Provider into a different mode, where you can wr
2728
code and nothing gets evaluated and mounted automatically. Your example will need to call `render`
2829
with valid JSX elements.
2930

31+
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.
32+
33+
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.
34+
3035
### `<LiveEditor />`
3136

3237
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.

‎docs/usage.md‎

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,33 @@ This means that while you may be used to destructuring `useState` when importing
9090
);
9191
};
9292
```
93+
94+
### Server rendering
95+
96+
`LiveProvider` can render the initial preview during SSR when `ssr` is enabled and the preview can be resolved synchronously.
97+
98+
This works for:
99+
100+
- Inline examples such as `<strong>Hello world</strong>`
101+
- `noInline` examples that call `render(...)` during evaluation
102+
- Synchronous `transformCode` functions
103+
104+
If `transformCode` returns a Promise, React Live leaves the preview empty on the server and fills it in after hydration.
105+
106+
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.
107+
108+
```jsx
109+
const code = `<strong>Hello from SSR</strong>`;
110+
111+
<LiveProvider code={code} ssr>
112+
<LivePreview />
113+
</LiveProvider>;
114+
```
115+
116+
```jsx
117+
const code = `render(<strong>Hello from SSR</strong>)`;
118+
119+
<LiveProvider code={code} noInline ssr>
120+
<LivePreview />
121+
</LiveProvider>;
122+
```
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
import React from "react";
2+
import ReactDOMServer from "react-dom/server";
3+
import { act } from "@testing-library/react";
4+
import { hydrateRoot } from "react-dom/client";
5+
6+
import LiveError from "./LiveError";
7+
import LivePreview from "./LivePreview";
8+
import LiveProvider from "./LiveProvider";
9+
10+
describe("LiveProvider SSR", () => {
11+
it("keeps server rendering disabled by default", () => {
12+
const html = ReactDOMServer.renderToStaticMarkup(
13+
<LiveProvider code="() => { throw new Error('server crash') }">
14+
<LivePreview />
15+
</LiveProvider>,
16+
);
17+
18+
expect(html).toBe("<div></div>");
19+
});
20+
21+
it("renders inline previews during the initial server render", () => {
22+
const html = ReactDOMServer.renderToStaticMarkup(
23+
<LiveProvider code="<strong>Hello SSR!</strong>" ssr>
24+
<LivePreview />
25+
</LiveProvider>,
26+
);
27+
28+
expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
29+
});
30+
31+
it("renders noInline previews during the initial server render", () => {
32+
const html = ReactDOMServer.renderToStaticMarkup(
33+
<LiveProvider code="render(<strong>Hello SSR!</strong>)" noInline ssr>
34+
<LivePreview />
35+
</LiveProvider>,
36+
);
37+
38+
expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
39+
});
40+
41+
it("renders transformed code when transformCode is synchronous", () => {
42+
const transformCode = vi.fn((code) => `<strong>${code}</strong>`);
43+
const html = ReactDOMServer.renderToStaticMarkup(
44+
<LiveProvider code="Hello SSR!" transformCode={transformCode} ssr>
45+
<LivePreview />
46+
</LiveProvider>,
47+
);
48+
49+
expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
50+
expect(transformCode).toHaveBeenCalledOnce();
51+
});
52+
53+
it("defers the preview when transformCode resolves asynchronously", () => {
54+
const html = ReactDOMServer.renderToStaticMarkup(
55+
<LiveProvider
56+
code="Hello SSR!"
57+
transformCode={(code) => Promise.resolve(`<strong>${code}</strong>`)}
58+
ssr
59+
>
60+
<LivePreview />
61+
</LiveProvider>,
62+
);
63+
64+
expect(html).toBe("<div></div>");
65+
});
66+
67+
it("does not repeat a synchronous transform after hydration", async () => {
68+
const transformCode = vi.fn((code) => `<strong>${code}</strong>`);
69+
const preview = (
70+
<LiveProvider code="Hello SSR!" transformCode={transformCode} ssr>
71+
<LivePreview />
72+
</LiveProvider>
73+
);
74+
const container = document.createElement("div");
75+
container.innerHTML = ReactDOMServer.renderToString(preview);
76+
const onRecoverableError = vi.fn();
77+
78+
let root;
79+
await act(async () => {
80+
root = hydrateRoot(container, preview, { onRecoverableError });
81+
});
82+
83+
expect(container.innerHTML).toBe("<div><strong>Hello SSR!</strong></div>");
84+
expect(transformCode).toHaveBeenCalledTimes(2);
85+
expect(onRecoverableError).not.toHaveBeenCalled();
86+
87+
await act(async () => root.unmount());
88+
});
89+
90+
it("renders an asynchronous transform after hydration", async () => {
91+
const resolvers = [];
92+
const transformCode = vi.fn(
93+
() =>
94+
new Promise((resolve) => {
95+
resolvers.push(resolve);
96+
}),
97+
);
98+
const preview = (
99+
<LiveProvider code="Hello SSR!" transformCode={transformCode} ssr>
100+
<LivePreview />
101+
</LiveProvider>
102+
);
103+
const container = document.createElement("div");
104+
container.innerHTML = ReactDOMServer.renderToString(preview);
105+
const onRecoverableError = vi.fn();
106+
107+
let root;
108+
await act(async () => {
109+
root = hydrateRoot(container, preview, { onRecoverableError });
110+
});
111+
112+
expect(container.innerHTML).toBe("<div></div>");
113+
114+
await act(async () => {
115+
resolvers.forEach((resolve) => resolve("<strong>Hello SSR!</strong>"));
116+
});
117+
118+
expect(container.innerHTML).toBe("<div><strong>Hello SSR!</strong></div>");
119+
expect(onRecoverableError).not.toHaveBeenCalled();
120+
expect(transformCode).toHaveBeenCalledTimes(2);
121+
122+
await act(async () => root.unmount());
123+
});
124+
125+
it("transpiles later code updates after hydrating an SSR preview", async () => {
126+
const firstPreview = (
127+
<LiveProvider code="<strong>first</strong>" ssr>
128+
<LivePreview />
129+
</LiveProvider>
130+
);
131+
const container = document.createElement("div");
132+
container.innerHTML = ReactDOMServer.renderToString(firstPreview);
133+
134+
let root;
135+
await act(async () => {
136+
root = hydrateRoot(container, firstPreview);
137+
});
138+
139+
await act(async () => {
140+
root.render(
141+
<LiveProvider code="<strong>second</strong>" ssr>
142+
<LivePreview />
143+
</LiveProvider>,
144+
);
145+
});
146+
147+
expect(container.innerHTML).toBe("<div><strong>second</strong></div>");
148+
149+
await act(async () => root.unmount());
150+
});
151+
152+
it("renders transform errors during the initial server render", () => {
153+
const html = ReactDOMServer.renderToStaticMarkup(
154+
<LiveProvider
155+
code="Hello SSR!"
156+
transformCode={() => {
157+
throw new Error("Failed to transform");
158+
}}
159+
ssr
160+
>
161+
<LivePreview />
162+
<LiveError />
163+
</LiveProvider>,
164+
);
165+
166+
expect(html).toBe("<div></div><pre>Error: Failed to transform</pre>");
167+
});
168+
169+
it("renders noInline evaluation errors during the initial server render", () => {
170+
const html = ReactDOMServer.renderToStaticMarkup(
171+
<LiveProvider code="<strong>Hello SSR!</strong>" noInline ssr>
172+
<LivePreview />
173+
<LiveError />
174+
</LiveProvider>,
175+
);
176+
177+
expect(html).toContain("No-Inline evaluations must call `render`.");
178+
});
179+
});

‎packages/react-live/src/components/Live/LiveProvider.test.jsx‎

Lines changed: 35 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { render, screen } from "@testing-library/react";
1+
import { act, render, screen } from "@testing-library/react";
22
import userEvent from "@testing-library/user-event";
33

44
import LiveProvider from "./LiveProvider";
@@ -167,12 +167,15 @@ describe("errors", () => {
167167
*/
168168
describe("transformCode", () => {
169169
it("applies a synchronous transformCode function", async () => {
170+
const transformCode = vi.fn((code) => `render(<div>${code}</div>)`);
171+
170172
renderLive({
171173
code: "hello",
172174
noInline: true,
173-
transformCode: (code) => `render(<div>${code}</div>)`,
175+
transformCode,
174176
});
175177
expect(await screen.findByText("hello")).toBeDefined();
178+
expect(transformCode).toHaveBeenCalledOnce();
176179
});
177180

178181
it("applies an asynchronous transformCode function", async () => {
@@ -184,6 +187,36 @@ describe("transformCode", () => {
184187
expect(await screen.findByText("hello")).toBeDefined();
185188
});
186189

190+
it("ignores stale asynchronous transform results", async () => {
191+
const resolvers = new Map();
192+
const transformCode = (code) =>
193+
new Promise((resolve) => {
194+
resolvers.set(code, resolve);
195+
});
196+
const { rerender } = render(
197+
<LiveProvider code="first" transformCode={transformCode}>
198+
<LivePreview />
199+
</LiveProvider>,
200+
);
201+
202+
rerender(
203+
<LiveProvider code="second" transformCode={transformCode}>
204+
<LivePreview />
205+
</LiveProvider>,
206+
);
207+
208+
await act(async () => {
209+
resolvers.get("second")("<strong>second</strong>");
210+
});
211+
expect(screen.getByText("second")).toBeDefined();
212+
213+
await act(async () => {
214+
resolvers.get("first")("<strong>first</strong>");
215+
});
216+
expect(screen.queryByText("first")).toBeNull();
217+
expect(screen.getByText("second")).toBeDefined();
218+
});
219+
187220
it("catches errors from a synchronous transformCode function", async () => {
188221
renderLive({
189222
code: "hello",

0 commit comments

Comments
 (0)