diff --git a/.codesandbox/ci.json b/.codesandbox/ci.json index 8678701e7..fabf52bc2 100644 --- a/.codesandbox/ci.json +++ b/.codesandbox/ci.json @@ -1,5 +1,5 @@ { "packages": ["sandpack-client", "sandpack-react"], - "sandboxes": ["sowx8r"], + "sandboxes": ["sowx8r", "909l3f"], "node": "16" } diff --git a/sandpack-client/package.json b/sandpack-client/package.json index 6cc10e37d..b22535c14 100644 --- a/sandpack-client/package.json +++ b/sandpack-client/package.json @@ -47,11 +47,10 @@ ], "dependencies": { "@codesandbox/nodebox": "0.1.0", - "lodash.isequal": "^4.5.0", + "dequal": "^2.0.2", "outvariant": "1.3.0" }, "devDependencies": { - "@types/lodash.isequal": "^4.5.2", "@types/node": "^9.3.0", "console-feed": "3.3.0", "del": "^6.0.0", diff --git a/sandpack-client/src/clients/base.ts b/sandpack-client/src/clients/base.ts index 3ab17316a..366bd8c72 100644 --- a/sandpack-client/src/clients/base.ts +++ b/sandpack-client/src/clients/base.ts @@ -1,4 +1,4 @@ -import isEqual from "lodash.isequal"; +import { dequal as deepEqual } from "dequal"; import type { ClientOptions, @@ -35,7 +35,7 @@ export class SandpackClient { * Clients handles */ public updateOptions(options: ClientOptions): void { - if (!isEqual(this.options, options)) { + if (!deepEqual(this.options, options)) { this.options = options; this.updateSandbox(); } diff --git a/sandpack-client/src/clients/runtime/index.ts b/sandpack-client/src/clients/runtime/index.ts index bd4154ec5..44365ad7e 100644 --- a/sandpack-client/src/clients/runtime/index.ts +++ b/sandpack-client/src/clients/runtime/index.ts @@ -1,4 +1,4 @@ -import isEqual from "lodash.isequal"; +import { dequal as deepEqual } from "dequal"; import type { SandpackMessage } from "../.."; import { nullthrows } from "../.."; @@ -163,7 +163,7 @@ export class SandpackRuntime extends SandpackClient { } updateOptions(options: ClientOptions): void { - if (!isEqual(this.options, options)) { + if (!deepEqual(this.options, options)) { this.options = options; this.updateSandbox(); } diff --git a/sandpack-react/package.json b/sandpack-react/package.json index 7e0954d00..3650886ff 100644 --- a/sandpack-react/package.json +++ b/sandpack-react/package.json @@ -35,6 +35,7 @@ "README.md" ], "dependencies": { + "use-deep-compare-effect": "1.8.1", "@code-hike/classer": "^0.0.0-aa6efee", "@codemirror/autocomplete": "^6.4.0", "@codemirror/commands": "^6.1.3", @@ -51,7 +52,7 @@ "ansi-to-react": "6.1.6", "clean-set": "^1.1.2", "codesandbox-import-util-types": "^2.2.3", - "lodash.isequal": "^4.5.0", + "dequal": "^2.0.2", "lz-string": "^1.4.4", "react-devtools-inline": "4.4.0", "react-is": "^17.0.2" @@ -66,7 +67,6 @@ "@testing-library/react-hooks": "8.0.1", "@types/fs-extra": "^5.0.4", "@types/glob": "^5.0.35", - "@types/lodash.isequal": "^4.5.2", "@types/lz-string": "^1.3.34", "@types/node": "^9.4.6", "@types/react": "^18.0.15", diff --git a/sandpack-react/src/Playground.stories.tsx b/sandpack-react/src/Playground.stories.tsx index d42734b9f..19a211578 100644 --- a/sandpack-react/src/Playground.stories.tsx +++ b/sandpack-react/src/Playground.stories.tsx @@ -1,352 +1,31 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import * as themes from "@codesandbox/sandpack-themes"; -import React, { useState } from "react"; +import React from "react"; -import type { CodeEditorProps } from "./components/CodeEditor"; import { Sandpack } from "./presets"; -import { SANDBOX_TEMPLATES } from "./templates"; - -import { - SandpackProvider, - SandpackCodeEditor, - SandpackPreview, - SandpackLayout, - SandpackFileExplorer, - SandpackConsole, - SandpackTests, -} from "./"; export default { title: "Intro/Playground", }; -export const Resizable = (): JSX.Element => { +export const Basic: React.FC = () => { + const [text, setText] = React.useState("world"); return ( <> -
- -
-
-
- Modern building architecture -
-
-
- Company retreats -
- - Incredible accommodation for your team - -

- Looking to take your team away on a retreat to enjoy awesome food - and take in some sunshine? We have a list of places to do just that. -

- - -
-
-
-
- ); -}`, - }} - options={{ - externalResources: ["https://cdn.tailwindcss.com"], - showLineNumbers: true, - showConsole: false, - showConsoleButton: true, - resizablePanels: true, - editorHeight: 700, - }} - template="react" - /> - - - ); -}; - -export const Main = (): JSX.Element => { - const [config, setConfig] = useState({ - Components: { - Preview: true, - Editor: true, - FileExplorer: true, - Console: true, - Tests: true, - }, - Options: { - showTabs: true, - showLineNumbers: true, - showInlineErrors: true, - closableTabs: true, - wrapContent: false, - readOnly: false, - showReadOnly: true, - showNavigator: true, - showRefreshButton: true, - consoleShowHeader: true, - showConsoleButton: true, - showConsole: true, - }, - Template: "exhaustedFilesTests" as const, - Theme: "light", - }); - - const update = (key: any, value: any): void => { - setConfig((prev) => ({ ...prev, [key]: value })); - }; - - const toggle = (key: any, value: any): void => { - setConfig((prev) => { - return { - ...prev, - [key]: { ...prev[key], [value]: !prev[key][value] }, - }; - }); - }; - - const codeEditorOptions: CodeEditorProps = { - showTabs: config.Options.showTabs, - showLineNumbers: config.Options.showLineNumbers, - showInlineErrors: config.Options.showInlineErrors, - wrapContent: config.Options.wrapContent, - closableTabs: config.Options.closableTabs, - readOnly: config.Options.readOnly, - showReadOnly: config.Options.showReadOnly, - }; - - return ( -
-
- {Object.entries(config).map(([key, value]) => { - if (typeof value === "string") { - if (key === "Template") { - return ( -
-

Template

- -
- ); - } - - if (key === "Theme") { - return ( -
-

Themes

- -
- ); - } - - return value; - } - - return ( - <> -

{key}

- {Object.entries(value).map(([prop, propValue]) => { - return ( -

- -

- ); - })} - - ); - })} -
- -
- - -
- {config.Components.FileExplorer && } - {config.Components.Editor && ( - - )} - {config.Components.Preview && ( - - )} - - {config.Components.Console && ( - - )} - {config.Components.Tests && } -
-
-
- -
- - -
-
- ); -}; - -const defaultTemplate = SANDBOX_TEMPLATES["react-ts"]; - -const exhaustedFilesTests = { - ...defaultTemplate, - - files: { - "/src/index.tsx": defaultTemplate.files["/index.tsx"], - "/src/App.tsx": `console.log("Hello world");\n\n${defaultTemplate.files["/App.tsx"].code}`, - "/src/App.test.tsx": `import '@testing-library/jest-dom'; -import React from 'react'; -import { render, screen } from '@testing-library/react'; -import App from './App'; - -test('renders welcome message', () => { - render(); - expect(screen.getByText('Hello world')).toBeInTheDocument(); -});`, - "/src/styles.css": defaultTemplate.files["/styles.css"], - "/package.json": JSON.stringify({ - dependencies: { - react: "^18.0.0", - "react-dom": "^18.0.0", - "react-scripts": "^4.0.0", - "@testing-library/react": "^13.3.0", - "@testing-library/jest-dom": "^5.16.5", - }, - devDependencies: { - "@types/react": "^18.0.0", - "@types/react-dom": "^18.0.0", - typescript: "^4.0.0", - }, - main: "/add.ts", - }), - }, -}; - -export const BasicConsole: React.FC = () => { - const files = { - "/index.js": `const helloWorld = ""; - -console.log("foo");`, - - "/.eslintrc.js": `module.exports = { - rules: { - "no-unused-vars": "error", - "no-console": "error", - }, - parserOptions: { - ecmaVersion: 'latest', - sourceType: 'module' - }, + setText(target.value)} + /> + Hello ${text} } `, - - "/package.json": JSON.stringify({ - devDependencies: { - eslint: "^8.0.1", - }, - scripts: { start: "eslint index.js" }, - }), - }; - - return ( - - - - - - + }} + template="react" + theme={themes.sandpackDark} + /> + ); }; - -export const Basic: React.FC = () => { - return ; -}; diff --git a/sandpack-react/src/components/Console/Console.stories.tsx b/sandpack-react/src/components/Console/Console.stories.tsx index 598334e76..81fc277fc 100644 --- a/sandpack-react/src/components/Console/Console.stories.tsx +++ b/sandpack-react/src/components/Console/Console.stories.tsx @@ -3,7 +3,8 @@ import React from "react"; import { SandpackCodeEditor, SandpackPreview } from ".."; import { SandpackProvider, SandpackLayout, Sandpack } from "../.."; -import { SandpackConsole, SandpackConsoleRef } from "./SandpackConsole"; +import type { SandpackConsoleRef } from "./SandpackConsole"; +import { SandpackConsole } from "./SandpackConsole"; export default { title: "components/Console", diff --git a/sandpack-react/src/contexts/utils/useAppState.ts b/sandpack-react/src/contexts/utils/useAppState.ts index 7d171764b..edba3dec8 100644 --- a/sandpack-react/src/contexts/utils/useAppState.ts +++ b/sandpack-react/src/contexts/utils/useAppState.ts @@ -1,5 +1,5 @@ import type { SandpackBundlerFiles } from "@codesandbox/sandpack-client"; -import isEqual from "lodash.isequal"; +import { dequal as deepEqual } from "dequal"; import { useState } from "react"; import type { SandpackProviderProps } from "../.."; @@ -20,7 +20,7 @@ export const useAppState: UseAppState = (props, files) => { }); const originalStateFromProps = getSandpackStateFromProps(props); - const editorState = isEqual(originalStateFromProps.files, files) + const editorState = deepEqual(originalStateFromProps.files, files) ? "pristine" : "dirty"; diff --git a/sandpack-react/src/contexts/utils/useClient.ts b/sandpack-react/src/contexts/utils/useClient.ts index 95ccc1a47..55f58879b 100644 --- a/sandpack-react/src/contexts/utils/useClient.ts +++ b/sandpack-react/src/contexts/utils/useClient.ts @@ -65,16 +65,19 @@ type UseClient = ( filesState: FilesState ) => [SandpackConfigState, UseClientOperations]; -export const useClient: UseClient = (props, filesState) => { - const initModeFromProps = props.options?.initMode || "lazy"; +export const useClient: UseClient = ({ options, customSetup }, filesState) => { + options ??= {}; + customSetup ??= {}; + + const initModeFromProps = options?.initMode || "lazy"; const [state, setState] = useState({ - startRoute: props.options?.startRoute, + startRoute: options?.startRoute, bundlerState: undefined, error: null, initMode: initModeFromProps, reactDevTools: undefined, - status: props.options?.autorun ?? true ? "initial" : "idle", + status: options?.autorun ?? true ? "initial" : "idle", }); /** @@ -106,7 +109,10 @@ export const useClient: UseClient = (props, filesState) => { iframe: HTMLIFrameElement, clientId: string ): Promise => { - const timeOut = props.options?.bundlerTimeOut ?? BUNDLER_TIMEOUT; + options ??= {}; + customSetup ??= {}; + + const timeOut = options?.bundlerTimeOut ?? BUNDLER_TIMEOUT; if (timeoutHook.current) { clearTimeout(timeoutHook.current); @@ -123,17 +129,17 @@ export const useClient: UseClient = (props, filesState) => { template: filesState.environment, }, { - externalResources: props.options?.externalResources, - bundlerURL: props.options?.bundlerURL, - startRoute: props.options?.startRoute, - fileResolver: props.options?.fileResolver, - skipEval: props.options?.skipEval ?? false, - logLevel: props.options?.logLevel, + externalResources: options.externalResources, + bundlerURL: options.bundlerURL, + startRoute: options.startRoute, + fileResolver: options.fileResolver, + skipEval: options.skipEval ?? false, + logLevel: options.logLevel, showOpenInCodeSandbox: false, showErrorScreen: errorScreenRegisteredRef.current, showLoadingScreen: loadingScreenRegisteredRef.current, reactDevTools: state.reactDevTools, - customNpmRegistries: props.customSetup?.npmRegistries?.map( + customNpmRegistries: customSetup.npmRegistries?.map( (config) => ({ ...config, @@ -187,18 +193,7 @@ export const useClient: UseClient = (props, filesState) => { return client; }, - [ - filesState.environment, - filesState.files, - props.customSetup?.npmRegistries, - props.options?.bundlerURL, - props.options?.externalResources, - props.options?.fileResolver, - props.options?.logLevel, - props.options?.skipEval, - props.options?.startRoute, - state.reactDevTools, - ] + [filesState.environment, filesState.files, state.reactDevTools] ); const unregisterAllClients = useCallback((): void => { @@ -222,13 +217,13 @@ export const useClient: UseClient = (props, filesState) => { }, [createClient]); const initializeSandpackIframe = useCallback((): void => { - const autorun = props.options?.autorun ?? true; + const autorun = options?.autorun ?? true; if (!autorun) { return; } - const observerOptions = props.options?.initModeObserverOptions ?? { + const observerOptions = options?.initModeObserverOptions ?? { rootMargin: `1000px 0px`, }; @@ -278,23 +273,23 @@ export const useClient: UseClient = (props, filesState) => { ); } }, [ - props.options?.autorun, - props.options?.initModeObserverOptions, + options?.autorun, + options?.initModeObserverOptions, runSandpack, state.initMode, unregisterAllClients, ]); - const registerBundler = async ( - iframe: HTMLIFrameElement, - clientId: string - ): Promise => { - if (state.status === "running") { - clients.current[clientId] = await createClient(iframe, clientId); - } else { - preregisteredIframes.current[clientId] = iframe; - } - }; + const registerBundler = useCallback( + async (iframe: HTMLIFrameElement, clientId: string): Promise => { + if (state.status === "running") { + clients.current[clientId] = await createClient(iframe, clientId); + } else { + preregisteredIframes.current[clientId] = iframe; + } + }, + [createClient, state.status] + ); const unregisterBundler = (clientId: string): void => { const client = clients.current[clientId]; @@ -354,56 +349,8 @@ export const useClient: UseClient = (props, filesState) => { setState((prev) => ({ ...prev, reactDevTools: value })); }; - const recompileMode = props.options?.recompileMode ?? "delayed"; - const recompileDelay = props.options?.recompileDelay ?? 500; - const updateClients = useCallback((): void => { - if (state.status !== "running" || !filesState.shouldUpdatePreview) { - return; - } - - /** - * When the environment changes, Sandpack needs to make sure - * to create a new client and the proper bundler - */ - if (prevEnvironment.current !== filesState.environment) { - prevEnvironment.current = filesState.environment; - - Object.entries(clients.current).forEach(([key, client]) => { - registerBundler(client.iframe, key); - }); - } - - if (recompileMode === "immediate") { - Object.values(clients.current).forEach((client) => { - client.updateSandbox({ - files: filesState.files, - template: filesState.environment, - }); - }); - } - - if (recompileMode === "delayed") { - if (typeof window === "undefined") return; - - window.clearTimeout(debounceHook.current); - debounceHook.current = window.setTimeout(() => { - Object.values(clients.current).forEach((client) => { - client.updateSandbox({ - files: filesState.files, - template: filesState.environment, - }); - }); - }, recompileDelay); - } - }, [ - filesState.shouldUpdatePreview, - filesState.files, - filesState.environment, - recompileMode, - recompileDelay, - state.status, - createClient, - ]); + const recompileMode = options?.recompileMode ?? "delayed"; + const recompileDelay = options?.recompileDelay ?? 500; const dispatchMessage = ( message: SandpackMessage, @@ -499,11 +446,57 @@ export const useClient: UseClient = (props, filesState) => { /** * Effects */ + useEffect( function watchFileChanges() { - updateClients(); + if (state.status !== "running" || !filesState.shouldUpdatePreview) { + return; + } + + /** + * When the environment changes, Sandpack needs to make sure + * to create a new client and the proper bundler + */ + if (prevEnvironment.current !== filesState.environment) { + prevEnvironment.current = filesState.environment; + + Object.entries(clients.current).forEach(([key, client]) => { + registerBundler(client.iframe, key); + }); + } + + if (recompileMode === "immediate") { + Object.values(clients.current).forEach((client) => { + client.updateSandbox({ + files: filesState.files, + template: filesState.environment, + }); + }); + } + + if (recompileMode === "delayed") { + if (typeof window === "undefined") return; + + window.clearTimeout(debounceHook.current); + debounceHook.current = window.setTimeout(() => { + Object.values(clients.current).forEach((client) => { + client.updateSandbox({ + files: filesState.files, + template: filesState.environment, + }); + }); + }, recompileDelay); + } }, - [filesState.files, filesState.environment, updateClients] + [ + filesState.files, + filesState.environment, + filesState.shouldUpdatePreview, + recompileDelay, + recompileMode, + registerBundler, + state.status, + ] ); useEffect( diff --git a/sandpack-react/src/contexts/utils/useFiles.ts b/sandpack-react/src/contexts/utils/useFiles.ts index f89045529..f5a6fd530 100644 --- a/sandpack-react/src/contexts/utils/useFiles.ts +++ b/sandpack-react/src/contexts/utils/useFiles.ts @@ -1,7 +1,7 @@ import type { SandpackBundlerFiles } from "@codesandbox/sandpack-client"; import { normalizePath } from "@codesandbox/sandpack-client"; -import isEqual from "lodash.isequal"; -import { useRef, useState } from "react"; +import { useState } from "react"; +import useDeepCompareEffect from "use-deep-compare-effect"; import type { SandboxEnvironment, @@ -54,19 +54,12 @@ export type UseFiles = (props: SandpackProviderProps) => [ export const useFiles: UseFiles = (props) => { const originalStateFromProps = getSandpackStateFromProps(props); - const prevOriginalStateFromProps = useRef(originalStateFromProps); const [state, setState] = useState(originalStateFromProps); - const filesHaveChanged = !isEqual( - prevOriginalStateFromProps.current, - originalStateFromProps - ); - if (filesHaveChanged) { - setState(originalStateFromProps); - - prevOriginalStateFromProps.current = originalStateFromProps; - } + useDeepCompareEffect(() => { + setState(getSandpackStateFromProps(props)); + }, [props]); const updateFile = ( pathOrFiles: string | SandpackFiles, diff --git a/yarn.lock b/yarn.lock index 169defc90..5f19e3257 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5016,18 +5016,6 @@ resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee" integrity sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ== -"@types/lodash.isequal@^4.5.2": - version "4.5.6" - resolved "https://registry.yarnpkg.com/@types/lodash.isequal/-/lodash.isequal-4.5.6.tgz#ff42a1b8e20caa59a97e446a77dc57db923bc02b" - integrity sha512-Ww4UGSe3DmtvLLJm2F16hDwEQSv7U0Rr8SujLUA2wHI2D2dm8kPu6Et+/y303LfjTIwSBKXB/YTUcAKpem/XEg== - dependencies: - "@types/lodash" "*" - -"@types/lodash@*": - version "4.14.182" - resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2" - integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q== - "@types/lodash@^4.14.167": version "4.14.186" resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.186.tgz#862e5514dd7bd66ada6c70ee5fce844b06c8ee97" @@ -8234,7 +8222,7 @@ deprecation@^2.0.0, deprecation@^2.3.1: resolved "https://registry.yarnpkg.com/deprecation/-/deprecation-2.3.1.tgz#6368cbdb40abf3373b525ac87e4a260c3a700919" integrity sha512-xmHIy4F3scKVwMsQ4WnVaS8bHOx0DmVwRywosKhaILI0ywMDWPtBSku2HNxRvF7jtwDRsoEwYQSfbxj8b7RlJQ== -dequal@^2.0.0: +dequal@^2.0.0, dequal@^2.0.2: version "2.0.3" resolved "https://registry.yarnpkg.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== @@ -12845,11 +12833,6 @@ lodash.debounce@^4.0.8: resolved "https://registry.yarnpkg.com/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= -lodash.isequal@^4.5.0: - version "4.5.0" - resolved "https://registry.yarnpkg.com/lodash.isequal/-/lodash.isequal-4.5.0.tgz#415c4478f2bcc30120c22ce10ed3226f7d3e18e0" - integrity sha1-QVxEePK8wwEgwizhDtMib30+GOA= - lodash.ismatch@^4.4.0: version "4.4.0" resolved "https://registry.yarnpkg.com/lodash.ismatch/-/lodash.ismatch-4.4.0.tgz#756cb5150ca3ba6f11085a78849645f188f85f37" @@ -18796,6 +18779,14 @@ use-clipboard-copy@^0.2.0: dependencies: clipboard-copy "^3.0.0" +use-deep-compare-effect@1.8.1: + version "1.8.1" + resolved "https://registry.yarnpkg.com/use-deep-compare-effect/-/use-deep-compare-effect-1.8.1.tgz#ef0ce3b3271edb801da1ec23bf0754ef4189d0c6" + integrity sha512-kbeNVZ9Zkc0RFGpfMN3MNfaKNvcLNyxOAAd9O4CBZ+kCBXXscn9s/4I+8ytUER4RDpEYs5+O6Rs4PqiZ+rHr5Q== + dependencies: + "@babel/runtime" "^7.12.5" + dequal "^2.0.2" + use-sync-external-store@1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz#7dbefd6ef3fe4e767a0cf5d7287aacfb5846928a"