From eb0610dfa078611a8b84db606a1254dbe80adec6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tobias=20H=C3=B8egh?= Date: Wed, 25 May 2022 16:15:39 +0200 Subject: [PATCH] feat(SSR): add support for initial SSR render support --- README.md | 1 + package.json | 2 +- src/components/Live/LiveProvider.js | 57 +++- src/components/Live/LiveProvider.test.js | 317 +++++++++++++++++++---- typings/react-live.d.ts | 1 + yarn.lock | 68 +++++ 6 files changed, 389 insertions(+), 57 deletions(-) diff --git a/README.md b/README.md index 45a78f9f..db66cf8f 100644 --- a/README.md +++ b/README.md @@ -180,6 +180,7 @@ It supports these props, while passing any others through to the `children`: |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}` +|skipInitialRender|PropTypes.bool|Skip the initial render used for SSR (Default: `false`) |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`) |disabled|PropTypes.bool|Disable editing on the `` (Default: `false`) diff --git a/package.json b/package.json index 33e21e41..1445f2cb 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ "use-editable": "^2.3.3" }, "devDependencies": { + "@testing-library/react": "^12.1.5", "@babel/core": "^7.15.0", "@babel/eslint-parser": "^7.15.0", "@babel/plugin-proposal-class-properties": "^7.14.5", @@ -90,7 +91,6 @@ ], "jest": { "testEnvironment": "jsdom", - "resetMocks": true, "rootDir": "./src", "testURL": "http://localhost/" }, diff --git a/src/components/Live/LiveProvider.js b/src/components/Live/LiveProvider.js index 41534a00..c1186727 100644 --- a/src/components/Live/LiveProvider.js +++ b/src/components/Live/LiveProvider.js @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect, useState, useRef } from "react"; import PropTypes from "prop-types"; import LiveContext from "./LiveContext"; @@ -13,13 +13,58 @@ function LiveProvider({ scope, transformCode, noInline = false, + skipInitialRender = false, }) { - const [state, setState] = useState({ - error: undefined, - element: undefined, - }); + // avoid to render code twice when rendered initially (ssr) + const cache = useRef("initial"); + + // ssr render the code in sync + const [state, setState] = useState(() => transpileSync(code)); + + function transpileSync(code) { + const returnObject = { + element: undefined, + error: undefined, + }; + + if (!skipInitialRender) { + const renderElement = (element) => { + return (returnObject.element = element); + }; + const errorCallback = (error) => { + return (returnObject.error = error); + }; + + try { + const transformResult = transformCode ? transformCode(code) : code; + + // Transpilation arguments + const input = { + code: transformResult, + scope, + }; + + if (noInline) { + renderElementAsync(input, renderElement, errorCallback); + } else { + renderElement(generateElement(input, errorCallback)); + } + + cache.current = code; + } catch (e) { + errorCallback(e); + } + } + + return returnObject; + } function transpileAsync(newCode) { + if (cache.current === newCode) { + cache.current = "used"; // do not check for null or undefined, in case the new code is such + return Promise.resolve(); + } + const errorCallback = (error) => { setState({ error: error.toString(), element: undefined }); }; @@ -92,6 +137,7 @@ LiveProvider.propTypes = { language: PropTypes.string, noInline: PropTypes.bool, scope: PropTypes.object, + skipInitialRender: PropTypes.bool, theme: PropTypes.object, transformCode: PropTypes.func, }; @@ -99,6 +145,7 @@ LiveProvider.propTypes = { LiveProvider.defaultProps = { code: "", noInline: false, + skipInitialRender: false, language: "jsx", disabled: false, }; diff --git a/src/components/Live/LiveProvider.test.js b/src/components/Live/LiveProvider.test.js index 901df246..4ecc0e37 100644 --- a/src/components/Live/LiveProvider.test.js +++ b/src/components/Live/LiveProvider.test.js @@ -1,85 +1,300 @@ import React, { useContext } from "react"; import { act } from "react-dom/test-utils"; -import { renderElementAsync } from "../../utils/transpile"; -import { render } from "../../utils/test/renderer"; +import { renderElementAsync, generateElement } from "../../utils/transpile"; +import { render, screen } from "@testing-library/react"; import LiveProvider from "./LiveProvider"; import LiveContext from "./LiveContext"; -jest.mock("../../utils/transpile"); +jest.mock("../../utils/transpile", () => { + const orig = jest.requireActual("../../utils/transpile"); + return { + ...orig, + + // So we still can use/run these methods + generateElement: jest.fn().mockImplementation(orig.generateElement), + renderElementAsync: jest.fn().mockImplementation(orig.renderElementAsync), + }; +}); function waitAsync() { return act(() => new Promise((resolve) => setTimeout(resolve, 0))); } -it("applies a synchronous transformCode function", () => { - function transformCode(code) { - return `render(
${code}
)`; - } +function ErrorRenderer() { + const { error } = useContext(LiveContext); + return
{error?.message || error}
; +} - render(); +beforeEach(() => { + document.body.innerHTML = ""; + jest.clearAllMocks(); +}); - return waitAsync().then(() => { - expect(renderElementAsync).toHaveBeenCalledTimes(1); - expect(renderElementAsync.mock.calls[0][0].code).toBe( - "render(
hello
)" +describe("LiveProvider with skipInitialRender", () => { + it("applies a synchronous transformCode function", (done) => { + function transformCode(code) { + return `render(
${code}
)`; + } + + render( + ); + + expect(renderElementAsync).toHaveBeenCalledTimes(0); + + waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(1); + expect(renderElementAsync.mock.calls[0][0].code).toBe( + "render(
hello
)" + ); + + done(); + }); + }); + + it("applies an asynchronous transformCode function", (done) => { + function transformCode(code) { + return Promise.resolve(`render(
${code}
)`); + } + + render( + + ); + + expect(renderElementAsync).toHaveBeenCalledTimes(0); + + waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(1); + expect(renderElementAsync.mock.calls[0][0].code).toBe( + "render(
hello
)" + ); + + done(); + }); + }); + + it("catches errors from a synchronous transformCode function", (done) => { + function transformCode() { + throw new Error("testError"); + } + + render( + + + + ); + + waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(0); + + const handledErrorWrapper = screen.getByTestId("handledError"); + expect(handledErrorWrapper.textContent).toBe("Error: testError"); + + done(); + }); + }); + + it("catches errors from an asynchronous transformCode function", (done) => { + function transformCode() { + return Promise.reject(new Error("testError")); + } + + render( + + + + ); + + waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(0); + + const handledErrorWrapper = screen.getByTestId("handledError"); + expect(handledErrorWrapper.textContent).toBe("Error: testError"); + + done(); + }); + }); + + it("will skip ssr render", (done) => { + function transformCode(code) { + return `render(
${code}
)`; + } + + render( + + ); + + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(0); + + waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(1); + expect(renderElementAsync.mock.calls[0][0].code).toBe( + "render(
hello
)" + ); + + done(); + }); }); }); -it("applies an asynchronous transformCode function", () => { - function transformCode(code) { - return Promise.resolve(`render(
${code}
)`); - } +describe("LiveProvider with ssr support", () => { + it("will apply synchronous transformCode function on initial render", (done) => { + function transformCode(code) { + return `
${code}
`; + } + + render( + + ); + + function assert() { + expect(generateElement).toHaveBeenCalledTimes(1); + expect(generateElement.mock.calls[0][0].code).toBe("
hello
"); + expect(renderElementAsync).toHaveBeenCalledTimes(0); + } + + assert(); + + waitAsync().then(() => { + assert(); + done(); + }); + }); + + it("will apply asynchronous transformCode function on initial render", (done) => { + function transformCode(code) { + return `render(
${code}
)`; + } + + render( + + ); + + function assert() { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(1); + expect(renderElementAsync.mock.calls[0][0].code).toBe( + "render(
hello
)" + ); + } + + assert(); + + waitAsync().then(() => { + assert(); + done(); + }); + }); - render(); + it("will rerender on property changes while supporting ssr", (done) => { + const { rerender } = render( + `render(
${code}
)`} + /> + ); - return waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(0); expect(renderElementAsync).toHaveBeenCalledTimes(1); expect(renderElementAsync.mock.calls[0][0].code).toBe( "render(
hello
)" ); - }); -}); -function ErrorRenderer() { - const { error } = useContext(LiveContext); - return
{error?.message}
; -} + rerender( + `render(
${code}
)`} + /> + ); -it("catches errors from a synchronous transformCode function", () => { - function transformCode() { - throw new Error("testError"); - } + waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(2); + expect(renderElementAsync.mock.calls[1][0].code).toBe( + "render(
changed code
)" + ); - const wrapper = render( - - - - ); + rerender( + `
${code}
`} + /> + ); - return waitAsync().then(() => { - expect(renderElementAsync).not.toHaveBeenCalled(); + waitAsync().then(() => { + expect(generateElement).toHaveBeenCalledTimes(1); + expect(generateElement.mock.calls[0][0].code).toBe( + "
changed code to inline
" + ); + expect(renderElementAsync).toHaveBeenCalledTimes(2); - const handledErrorWrapper = wrapper.find('[data-testid="handledError"]'); - expect(handledErrorWrapper.text()).toBe("testError"); + done(); + }); + }); }); -}); -it("catches errors from an asynchronous transformCode function", () => { - function transformCode() { - return Promise.reject(new Error("testError")); - } + it("catches errors from a synchronous transformCode function", (done) => { + function transformCode() { + throw new Error("testError"); + } + + render( + + + + ); + + function assert() { + expect(generateElement).toHaveBeenCalledTimes(0); + expect(renderElementAsync).toHaveBeenCalledTimes(0); - const wrapper = render( - - - - ); + expect(screen.getByTestId("handledError").textContent).toBe( + "Error: testError" + ); + } - return waitAsync().then(() => { - expect(renderElementAsync).not.toHaveBeenCalled(); + assert(); - const handledErrorWrapper = wrapper.find('[data-testid="handledError"]'); - expect(handledErrorWrapper.text()).toBe("testError"); + waitAsync().then(() => { + assert(); + done(); + }); }); }); diff --git a/typings/react-live.d.ts b/typings/react-live.d.ts index 0e57a27f..25061381 100644 --- a/typings/react-live.d.ts +++ b/typings/react-live.d.ts @@ -13,6 +13,7 @@ export type LiveProviderProps = Omit & { scope?: { [key: string]: any }; code?: string; noInline?: boolean; + skipInitialRender?: boolean; transformCode?: (code: string) => (string | Promise); language?: Language; disabled?: boolean; diff --git a/yarn.lock b/yarn.lock index 51d4db6d..7a48044c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2369,11 +2369,39 @@ resolve-from "^5.0.0" store2 "^2.12.0" +"@testing-library/dom@^8.0.0": + version "8.13.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.13.0.tgz#bc00bdd64c7d8b40841e27a70211399ad3af46f5" + integrity sha512-9VHgfIatKNXQNaZTtLnalIy0jNZzY35a4S3oi08YAt9Hv1VsfZ/DfA45lM8D/UhtHBGJ4/lGwp0PZkVndRkoOQ== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^5.0.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.4.4" + pretty-format "^27.0.2" + +"@testing-library/react@^12.1.5": + version "12.1.5" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-12.1.5.tgz#bb248f72f02a5ac9d949dea07279095fa577963b" + integrity sha512-OfTXCJUFgjd/digLUuPxa0+/3ZxsQmE7ub9kcbW/wi96Bh3o/p5vrETcBGfP17NWPGqeYYl5LTRpwyGoMC4ysg== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.0.0" + "@types/react-dom" "<18.0.0" + "@tootallnate/once@1": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@types/aria-query@^4.2.0": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" + integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14": version "7.1.15" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.15.tgz#2ccfb1ad55a02c83f8e0ad327cbc332f55eb1024" @@ -2565,6 +2593,13 @@ resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== +"@types/react-dom@<18.0.0": + version "17.0.17" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.17.tgz#2e3743277a793a96a99f1bf87614598289da68a1" + integrity sha512-VjnqEmqGnasQKV0CWLevqMTXBYG9GbwuE6x3VetERLh0cq2LTptFE73MrQi2S7GkKXCf2GgwItB/melLnxfnsg== + dependencies: + "@types/react" "^17" + "@types/react-syntax-highlighter@11.0.5": version "11.0.5" resolved "https://registry.yarnpkg.com/@types/react-syntax-highlighter/-/react-syntax-highlighter-11.0.5.tgz#0d546261b4021e1f9d85b50401c0a42acb106087" @@ -2581,6 +2616,15 @@ "@types/scheduler" "*" csstype "^3.0.2" +"@types/react@^17": + version "17.0.45" + resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.45.tgz#9b3d5b661fd26365fefef0e766a1c6c30ccf7b3f" + integrity sha512-YfhQ22Lah2e3CHPsb93tRwIGNiSwkuz1/blk4e6QrWS0jQzCSNbGLtOEYhPg02W0yGTTmpajp7dCTbBAMN3qsg== + dependencies: + "@types/prop-types" "*" + "@types/scheduler" "*" + csstype "^3.0.2" + "@types/react@^17.0.38": version "17.0.38" resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.38.tgz#f24249fefd89357d5fa71f739a686b8d7c7202bd" @@ -3104,6 +3148,11 @@ argparse@^2.0.1: resolved "https://registry.yarnpkg.com/argparse/-/argparse-2.0.1.tgz#246f50f3ca78a3240f6c997e8a9bd1eac49e4b38" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== +aria-query@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c" + integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/arr-diff/-/arr-diff-4.0.0.tgz#d6461074febfec71e7e15235761a329a5dc7c520" @@ -4790,6 +4839,11 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dom-accessibility-api@^0.5.9: + version "0.5.14" + resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56" + integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg== + dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.yarnpkg.com/dom-converter/-/dom-converter-0.2.0.tgz#6721a9daee2e293682955b6afe416771627bb768" @@ -7959,6 +8013,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + integrity sha1-wNjq82BZ9wV5bh40SBHPTEmNOiY= + magic-string@^0.25.7: version "0.25.7" resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.25.7.tgz#3f497d6fd34c669c6798dcb821f2ef31f5445051" @@ -9474,6 +9533,15 @@ pretty-error@^2.1.1: lodash "^4.17.20" renderkid "^2.0.4" +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + pretty-format@^27.0.6: version "27.0.6" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.0.6.tgz#ab770c47b2c6f893a21aefc57b75da63ef49a11f"