Repository navigation
feat(LiveProvider): add opt-in server-rendered previews #414
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
tujoworker
wants to merge
4
commits into
FormidableLabs:master
Choose a base branch
from
tujoworker:feat/ssr
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+685
−77
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
19617bd
Add opt-in LiveProvider SSR support
tujoworker 29420e6
Snapshot server-rendered and hydrated preview HTML
tujoworker b4a6e4f
Simplify async preview test setup
tujoworker dae0d9a
Preserve locked tooling in React compatibility checks
tujoworker File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); |
265 changes: 265 additions & 0 deletions
265
packages/react-live/src/components/Live/LiveProvider.ssr.test.jsx
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,265 @@ | ||
| 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.innerHTML).toMatchSnapshot(); | ||
| 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(() => { | ||
| root = hydrateRoot(container, preview); | ||
| }); | ||
| expect(container.innerHTML).toMatchSnapshot(); | ||
| 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(() => { | ||
| root = hydrateRoot(container, preview); | ||
| }); | ||
| expect(container.innerHTML).toMatchSnapshot(); | ||
| await act(async () => { | ||
| root.render( | ||
| <LiveProvider | ||
| code="fixed" | ||
| ssr | ||
| transformCode={() => Promise.resolve("<strong>fixed</strong>")} | ||
| > | ||
| <LivePreview /> | ||
| <LiveError /> | ||
| </LiveProvider>, | ||
| ); | ||
| }); | ||
| expect(container.innerHTML).toMatchSnapshot(); | ||
| await act(() => root.unmount()); | ||
| }, | ||
| ); | ||
|
|
||
| it("keeps server rendering disabled by default", () => { | ||
| const html = ReactDOMServer.renderToStaticMarkup( | ||
| <LiveProvider code="() => { throw new Error('server crash') }"> | ||
| <LivePreview /> | ||
| </LiveProvider>, | ||
| ); | ||
|
|
||
| expect(html).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| it("renders inline previews during the initial server render", () => { | ||
| const html = ReactDOMServer.renderToStaticMarkup( | ||
| <LiveProvider code="<strong>Hello SSR!</strong>" ssr> | ||
| <LivePreview /> | ||
| </LiveProvider>, | ||
| ); | ||
|
|
||
| expect(html).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| 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).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| 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).toMatchSnapshot(); | ||
| 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).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| 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(() => { | ||
| root = hydrateRoot(container, preview, { onRecoverableError }); | ||
| }); | ||
|
|
||
| expect(container.innerHTML).toMatchSnapshot(); | ||
| expect(transformCode).toHaveBeenCalledTimes(2); | ||
| expect(onRecoverableError).not.toHaveBeenCalled(); | ||
|
|
||
| await act(() => root.unmount()); | ||
| }); | ||
|
|
||
| it.each([false, true])( | ||
| "renders an async transform after hydration (StrictMode: %s)", | ||
| async (strict) => { | ||
| const resolvers = []; | ||
| const transformCode = vi.fn(() => { | ||
| const { promise, resolve } = Promise.withResolvers(); | ||
| resolvers.push(resolve); | ||
| return promise; | ||
| }); | ||
| 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(() => { | ||
| root = hydrateRoot(container, preview, { onRecoverableError }); | ||
| }); | ||
|
|
||
| expect(container.innerHTML).toMatchSnapshot(); | ||
|
|
||
| await act(() => { | ||
| resolvers.forEach((resolve) => resolve("<strong>Hello SSR!</strong>")); | ||
| }); | ||
|
|
||
| expect(container.innerHTML).toMatchSnapshot(); | ||
| expect(onRecoverableError).not.toHaveBeenCalled(); | ||
| expect(transformCode).toHaveBeenCalledTimes(strict ? 3 : 2); | ||
|
|
||
| await act(() => 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(() => { | ||
| root = hydrateRoot(container, firstPreview); | ||
| }); | ||
|
|
||
| await act(async () => { | ||
| root.render( | ||
| <LiveProvider code="<strong>second</strong>" ssr> | ||
| <LivePreview /> | ||
| </LiveProvider>, | ||
| ); | ||
| }); | ||
|
|
||
| expect(container.innerHTML).toMatchSnapshot(); | ||
|
|
||
| await act(() => 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).toMatchSnapshot(); | ||
| }); | ||
|
|
||
| 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).toMatchSnapshot(); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should me
npm ci