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
22 changes: 12 additions & 10 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,16 @@ sidebar_position: 4
This component provides the `context` for all the other ones. It also transpiles the user’s code!
It supports these props, while passing any others through to the `children`:

| Name | PropType | Description |
| ------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| code | `PropTypes.string` | The code that should be rendered, apart from the user’s edits |
| 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 |
| 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`) |
| theme | `PropTypes.object` | A `prism-react-renderer` theme object. See more [here](https://github.com/FormidableLabs/prism-react-renderer#theming) |
| Name | PropType | Description |
| ---------------- | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| code | `PropTypes.string` | The code that should be rendered, apart from the user’s edits |
| 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. Synchronous transforms participate in the initial server render, while asynchronous transforms defer preview output until hydration. |
| 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`) |
| theme | `PropTypes.object` | A `prism-react-renderer` theme object. See more [here](https://github.com/FormidableLabs/prism-react-renderer#theming) |

All subsequent components must be rendered inside a provider, since they communicate
using one.
Expand All @@ -27,6 +27,8 @@ 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.

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

### `<LiveEditor />`

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).
Expand Down
28 changes: 28 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -90,3 +90,31 @@ This means that while you may be used to destructuring `useState` when importing
);
};
```

### Server rendering

`LiveProvider` can render the initial preview during SSR when 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.

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

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

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

<LiveProvider code={code} noInline>
<LivePreview />
</LiveProvider>;
```
53 changes: 53 additions & 0 deletions packages/react-live/src/components/Live/LiveProvider.ssr.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from "react";
import ReactDOMServer from "react-dom/server";

import LivePreview from "./LivePreview.tsx";
import LiveProvider from "./LiveProvider.tsx";

describe("LiveProvider SSR", () => {
it("renders inline previews during the initial server render", () => {
const html = ReactDOMServer.renderToStaticMarkup(
<LiveProvider code="<strong>Hello SSR!</strong>">
<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>
<LivePreview />
</LiveProvider>
);

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

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

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

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

expect(html).toBe("<div></div>");
});
});
148 changes: 113 additions & 35 deletions packages/react-live/src/components/Live/LiveProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ type ProviderState = {
newCode?: string;
};

type TransformResult = string | Promise<string>;

type Props = {
code?: string;
disabled?: boolean;
Expand All @@ -17,7 +19,96 @@ type Props = {
noInline?: boolean;
scope?: Record<string, unknown>;
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 isPromiseLike = (value: TransformResult): value is Promise<string> => {
return typeof (value as Promise<string>)?.then === "function";
};

const getErrorState = (error: Error): ProviderState => ({
error: error.toString(),
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 getInitialState = (
code: string,
options: TranspileOptions,
onError: (error: Error) => void
): ProviderState => {
try {
const transformResult = options.transformCode
? options.transformCode(code)
: code;

if (isPromiseLike(transformResult)) {
void transformResult.catch(() => undefined);
return DEFAULT_STATE;
}

return getPreviewState(code, transformResult, options, onError);
} catch (error) {
return getErrorState(error as Error);
}
};

function LiveProvider({
Expand All @@ -31,17 +122,29 @@ function LiveProvider({
transformCode,
noInline = false,
}: PropsWithChildren<Props>) {
const [state, setState] = useState<ProviderState>({
error: undefined,
element: undefined,
});
const [state, setState] = useState<ProviderState>(DEFAULT_STATE);

const options: TranspileOptions = {
enableTypeScript,
noInline,
scope,
transformCode,
};

const onError = (error: Error) => setState(getErrorState(error));

const resolvedState =
state.element === undefined &&
state.error === undefined &&
state.newCode === undefined
? getInitialState(code, options, onError)
: state;

async function transpileAsync(newCode: string) {
const errorCallback = (error: Error) => {
setState((previousState) => ({
...previousState,
error: error.toString(),
element: undefined,
...getErrorState(error),
}));
};

Expand All @@ -55,30 +158,7 @@ function LiveProvider({
const transformResult = transformCode ? transformCode(newCode) : newCode;
try {
const transformedCode = await Promise.resolve(transformResult);
const renderElement = (element: ComponentType) =>
setState({ error: undefined, element, newCode });

if (typeof transformedCode !== "string") {
throw new Error("Code failed to transform");
}

// Transpilation arguments
const input = {
code: transformedCode,
scope,
enableTypeScript,
};

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));
}
setState(getPreviewState(newCode, transformedCode, options, onError));
} catch (error) {
return errorCallback(error as Error);
}
Expand All @@ -88,11 +168,9 @@ function LiveProvider({
}
}

const onError = (error: Error) => setState({ error: error.toString() });

useEffect(() => {
transpileAsync(code).catch(onError);
}, [code, scope, noInline, transformCode]);
}, [code, enableTypeScript, noInline, scope, transformCode]);

const onChange = (newCode: string) => {
transpileAsync(newCode).catch(onError);
Expand All @@ -101,7 +179,7 @@ function LiveProvider({
return (
<LiveContext.Provider
value={{
...state,
...resolvedState,
code,
language,
theme,
Expand Down
5 changes: 3 additions & 2 deletions packages/react-live/src/utils/transpile/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,14 +51,15 @@ export const generateElement = (
export const renderElementAsync = (
{ code = "", scope = {}, enableTypeScript = true }: GenerateOptions,
resultCallback: (sender: ComponentType) => void,
errorCallback: (error: Error) => 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));
}
};

Expand Down