Skip to content

Commit fa9e0c8

Browse files
committed
Fix LiveProvider SSR transpilation and docs
1 parent 0913756 commit fa9e0c8

5 files changed

Lines changed: 209 additions & 47 deletions

File tree

docs/api.md

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,16 @@ sidebar_position: 4
99
This component provides the `context` for all the other ones. It also transpiles the user’s code!
1010
It supports these props, while passing any others through to the `children`:
1111

12-
| Name | PropType | Description |
13-
| ------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14-
| code | `PropTypes.string` | The code that should be rendered, apart from the user’s edits |
15-
| scope | `PropTypes.object` | Accepts custom globals that the `code` can use |
16-
| 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}` |
17-
| transformCode | `PropTypes.func` | Accepts and returns the code to be transpiled, affording an opportunity to first transform it |
18-
| language | `PropTypes.string` | What language you're writing for correct syntax highlighting. (Default: `jsx`) |
19-
| enableTypeScript | `PropTypes.bool` | Enables TypeScript support in transpilation. (Default: `true`) |
20-
| disabled | `PropTypes.bool` | Disable editing on the `<LiveEditor />` (Default: `false`) |
21-
| theme | `PropTypes.object` | A `prism-react-renderer` theme object. See more [here](https://github.com/FormidableLabs/prism-react-renderer#theming) |
12+
| Name | PropType | Description |
13+
| ---------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
14+
| code | `PropTypes.string` | The code that should be rendered, apart from the user’s edits |
15+
| scope | `PropTypes.object` | Accepts custom globals that the `code` can use |
16+
| 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}` |
17+
| transformCode | `PropTypes.func` | Accepts and returns the code to be transpiled, affording an opportunity to first transform it. Synchronous transforms participate in the initial server render, while asynchronous transforms defer preview output until hydration. |
18+
| language | `PropTypes.string` | What language you're writing for correct syntax highlighting. (Default: `jsx`) |
19+
| enableTypeScript | `PropTypes.bool` | Enables TypeScript support in transpilation. (Default: `true`) |
20+
| disabled | `PropTypes.bool` | Disable editing on the `<LiveEditor />` (Default: `false`) |
21+
| theme | `PropTypes.object` | A `prism-react-renderer` theme object. See more [here](https://github.com/FormidableLabs/prism-react-renderer#theming) |
2222

2323
All subsequent components must be rendered inside a provider, since they communicate
2424
using one.
@@ -27,6 +27,8 @@ The `noInline` option kicks the Provider into a different mode, where you can wr
2727
code and nothing gets evaluated and mounted automatically. Your example will need to call `render`
2828
with valid JSX elements.
2929

30+
`LiveProvider` can also render the initial preview during SSR as long as the example can be evaluated synchronously. That includes the default inline mode and `noInline` examples that call `render(...)` during evaluation. If `transformCode` returns a Promise, the preview stays empty on the server and is filled in after hydration.
31+
3032
### `<LiveEditor />`
3133

3234
This component renders the editor that displays the code. It is a wrapper around [`react-simple-code-editor`](https://github.com/satya164/react-simple-code-editor) and the code highlighted using [`prism-react-renderer`](https://github.com/FormidableLabs/prism-react-renderer).

docs/usage.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,3 +90,31 @@ 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 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+
```jsx
107+
const code = `<strong>Hello from SSR</strong>`;
108+
109+
<LiveProvider code={code}>
110+
<LivePreview />
111+
</LiveProvider>;
112+
```
113+
114+
```jsx
115+
const code = `render(<strong>Hello from SSR</strong>)`;
116+
117+
<LiveProvider code={code} noInline>
118+
<LivePreview />
119+
</LiveProvider>;
120+
```
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import React from "react";
2+
import ReactDOMServer from "react-dom/server";
3+
4+
import LivePreview from "./LivePreview.tsx";
5+
import LiveProvider from "./LiveProvider.tsx";
6+
7+
describe("LiveProvider SSR", () => {
8+
it("renders inline previews during the initial server render", () => {
9+
const html = ReactDOMServer.renderToStaticMarkup(
10+
<LiveProvider code="<strong>Hello SSR!</strong>">
11+
<LivePreview />
12+
</LiveProvider>
13+
);
14+
15+
expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
16+
});
17+
18+
it("renders noInline previews during the initial server render", () => {
19+
const html = ReactDOMServer.renderToStaticMarkup(
20+
<LiveProvider code="render(<strong>Hello SSR!</strong>)" noInline>
21+
<LivePreview />
22+
</LiveProvider>
23+
);
24+
25+
expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
26+
});
27+
28+
it("renders transformed code when transformCode is synchronous", () => {
29+
const html = ReactDOMServer.renderToStaticMarkup(
30+
<LiveProvider
31+
code="Hello SSR!"
32+
transformCode={(code) => `<strong>${code}</strong>`}
33+
>
34+
<LivePreview />
35+
</LiveProvider>
36+
);
37+
38+
expect(html).toBe("<div><strong>Hello SSR!</strong></div>");
39+
});
40+
41+
it("defers the preview when transformCode resolves asynchronously", () => {
42+
const html = ReactDOMServer.renderToStaticMarkup(
43+
<LiveProvider
44+
code="Hello SSR!"
45+
transformCode={(code) => Promise.resolve(`<strong>${code}</strong>`)}
46+
>
47+
<LivePreview />
48+
</LiveProvider>
49+
);
50+
51+
expect(html).toBe("<div></div>");
52+
});
53+
});

packages/react-live/src/components/Live/LiveProvider.tsx

Lines changed: 113 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ type ProviderState = {
99
newCode?: string;
1010
};
1111

12+
type TransformResult = string | Promise<string>;
13+
1214
type Props = {
1315
code?: string;
1416
disabled?: boolean;
@@ -17,7 +19,96 @@ type Props = {
1719
noInline?: boolean;
1820
scope?: Record<string, unknown>;
1921
theme?: typeof themes.nightOwl;
20-
transformCode?(code: string): void;
22+
transformCode?(code: string): TransformResult;
23+
};
24+
25+
type TranspileOptions = Pick<
26+
Props,
27+
"enableTypeScript" | "noInline" | "scope" | "transformCode"
28+
>;
29+
30+
const DEFAULT_STATE: ProviderState = {
31+
error: undefined,
32+
element: undefined,
33+
};
34+
35+
const isPromiseLike = (value: TransformResult): value is Promise<string> => {
36+
return typeof (value as Promise<string>)?.then === "function";
37+
};
38+
39+
const getErrorState = (error: Error): ProviderState => ({
40+
error: error.toString(),
41+
element: undefined,
42+
});
43+
44+
const getTranspileInput = (
45+
code: string,
46+
{ scope, enableTypeScript = true }: TranspileOptions
47+
) => ({
48+
code,
49+
scope,
50+
enableTypeScript,
51+
});
52+
53+
const getPreviewState = (
54+
newCode: string,
55+
transformedCode: string,
56+
options: TranspileOptions,
57+
onError: (error: Error) => void
58+
): ProviderState => {
59+
if (typeof transformedCode !== "string") {
60+
throw new Error("Code failed to transform");
61+
}
62+
63+
const input = getTranspileInput(transformedCode, options);
64+
65+
if (options.noInline) {
66+
let nextState: ProviderState = {
67+
error: undefined,
68+
element: null,
69+
newCode,
70+
};
71+
72+
renderElementAsync(
73+
input,
74+
(element: ComponentType) => {
75+
nextState = { error: undefined, element, newCode };
76+
},
77+
(error: Error) => {
78+
nextState = getErrorState(error);
79+
},
80+
onError
81+
);
82+
83+
return nextState;
84+
}
85+
86+
return {
87+
error: undefined,
88+
element: generateElement(input, onError),
89+
newCode,
90+
};
91+
};
92+
93+
const getInitialState = (
94+
code: string,
95+
options: TranspileOptions,
96+
onError: (error: Error) => void
97+
): ProviderState => {
98+
try {
99+
const transformResult = options.transformCode
100+
? options.transformCode(code)
101+
: code;
102+
103+
if (isPromiseLike(transformResult)) {
104+
void transformResult.catch(() => undefined);
105+
return DEFAULT_STATE;
106+
}
107+
108+
return getPreviewState(code, transformResult, options, onError);
109+
} catch (error) {
110+
return getErrorState(error as Error);
111+
}
21112
};
22113

23114
function LiveProvider({
@@ -31,17 +122,29 @@ function LiveProvider({
31122
transformCode,
32123
noInline = false,
33124
}: PropsWithChildren<Props>) {
34-
const [state, setState] = useState<ProviderState>({
35-
error: undefined,
36-
element: undefined,
37-
});
125+
const [state, setState] = useState<ProviderState>(DEFAULT_STATE);
126+
127+
const options: TranspileOptions = {
128+
enableTypeScript,
129+
noInline,
130+
scope,
131+
transformCode,
132+
};
133+
134+
const onError = (error: Error) => setState(getErrorState(error));
135+
136+
const resolvedState =
137+
state.element === undefined &&
138+
state.error === undefined &&
139+
state.newCode === undefined
140+
? getInitialState(code, options, onError)
141+
: state;
38142

39143
async function transpileAsync(newCode: string) {
40144
const errorCallback = (error: Error) => {
41145
setState((previousState) => ({
42146
...previousState,
43-
error: error.toString(),
44-
element: undefined,
147+
...getErrorState(error),
45148
}));
46149
};
47150

@@ -55,30 +158,7 @@ function LiveProvider({
55158
const transformResult = transformCode ? transformCode(newCode) : newCode;
56159
try {
57160
const transformedCode = await Promise.resolve(transformResult);
58-
const renderElement = (element: ComponentType) =>
59-
setState({ error: undefined, element, newCode });
60-
61-
if (typeof transformedCode !== "string") {
62-
throw new Error("Code failed to transform");
63-
}
64-
65-
// Transpilation arguments
66-
const input = {
67-
code: transformedCode,
68-
scope,
69-
enableTypeScript,
70-
};
71-
72-
if (noInline) {
73-
setState((previousState) => ({
74-
...previousState,
75-
error: undefined,
76-
element: null,
77-
})); // Reset output for async (no inline) evaluation
78-
renderElementAsync(input, renderElement, errorCallback);
79-
} else {
80-
renderElement(generateElement(input, errorCallback));
81-
}
161+
setState(getPreviewState(newCode, transformedCode, options, onError));
82162
} catch (error) {
83163
return errorCallback(error as Error);
84164
}
@@ -88,11 +168,9 @@ function LiveProvider({
88168
}
89169
}
90170

91-
const onError = (error: Error) => setState({ error: error.toString() });
92-
93171
useEffect(() => {
94172
transpileAsync(code).catch(onError);
95-
}, [code, scope, noInline, transformCode]);
173+
}, [code, enableTypeScript, noInline, scope, transformCode]);
96174

97175
const onChange = (newCode: string) => {
98176
transpileAsync(newCode).catch(onError);
@@ -101,7 +179,7 @@ function LiveProvider({
101179
return (
102180
<LiveContext.Provider
103181
value={{
104-
...state,
182+
...resolvedState,
105183
code,
106184
language,
107185
theme,

packages/react-live/src/utils/transpile/index.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,14 +51,15 @@ export const generateElement = (
5151
export const renderElementAsync = (
5252
{ code = "", scope = {}, enableTypeScript = true }: GenerateOptions,
5353
resultCallback: (sender: ComponentType) => void,
54-
errorCallback: (error: Error) => void
54+
errorCallback: (error: Error) => void,
55+
renderErrorCallback: (error: Error) => void = errorCallback
5556
// eslint-disable-next-line consistent-return
5657
) => {
5758
const render = (element: ComponentType) => {
5859
if (typeof element === "undefined") {
5960
errorCallback(new SyntaxError("`render` must be called with valid JSX."));
6061
} else {
61-
resultCallback(errorBoundary(element, errorCallback));
62+
resultCallback(errorBoundary(element, renderErrorCallback));
6263
}
6364
};
6465

0 commit comments

Comments
 (0)