diff --git a/sandpack-react/src/contexts/sandpackContext.tsx b/sandpack-react/src/contexts/sandpackContext.tsx index afc2090f1..e15045018 100644 --- a/sandpack-react/src/contexts/sandpackContext.tsx +++ b/sandpack-react/src/contexts/sandpackContext.tsx @@ -1,10 +1,12 @@ -import { ClasserProvider } from "@code-hike/classer"; import type { + BundlerState, ListenerFunction, SandpackBundlerFiles, + SandpackError, SandpackMessage, UnsubscribeFunction, ReactDevToolsMode, + SandpackLogLevel, } from "@codesandbox/sandpack-client"; import { SandpackClient, @@ -14,12 +16,15 @@ import isEqual from "lodash.isequal"; import * as React from "react"; import type { SandpackFiles } from ".."; -import { SandpackThemeProvider } from "../contexts/themeContext"; import type { SandpackContext, - SandpackInternalProvider, - SandpackProviderState, - SandpackProviderProps, + SandboxEnvironment, + FileResolver, + SandpackStatus, + EditorState, + SandpackPredefinedTemplate, + SandpackSetup, + SandpackInitMode, } from "../types"; import { convertedFilesToBundlerFiles, @@ -33,17 +38,70 @@ import { generateRandomId } from "../utils/stringUtils"; const Sandpack = React.createContext(null); const BUNDLER_TIMEOUT = 30000; // 30 seconds timeout for the bundler to respond. +export interface SandpackProviderState { + files: SandpackBundlerFiles; + environment?: SandboxEnvironment; + activePath: string; + openPaths: string[]; + startRoute?: string; + bundlerState?: BundlerState; + error: SandpackError | null; + sandpackStatus: SandpackStatus; + editorState: EditorState; + renderHiddenIframe: boolean; + initMode: SandpackInitMode; + reactDevTools?: ReactDevToolsMode; +} + +export interface SandpackProviderProps { + template?: SandpackPredefinedTemplate; + customSetup?: SandpackSetup; + + // editor state (override values) + activePath?: string; + openPaths?: string[]; + + // execution and recompile + recompileMode?: "immediate" | "delayed"; + recompileDelay?: number; + autorun?: boolean; + + /** + * This provides a way to control how some components are going to + * be initialized on the page. The CodeEditor and the Preview components + * are quite expensive and might overload the memory usage, so this gives + * a certain control of when to initialize them. + */ + initMode?: SandpackInitMode; + initModeObserverOptions?: IntersectionObserverInit; + + // bundler options + bundlerURL?: string; + logLevel?: SandpackLogLevel; + startRoute?: string; + skipEval?: boolean; + fileResolver?: FileResolver; + externalResources?: string[]; +} + /** * Main context provider that should wraps your entire component. * Use * [`useSandpack`](/api/react/#usesandpack) hook, which gives you the entire context object to play with. * * @category Provider - * @hidden + * @noInheritDoc */ -class SandpackProviderClass extends React.PureComponent< +class SandpackProvider extends React.PureComponent< SandpackProviderProps, SandpackProviderState > { + static defaultProps = { + skipEval: false, + recompileMode: "delayed", + recompileDelay: 500, + autorun: true, + }; + lazyAnchorRef: React.RefObject; preregisteredIframes: Record; @@ -67,21 +125,21 @@ class SandpackProviderClass extends React.PureComponent< constructor(props: SandpackProviderProps) { super(props); - const { activeFile, visibleFiles, files, environment } = + const { activePath, openPaths, files, environment } = getSandpackStateFromProps(props); this.state = { files, environment, - visibleFiles, - activeFile, - startRoute: this.props.options?.startRoute, + openPaths, + activePath, + startRoute: this.props.startRoute, bundlerState: undefined, error: null, - sandpackStatus: this.props.options?.autorun ?? true ? "initial" : "idle", + sandpackStatus: this.props.autorun ? "initial" : "idle", editorState: "pristine", renderHiddenIframe: false, - initMode: this.props.options?.initMode || "lazy", + initMode: this.props.initMode || "lazy", reactDevTools: undefined, }; @@ -94,7 +152,6 @@ class SandpackProviderClass extends React.PureComponent< * - A client already exists, set a new listener and then one more client has been created; */ this.queuedListeners = { global: {} }; - /** * Global list of unsubscribe function for the listeners */ @@ -111,6 +168,9 @@ class SandpackProviderClass extends React.PureComponent< React.createRef() as React.MutableRefObject; } + /** + * @hidden + */ handleMessage = (msg: SandpackMessage): void => { if (this.timeoutHook) { clearTimeout(this.timeoutHook); @@ -133,14 +193,23 @@ class SandpackProviderClass extends React.PureComponent< } }; + /** + * @hidden + */ registerReactDevTools = (value: ReactDevToolsMode): void => { this.setState({ reactDevTools: value }); }; + /** + * @hidden + */ updateCurrentFile = (code: string): void => { - this.updateFile(this.state.activeFile, code); + this.updateFile(this.state.activePath, code); }; + /** + * @hidden + */ updateFile = (pathOrFiles: string | SandpackFiles, code?: string): void => { let files = this.state.files; @@ -157,10 +226,12 @@ class SandpackProviderClass extends React.PureComponent< this.setState({ files }, this.updateClients); }; + /** + * @hidden + */ updateClients = (): void => { const { files, sandpackStatus } = this.state; - const recompileMode = this.props.options?.recompileMode ?? "delayed"; - const recompileDelay = this.props.options?.recompileDelay ?? 500; + const { recompileMode, recompileDelay } = this.props; if (sandpackStatus !== "running") { return; @@ -186,14 +257,15 @@ class SandpackProviderClass extends React.PureComponent< } }; + /** + * @hidden + */ initializeSandpackIframe(): void { - const autorun = this.props.options?.autorun ?? true; - - if (!autorun) { + if (!this.props.autorun) { return; } - const observerOptions = this.props.options?.initModeObserverOptions ?? { + const observerOptions = this.props.initModeObserverOptions ?? { rootMargin: `1000px 0px`, }; @@ -247,20 +319,23 @@ class SandpackProviderClass extends React.PureComponent< } } + /** + * @hidden + */ componentDidMount(): void { this.initializeSandpackIframe(); } + /** + * @hidden + */ componentDidUpdate(prevProps: SandpackProviderProps): void { /** * Watch the changes on the initMode prop */ - if ( - prevProps.options?.initMode !== this.props.options?.initMode && - this.props.options?.initMode - ) { + if (prevProps.initMode !== this.props.initMode && this.props.initMode) { this.setState( - { initMode: this.props.options?.initMode }, + { initMode: this.props.initMode }, this.initializeSandpackIframe ); } @@ -268,7 +343,7 @@ class SandpackProviderClass extends React.PureComponent< /** * Custom setup derived from props */ - const { activeFile, visibleFiles, files, environment } = + const { activePath, openPaths, files, environment } = getSandpackStateFromProps(this.props); /** @@ -276,11 +351,12 @@ class SandpackProviderClass extends React.PureComponent< */ if ( prevProps.template !== this.props.template || - !isEqual(prevProps.customSetup, this.props.customSetup) || - !isEqual(prevProps.files, this.props.files) + prevProps.activePath !== this.props.activePath || + !isEqual(prevProps.openPaths, this.props.openPaths) || + !isEqual(prevProps.customSetup, this.props.customSetup) ) { /* eslint-disable react/no-did-update-set-state */ - this.setState({ activeFile, visibleFiles, files, environment }); + this.setState({ activePath, openPaths, files, environment }); if (this.state.sandpackStatus !== "running") { return; @@ -303,6 +379,9 @@ class SandpackProviderClass extends React.PureComponent< } } + /** + * @hidden + */ componentWillUnmount(): void { if (typeof this.unsubscribe === "function") { this.unsubscribe(); @@ -325,6 +404,9 @@ class SandpackProviderClass extends React.PureComponent< } } + /** + * @hidden + */ createClient = ( iframe: HTMLIFrameElement, clientId: string @@ -336,12 +418,12 @@ class SandpackProviderClass extends React.PureComponent< template: this.state.environment, }, { - externalResources: this.props.options?.externalResources, - bundlerURL: this.props.options?.bundlerURL, - startRoute: this.props.options?.startRoute, - fileResolver: this.props.options?.fileResolver, - skipEval: this.props.options?.skipEval ?? false, - logLevel: this.props.options?.logLevel, + externalResources: this.props.externalResources, + bundlerURL: this.props.bundlerURL, + logLevel: this.props.logLevel, + startRoute: this.props.startRoute, + fileResolver: this.props.fileResolver, + skipEval: this.props.skipEval, showOpenInCodeSandbox: !this.openInCSBRegistered.current, showErrorScreen: !this.errorScreenRegistered.current, showLoadingScreen: !this.loadingScreenRegistered.current, @@ -394,6 +476,9 @@ class SandpackProviderClass extends React.PureComponent< return client; }; + /** + * @hidden + */ runSandpack = (): void => { Object.keys(this.preregisteredIframes).forEach((clientId) => { const iframe = this.preregisteredIframes[clientId]; @@ -403,6 +488,9 @@ class SandpackProviderClass extends React.PureComponent< this.setState({ sandpackStatus: "running" }); }; + /** + * @hidden + */ registerBundler = (iframe: HTMLIFrameElement, clientId: string): void => { if (this.state.sandpackStatus === "running") { this.clients[clientId] = this.createClient(iframe, clientId); @@ -411,6 +499,9 @@ class SandpackProviderClass extends React.PureComponent< } }; + /** + * @hidden + */ unregisterBundler = (clientId: string): void => { const client = this.clients[clientId]; if (client) { @@ -428,6 +519,9 @@ class SandpackProviderClass extends React.PureComponent< this.setState({ sandpackStatus: "idle" }); }; + /** + * @hidden + */ unregisterAllClients = (): void => { Object.keys(this.clients).map(this.unregisterBundler); @@ -437,47 +531,59 @@ class SandpackProviderClass extends React.PureComponent< } }; - setActiveFile = (activeFile: string): void => { - this.setState({ activeFile }); + /** + * @hidden + */ + setActiveFile = (activePath: string): void => { + this.setState({ activePath }); }; + /** + * @hidden + */ openFile = (path: string): void => { - this.setState(({ visibleFiles }) => { - const newPaths = visibleFiles.includes(path) - ? visibleFiles - : [...visibleFiles, path]; + this.setState(({ openPaths }) => { + const newPaths = openPaths.includes(path) + ? openPaths + : [...openPaths, path]; return { - activeFile: path, - visibleFiles: newPaths, + activePath: path, + openPaths: newPaths, }; }); }; + /** + * @hidden + */ closeFile = (path: string): void => { - if (this.state.visibleFiles.length === 1) { + if (this.state.openPaths.length === 1) { return; } - this.setState(({ visibleFiles, activeFile }) => { - const indexOfRemovedPath = visibleFiles.indexOf(path); - const newPaths = visibleFiles.filter((openPath) => openPath !== path); + this.setState(({ openPaths, activePath }) => { + const indexOfRemovedPath = openPaths.indexOf(path); + const newPaths = openPaths.filter((openPath) => openPath !== path); return { - activeFile: - path === activeFile + activePath: + path === activePath ? indexOfRemovedPath === 0 - ? visibleFiles[1] - : visibleFiles[indexOfRemovedPath - 1] - : activeFile, - visibleFiles: newPaths, + ? openPaths[1] + : openPaths[indexOfRemovedPath - 1] + : activePath, + openPaths: newPaths, }; }); }; + /** + * @hidden + */ deleteFile = (path: string): void => { - this.setState(({ visibleFiles, files }) => { - const newPaths = visibleFiles.filter((openPath) => openPath !== path); + this.setState(({ openPaths, files }) => { + const newPaths = openPaths.filter((openPath) => openPath !== path); const newFiles = Object.keys(files).reduce( (acc: SandpackBundlerFiles, filePath) => { if (filePath === path) { @@ -490,18 +596,19 @@ class SandpackProviderClass extends React.PureComponent< ); return { - visibleFiles: newPaths, + openPaths: newPaths, files: newFiles, }; }); this.updateClients(); }; + /** + * @hidden + */ dispatchMessage = (message: SandpackMessage, clientId?: string): void => { if (this.state.sandpackStatus !== "running") { - console.warn( - `[sandpack-react]: dispatch cannot be called while in idle mode` - ); + console.warn("dispatch cannot be called while in idle mode"); return; } @@ -514,6 +621,9 @@ class SandpackProviderClass extends React.PureComponent< } }; + /** + * @hidden + */ addListener = ( listener: ListenerFunction, clientId?: string @@ -581,6 +691,9 @@ class SandpackProviderClass extends React.PureComponent< } }; + /** + * @hidden + */ resetFile = (path: string): void => { const { files } = getSandpackStateFromProps(this.props); @@ -592,17 +705,23 @@ class SandpackProviderClass extends React.PureComponent< ); }; + /** + * @hidden + */ resetAllFiles = (): void => { const { files } = getSandpackStateFromProps(this.props); this.setState({ files }, this.updateClients); }; + /** + * @hidden + */ _getSandpackState = (): SandpackContext => { const { files, - activeFile, - visibleFiles, + activePath, + openPaths, startRoute, bundlerState, editorState, @@ -615,8 +734,8 @@ class SandpackProviderClass extends React.PureComponent< return { files, environment, - visibleFiles, - activeFile, + openPaths, + activePath, startRoute, error, bundlerState, @@ -645,28 +764,20 @@ class SandpackProviderClass extends React.PureComponent< }; }; + /** + * @hidden + */ render(): React.ReactElement { - const { children, theme } = this.props; + const { children } = this.props; return ( - - - {children} - - + {children} ); } } -/** - * @hidden - */ -const SandpackProvider: SandpackInternalProvider = - // eslint-disable-next-line @typescript-eslint/no-explicit-any - SandpackProviderClass as any; - /** * @category Provider */ diff --git a/sandpack-react/src/templates/react.ts b/sandpack-react/src/templates/react.ts index 590b0bf89..6e45b8072 100644 --- a/sandpack-react/src/templates/react.ts +++ b/sandpack-react/src/templates/react.ts @@ -1,7 +1,6 @@ -/** - * @category Template - */ -export const REACT_TEMPLATE = { +import type { SandboxTemplate } from "../types"; + +export const REACT_TEMPLATE: SandboxTemplate = { files: { "/App.js": { code: `export default function App() { @@ -11,16 +10,17 @@ export const REACT_TEMPLATE = { }, "/index.js": { code: `import React, { StrictMode } from "react"; -import { createRoot } from "react-dom/client"; +import ReactDOM from "react-dom"; import "./styles.css"; import App from "./App"; -const root = createRoot(document.getElementById("root")); -root.render( +const rootElement = document.getElementById("root"); +ReactDOM.render( - + , + rootElement );`, }, "/styles.css": { @@ -55,8 +55,8 @@ h1 { }, }, dependencies: { - react: "^18.0.0", - "react-dom": "^18.0.0", + react: "^17.0.0", + "react-dom": "^17.0.0", "react-scripts": "^4.0.0", }, entry: "/index.js", diff --git a/sandpack-react/src/templates/vanilla.ts b/sandpack-react/src/templates/vanilla.ts index ebce0dfa0..872c3f98a 100644 --- a/sandpack-react/src/templates/vanilla.ts +++ b/sandpack-react/src/templates/vanilla.ts @@ -1,13 +1,12 @@ -/** - * @category Template - */ -export const VANILLA_TEMPLATE = { +import type { SandboxTemplate } from "../types"; + +export const VANILLA_TEMPLATE: SandboxTemplate = { files: { "/src/index.js": { code: `import "./styles.css"; document.getElementById("app").innerHTML = \` -

Hello World

+

Hello Vanilla!

We use the same configuration as Parcel to bundle this sandbox, you can find more info about Parcel diff --git a/sandpack-react/src/utils/sandpackUtils.test.ts b/sandpack-react/src/utils/sandpackUtils.test.ts index 4bed02135..e3ae8827e 100644 --- a/sandpack-react/src/utils/sandpackUtils.test.ts +++ b/sandpack-react/src/utils/sandpackUtils.test.ts @@ -3,231 +3,155 @@ import { REACT_TEMPLATE } from "../templates/react"; import { getSandpackStateFromProps, createSetupFromUserInput, - resolveFile, convertedFilesToBundlerFiles, } from "./sandpackUtils"; -describe(resolveFile, () => { - it("resolves the file path based on the extension", () => { - const data = resolveFile("/file.js", { "/file.ts": "" }); - - expect(data).toBe("/file.ts"); - }); - - it("adds the leading slash and resolves the file path", () => { - const data = resolveFile("file.js", { "/file.js": "" }); - - expect(data).toBe("/file.js"); - }); - - it("resolves the file path without leading slash", () => { - const data = resolveFile("file.ts", { "file.js": "" }); - - expect(data).toBe("file.js"); - }); - - it("removes the leading slash and resolves the file path", () => { - const data = resolveFile("/file.js", { "file.js": "" }); - - expect(data).toBe("file.js"); - }); - - it("fixes (add/remove) the leading slash and fixes the extension", () => { - const data = resolveFile("/file.ts", { "file.js": "" }); - - expect(data).toBe("file.js"); - }); -}); - describe(getSandpackStateFromProps, () => { /** - * Files + * activePath */ - test("it should merge template and files props", () => { + test("it returns the main file in case activePath doesn't exist", () => { const setup = getSandpackStateFromProps({ template: "react", - files: { - "foo.ts": "foo", - }, + activePath: "NO_EXIST.js", }); - expect(setup.files["foo.ts"].code).toBe("foo"); - }); - - test("files should override template files", () => { - const setup = getSandpackStateFromProps({ - template: "react", - files: { - "/App.js": "foo", - }, - }); - - expect(setup.files["/App.js"].code).toBe("foo"); - }); - - /** - * activeFile - */ - test("it returns the main file in case activeFile doesn't exist", () => { - const setup = getSandpackStateFromProps({ - template: "react", - options: { - activeFile: "NO_EXIST.js", - }, - }); - - expect(setup.activeFile).not.toBe("NO_EXIST.js"); - expect(setup.activeFile).toBe(REACT_TEMPLATE.main); + expect(setup.activePath).not.toBe("NO_EXIST.js"); + expect(setup.activePath).toBe(REACT_TEMPLATE.main); }); test("always return an activeFile", () => { const template = getSandpackStateFromProps({ template: "react" }); - expect(template.activeFile).toBe("/App.js"); + expect(template.activePath).toBe("/App.js"); const noTemplate = getSandpackStateFromProps({}); - expect(noTemplate.activeFile).toBe("/src/index.js"); + expect(noTemplate.activePath).toBe("/src/index.js"); const customSetup = getSandpackStateFromProps({ - files: { "foo.js": "" }, - customSetup: { entry: "foo.js" }, + customSetup: { entry: "foo.js", files: { "foo.js": "" } }, }); - expect(customSetup.activeFile).toBe("foo.js"); + expect(customSetup.activePath).toBe("foo.js"); }); - test("show activeFile even when it's hidden", () => { + test("show activePath even when it's hidden", () => { const setup = getSandpackStateFromProps({ template: "react", - options: { - activeFile: "/App.js", - }, - files: { - "/App.js": { hidden: true, code: "" }, - "/custom.js": { hidden: true, code: "" }, + activePath: "/App.js", + customSetup: { + files: { + "/App.js": { hidden: true, code: "" }, + "/custom.js": { hidden: true, code: "" }, + }, }, }); - expect(setup.activeFile).toEqual("/App.js"); + expect(setup.activePath).toEqual("/App.js"); }); - test("it uses entry as activeFile", () => { + test("activePath overrides the customSetup.main", () => { const setup = getSandpackStateFromProps({ - files: { "entry.js": "" }, + template: "react", + activePath: "/App.js", customSetup: { - entry: "entry.js", + main: "/custom.js", + files: { + "/App.js": "", + "/custom.js": "", + }, }, }); - expect(setup.activeFile).toEqual("entry.js"); + expect(setup.activePath).toEqual("/App.js"); }); /** * hidden file */ + test("exclude hidden files from template", () => { + const setup = getSandpackStateFromProps({ template: "react" }); + const collectFilenames = Object.entries(REACT_TEMPLATE.files).reduce( + (acc, [key, value]) => { + if (!value.hidden) { + acc.push(key); + } + + return acc; + }, + [] as string[] + ); + + expect(setup.openPaths.sort()).toEqual(collectFilenames.sort()); + }); test("exclude hidden files from custom files", () => { const setup = getSandpackStateFromProps({ - files: { - "/App.js": { code: "" }, - "/custom.js": { hidden: true, code: "" }, - }, customSetup: { entry: "/App.js", + files: { + "/App.js": { code: "" }, + "/custom.js": { hidden: true, code: "" }, + }, }, }); - expect(setup.visibleFiles.sort()).toEqual(["/App.js"]); + expect(setup.openPaths.sort()).toEqual(["/App.js"]); }); test("exclude hidden files from custom files & template", () => { const setup = getSandpackStateFromProps({ template: "react", - files: { - "/App.js": { code: "" }, - "/custom.js": { hidden: true, code: "" }, + customSetup: { + files: { + "/App.js": { code: "" }, + "/custom.js": { hidden: true, code: "" }, + }, }, - customSetup: {}, }); - expect(setup.visibleFiles.sort()).toEqual(["/App.js"]); + expect(setup.openPaths.sort()).toEqual(["/App.js"]); }); test("show files which are `hidden` & `active` at the same time", () => { const setup = getSandpackStateFromProps({ template: "react", - files: { - "/App.js": { hidden: true, active: true, code: "" }, - "/custom.js": { hidden: true, code: "" }, + customSetup: { + files: { + "/App.js": { hidden: true, active: true, code: "" }, + "/custom.js": { hidden: true, code: "" }, + }, }, - customSetup: {}, }); - expect(setup.visibleFiles.sort()).toEqual(["/App.js"]); + expect(setup.openPaths.sort()).toEqual(["/App.js"]); }); /** - * Files - visibleFiles - activeFile + * entry file */ - test("only the main file is visible in a default setup", () => { - const setup = getSandpackStateFromProps({ template: "react" }); - - expect(setup.visibleFiles.sort()).toEqual([REACT_TEMPLATE.main]); - }); - - test("it uses the visible path prop properly with a default template", () => { - const setup = getSandpackStateFromProps({ - options: { visibleFiles: ["/src/styles.css"] }, - }); - - expect(setup.visibleFiles.sort()).toEqual([ - "/src/index.js", - "/src/styles.css", - ]); - }); - - test("it uses the visible path prop properly with a template", () => { - const setup = getSandpackStateFromProps({ - template: "react", - options: { visibleFiles: ["/styles.css"] }, - }); - - expect(setup.visibleFiles.sort()).toEqual(["/App.js", "/styles.css"]); - }); - - test("visibleFiles override the files configurations", () => { - const setup = getSandpackStateFromProps({ - files: { - A: { hidden: true, code: "" }, - B: { hidden: true, code: "" }, - }, - customSetup: { entry: "A" }, - options: { visibleFiles: ["A", "B"] }, - }); - - expect(setup.visibleFiles).toEqual(["A", "B"]); - }); - - test("activeFile override the files configurations", () => { - const setup = getSandpackStateFromProps({ - files: { - A: { active: true, code: "" }, - B: { code: "" }, - }, - customSetup: { entry: "A" }, - options: { activeFile: "B" }, - }); - - expect(setup.activeFile).toEqual("B"); + test("it needs to provide a entry file, when template is omitted", () => { + try { + getSandpackStateFromProps({ + customSetup: { + files: { + "/App.js": { hidden: true, code: "" }, + "/custom.js": { hidden: true, code: "" }, + }, + }, + }); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } catch (err: any) { + expect(err.message).toEqual( + "undefined was set as the active file but was not provided" + ); + } }); - /** - * entry file - */ test("it updates the entry file in the package.json", () => { const setup = getSandpackStateFromProps({ template: "react", - files: { "foo.ts": "" }, customSetup: { entry: "foo.ts", + files: { "foo.ts": "" }, }, }); @@ -235,57 +159,31 @@ describe(getSandpackStateFromProps, () => { expect(packageContent.main).toBe("foo.ts"); }); - test("it resolves the entry file, even when the extension is wrong", () => { + /** + * openPaths + */ + test("should not show invalid files into `openPaths`", () => { const setup = getSandpackStateFromProps({ template: "react", - files: { "entry.js": "" }, - customSetup: { - entry: "entry.ts", - }, - }); - - const packageContent = JSON.parse(setup.files["/package.json"].code); - expect(packageContent.main).toBe("entry.js"); - }); - - test("it keeps the entry into package.json main", () => { - const setup = getSandpackStateFromProps({ - files: { - "/package.json": `{ "main": "main-entry.ts" }`, - "new-entry.js": "", - }, - customSetup: { entry: "entry.js" }, - }); - - const packageContent = JSON.parse(setup.files["/package.json"].code); - expect(packageContent.main).toEqual("main-entry.ts"); - }); - - test("it needs to set the entry into package.json as main", () => { - const setup = getSandpackStateFromProps({ - files: { - "/package.json": `{}`, - "entry.js": "", - }, - customSetup: { entry: "entry.js" }, + openPaths: ["/App.js", "not-exist.js"], }); - const packageContent = JSON.parse(setup.files["/package.json"].code); - expect(packageContent.main).toEqual("entry.js"); + expect(setup.openPaths).toEqual(["/App.js"]); }); /** - * visibleFiles + * main file (will be deprecated) */ - test("should not show invalid files into `visibleFiles`", () => { + test("it uses main as activePath", () => { const setup = getSandpackStateFromProps({ template: "react", - options: { - visibleFiles: ["/App.js", "not-exist.js"], + customSetup: { + main: "myfile.js", + files: { "myfile.js": "" }, }, }); - expect(setup.visibleFiles).toEqual(["/App.js"]); + expect(setup.activePath).toEqual("myfile.js"); }); /** @@ -293,9 +191,9 @@ describe(getSandpackStateFromProps, () => { */ test("it creates a package.json with the dependencies", () => { const setup = getSandpackStateFromProps({ - files: { "index.js": "" }, customSetup: { entry: "index.js", + files: { "index.js": "" }, dependencies: { foo: "*" }, }, }); @@ -306,116 +204,46 @@ describe(getSandpackStateFromProps, () => { test("it defatuls to a package.json", () => { const setup = getSandpackStateFromProps({ - files: { "index.js": "" }, - customSetup: { entry: "index.js" }, + customSetup: { + entry: "index.js", + files: { + "index.js": "", + }, + }, }); const packageContent = JSON.parse(setup.files["/package.json"].code); expect(packageContent.dependencies).toEqual({}); }); - test("it merges the dependencies into package.json dependencies", () => { - const setup = getSandpackStateFromProps({ - files: { "/package.json": `{ "dependencies": { "baz": "*" } }` }, - customSetup: { dependencies: { foo: "*" } }, - }); - - const packageContent = JSON.parse(setup.files["/package.json"].code); - expect(packageContent.dependencies).toEqual({ foo: "*", baz: "*" }); - }); - - test("it merges the dependencies from template into the package.json dependencies", () => { - const setup = getSandpackStateFromProps({ - template: "react", - customSetup: { dependencies: { foo: "*" } }, - }); - - const packageContent = JSON.parse(setup.files["/package.json"].code); - expect(packageContent.dependencies).toEqual({ - foo: "*", - react: "^18.0.0", - "react-dom": "^18.0.0", - "react-scripts": "^4.0.0", - }); - }); - /** * environment */ - test("environment default to parcel", () => { + it("environment default to parcel", () => { const setup = getSandpackStateFromProps({}); expect(setup.environment).toBe("parcel"); }); - test("environment default to the custom template environment", () => { + it("environment default to the custom template environment", () => { const setup = getSandpackStateFromProps({ template: "svelte" }); expect(setup.environment).toBe("svelte"); }); - - /** - * Errors handling - */ - test("it needs to provide a entry file, when template is omitted", () => { - try { - getSandpackStateFromProps({ - files: { - "/App.js": { hidden: true, code: "" }, - "/custom.js": { hidden: true, code: "" }, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - expect(err.message).toEqual( - `[sandpack-client]: "entry" was not specified - provide either a package.json with the "main" field or na "entry" value` - ); - } - }); - - test("it needs to provide whether template or files", () => { - try { - getSandpackStateFromProps({ - customSetup: {}, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - expect(err.message).toEqual( - "[sandpack-react]: without a template, you must pass at least one file" - ); - } - }); - - test("it throws an error when the given template doesn't exist", () => { - try { - getSandpackStateFromProps({ - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - template: "WHATEVER", - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (err: any) { - expect(err.message).toEqual( - `[sandpack-react]: invalid template "WHATEVER" provided` - ); - } - }); }); describe(createSetupFromUserInput, () => { test("convert `files` to a key/value format", () => { - const setup = createSetupFromUserInput({ files: { "App.js": "" } }); - - expect(setup).toStrictEqual({ files: { "App.js": { code: "" } } }); + const output = createSetupFromUserInput({ files: { "App.js": "" } }); + expect(output).toStrictEqual({ files: { "App.js": { code: "" } } }); }); test("supports custom properties", () => { - const setup = createSetupFromUserInput({ + const output = createSetupFromUserInput({ + environment: "create-react-app", files: { "App.js": "" }, - customSetup: { environment: "create-react-app" }, }); - - expect(setup).toStrictEqual({ + expect(output).toStrictEqual({ environment: "create-react-app", files: { "App.js": { code: "" } }, }); diff --git a/sandpack-react/src/utils/sandpackUtils.ts b/sandpack-react/src/utils/sandpackUtils.ts index 5cd61cb68..bce4624df 100644 --- a/sandpack-react/src/utils/sandpackUtils.ts +++ b/sandpack-react/src/utils/sandpackUtils.ts @@ -4,19 +4,19 @@ import type { } from "@codesandbox/sandpack-client"; import { addPackageJSONIfNeeded } from "@codesandbox/sandpack-client"; +import type { SandpackProviderProps } from "../contexts/sandpackContext"; import { SANDBOX_TEMPLATES } from "../templates"; import type { + SandboxEnvironment, SandboxTemplate, + SandpackFiles, SandpackPredefinedTemplate, - SandpackProviderProps, SandpackSetup, - SandpackFiles, - SandboxEnvironment, } from "../types"; export interface SandpackContextInfo { - activeFile: string; - visibleFiles: string[]; + activePath: string; + openPaths: string[]; files: Record; environment: SandboxEnvironment; } @@ -25,146 +25,96 @@ export const getSandpackStateFromProps = ( props: SandpackProviderProps ): SandpackContextInfo => { // Merge predefined template with custom setup - const projectSetup = getSetup({ - template: props.template, - customSetup: props.customSetup, - files: props.files, - }); + const projectSetup = getSetup(props.template, props.customSetup); - // visibleFiles and activeFile override the setup flags - let visibleFiles = props.options?.visibleFiles ?? []; - let activeFile = props.options?.activeFile; + // openPaths and activePath override the setup flags + let openPaths = props.openPaths ?? []; + let activePath = props.activePath; - if (visibleFiles.length === 0 && props?.files) { - const inputFiles = props.files; + if (openPaths.length === 0 && props.customSetup?.files) { + const inputFiles = props.customSetup.files; // extract open and active files from the custom input files Object.keys(inputFiles).forEach((filePath) => { const file = inputFiles[filePath]; if (typeof file === "string") { - visibleFiles.push(filePath); + openPaths.push(filePath); return; } - if (!activeFile && file.active) { - activeFile = filePath; + if (!activePath && file.active) { + activePath = filePath; if (file.hidden === true) { - visibleFiles.push(filePath); // active file needs to be available even if someone sets it as hidden by accident + openPaths.push(filePath); // active file needs to be available even if someone sets it as hidden by accident } } if (!file.hidden) { - visibleFiles.push(filePath); + openPaths.push(filePath); } }); } - if (visibleFiles.length === 0) { + if (openPaths.length === 0) { // If no files are received, use the project setup / template - visibleFiles = [projectSetup.main]; - } + openPaths = Object.keys(projectSetup.files).reduce((acc, key) => { + if (!projectSetup.files[key].hidden) { + acc.push(key); + } - // Make sure it resolves the entry file - if (!projectSetup.files[projectSetup.entry]) { - /* eslint-disable */ - // @ts-ignore - projectSetup.entry = resolveFile(projectSetup.entry, projectSetup.files); - /* eslint-enable */ + return acc; + }, []); } - if (!activeFile && projectSetup.main) { - activeFile = projectSetup.main; + // If no activePath is specified, use the first open file + if (!activePath || !projectSetup.files[activePath]) { + activePath = projectSetup.main || openPaths[0]; } - // If no activeFile is specified, use the first open file - if (!activeFile || !projectSetup.files[activeFile]) { - activeFile = visibleFiles[0]; + // If for whatever reason the active path was not set as open, set it + if (!openPaths.includes(activePath)) { + openPaths.push(activePath); } - // If for whatever reason the active path was not set as open, set it - if (!visibleFiles.includes(activeFile)) { - visibleFiles.push(activeFile); + if (!projectSetup.files[activePath]) { + throw new Error( + `${activePath} was set as the active file but was not provided` + ); } const files = addPackageJSONIfNeeded( projectSetup.files, - projectSetup.dependencies ?? {}, - projectSetup.devDependencies ?? {}, + projectSetup.dependencies || {}, + projectSetup.devDependencies || {}, projectSetup.entry ); - const existOpenPath = visibleFiles.filter((path) => files[path]); + const environment = projectSetup.environment; + const existOpenPath = openPaths.filter((file) => files[file]); - return { - visibleFiles: existOpenPath, - activeFile, - files, - environment: projectSetup.environment, - }; + return { openPaths: existOpenPath, activePath, files, environment }; }; -export const resolveFile = ( - path: string, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - files: Record -): string | undefined => { - if (!path) return undefined; - - let resolvedPath = undefined; - - let index = 0; - const strategies = [".js", ".jsx", ".ts", ".tsx"]; - const leadingSlash = Object.keys(files).every((file) => file.startsWith("/")); +// The template is predefined (eg: react, vue, vanilla) +// The setup can overwrite anything from the template (eg: files, dependencies, environment, etc.) +export const getSetup = ( + template?: SandpackPredefinedTemplate, + inputSetup?: SandpackSetup +): SandboxTemplate => { + // The input setup might have files in the simple form Record + // so we convert them to the sandbox template format - while (!resolvedPath && index < strategies.length) { - const slashPath = (): string => { - if (path.startsWith("/")) { - return leadingSlash ? path : path.replace(/^\/+/, ""); - } - - return leadingSlash ? `/${path}` : path; - }; - const removeExtension = slashPath().split(".")[0]; - const attemptPath = `${removeExtension}${strategies[index]}`; - - if (files[attemptPath] !== undefined) { - resolvedPath = attemptPath; - } - - index++; - } - - return resolvedPath; -}; - -/** - * The template is predefined (eg: react, vue, vanilla) - * The setup can overwrite anything from the template (eg: files, dependencies, environment, etc.) - */ -export const getSetup = ({ - files, - template, - customSetup, -}: { - files?: SandpackFiles; - template?: SandpackPredefinedTemplate; - customSetup?: SandpackSetup; -}): SandboxTemplate => { - /** - * The input setup might have files in the simple form Record - * so we convert them to the sandbox template format - */ - const setup = createSetupFromUserInput({ customSetup, files }); + const setup = createSetupFromUserInput(inputSetup); if (!template) { // If not input, default to vanilla if (!setup) { - return SANDBOX_TEMPLATES.vanilla as SandboxTemplate; + return SANDBOX_TEMPLATES.vanilla; } if (!setup.files || Object.keys(setup.files).length === 0) { throw new Error( - `[sandpack-react]: without a template, you must pass at least one file` + `When using the customSetup without a template, you must pass at least one file for sandpack to work` ); } @@ -172,11 +122,9 @@ export const getSetup = ({ return setup as SandboxTemplate; } - const baseTemplate = SANDBOX_TEMPLATES[template] as SandboxTemplate; + const baseTemplate = SANDBOX_TEMPLATES[template]; if (!baseTemplate) { - throw new Error( - `[sandpack-react]: invalid template "${template}" provided` - ); + throw new Error(`Invalid template '${template}' provided.`); } // If no setup, the template is used entirely @@ -198,7 +146,7 @@ export const getSetup = ({ entry: setup.entry || baseTemplate.entry, main: setup.main || baseTemplate.main, environment: setup.environment || baseTemplate.environment, - } as SandboxTemplate; + }; }; export const convertedFilesToBundlerFiles = ( @@ -215,25 +163,23 @@ export const convertedFilesToBundlerFiles = ( }, {}); }; -export const createSetupFromUserInput = ({ - files, - customSetup, -}: { - files?: SandpackFiles; - customSetup?: SandpackSetup; -}): Partial | null => { - if (!files && !customSetup) { +export const createSetupFromUserInput = ( + setup?: SandpackSetup +): Partial | null => { + if (!setup) { return null; } - if (!files) { - return customSetup as Partial; + if (!setup.files) { + return setup as Partial; } + const { files } = setup; + const convertedFiles = convertedFilesToBundlerFiles(files); return { - ...customSetup, + ...setup, files: convertedFiles, }; }; diff --git a/sandpack-react/tsconfig.json b/sandpack-react/tsconfig.json index 1f2a4abbe..68192d669 100644 --- a/sandpack-react/tsconfig.json +++ b/sandpack-react/tsconfig.json @@ -14,10 +14,5 @@ "skipLibCheck": true }, "include": ["src"], - "exclude": [ - "src/**/*.stories.tsx", - "src/**/*.test.tsx", - "src/**/*.test.ts", - "node_modules" - ] + "exclude": ["src/**/*.stories.tsx", "src/**/*.test.tsx", "src/**/*.test.ts"] }