From 1deb64a799d4ad54104e76443bbc33b24f054f90 Mon Sep 17 00:00:00 2001 From: Shaoyu Meng Date: Wed, 1 Jul 2026 10:21:39 -0700 Subject: [PATCH] fix: SSR-free browser build so react-dom/server stays out of browser bundles The npm entry (dist/index.js) externalizes node_modules, so the lazy require("react-dom/server") in Viewer.renderToString survived into the shipped bundle. Any bundler resolving seqviz via main was then forced to resolve react-dom/server, pulling Node-only SSR code into browser bundles (#293). - Extract shared render/setState logic into src/baseViewer.ts (createBaseViewer; imports only react + react-dom/client, never react-dom/server). - Rebuild the node entry (src/index.ts) on the shared core, keeping the lazy require("react-dom/server") for renderToString. Output behavior unchanged. - Add src/index.browser.ts: same exports, but Viewer returns only { render, setState } and has no path to react-dom/server. - webpack: rename npmBuild -> nodeBuild, add browserBuild -> dist/index.browser.js. - package.json: add browser field + exports map with per-condition types (browser -> dist/index.browser.d.ts, no renderToString). Legacy main/browser/ types/unpkg fields retained; ./dist/* keeps deep imports working. Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 16 ++++++++++ src/baseViewer.ts | 76 ++++++++++++++++++++++++++++++++++++++++++++ src/index.browser.ts | 37 +++++++++++++++++++++ src/index.ts | 49 ++++------------------------ webpack.config.js | 25 +++++++++++++-- 5 files changed, 157 insertions(+), 46 deletions(-) create mode 100644 src/baseViewer.ts create mode 100644 src/index.browser.ts diff --git a/package.json b/package.json index 9ca6749d3..426f759e2 100644 --- a/package.json +++ b/package.json @@ -3,8 +3,24 @@ "description": "A viewer for DNA, RNA, and protein sequences that supports many input formats", "version": "3.10.22", "main": "dist/index.js", + "browser": "dist/index.browser.js", "types": "dist/index.d.ts", "unpkg": "dist/seqviz.min.js", + "exports": { + ".": { + "browser": { + "types": "./dist/index.browser.d.ts", + "default": "./dist/index.browser.js" + }, + "node": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "default": "./dist/index.js" + }, + "./package.json": "./package.json", + "./dist/*": "./dist/*" + }, "scripts": { "build": "rm -rf ./dist && webpack", "fix": "prettier ./src/** --write && eslint src --ext ts,tsx --fix", diff --git a/src/baseViewer.ts b/src/baseViewer.ts new file mode 100644 index 000000000..8df887bca --- /dev/null +++ b/src/baseViewer.ts @@ -0,0 +1,76 @@ +import * as React from "react"; +import { Root, createRoot } from "react-dom/client"; + +import SeqViz, { SeqVizProps } from "./SeqViz"; + +/** + * The base Viewer created by `createBaseViewer`. Shared by the node and browser + * entrypoints. Intentionally imports only `react` and `react-dom/client` so it + * is safe to bundle for the browser (no `react-dom/server` dependency). + */ +export interface BaseViewer { + /** The current React element (used by the node entry for SSR). */ + readonly element: React.ReactElement; + /** Render the Viewer to the element passed. */ + render: () => React.ReactElement; + /** Update the viewer with new settings. Re-renders if render was already called. */ + setState: (state: SeqVizProps) => React.ReactElement; +} + +/** + * Create a Viewer bound to a DOM element. Returns `render`, `setState` and a live + * `element` getter. The node entrypoint layers `renderToString` on top of this via + * `element`; the browser entrypoint exposes only `render` and `setState`. + */ +const createBaseViewer = (element: string | HTMLElement = "root", options: SeqVizProps): BaseViewer | undefined => { + // used to keep track of whether to re-render after a "set" call + let root: Root | null = null; + // get the HTML element by ID or use as is if passed directly + let domElement: HTMLElement | null; + if (!document) return; + + if (typeof element === "string") { + if (document.getElementById(element)) { + domElement = document.getElementById(element); + } else { + throw new Error(`Failed to find an element with ID: ${element}`); + } + } else { + domElement = element; + } + let viewer = React.createElement(SeqViz, options, null); + + /** + * Render the Viewer to the element passed + */ + const render = () => { + if (!root && domElement) { + root = createRoot(domElement); + } + root?.render(viewer); + return viewer; + }; + + /** + * Update the viewer with new settings. Re-renders if render was already called. + */ + const setState = (state: SeqVizProps) => { + options = { ...options, ...state }; + viewer = React.createElement(SeqViz, options, null); + + if (root) { + root.render(viewer); + } + return viewer; + }; + + return { + get element() { + return viewer; + }, + render, + setState, + }; +}; + +export default createBaseViewer; diff --git a/src/index.browser.ts b/src/index.browser.ts new file mode 100644 index 000000000..b86046e00 --- /dev/null +++ b/src/index.browser.ts @@ -0,0 +1,37 @@ +import Circular from "./Circular/Circular"; +import Linear from "./Linear/Linear"; +import SeqViz, { SeqVizProps } from "./SeqViz"; +import enzymes from "./enzymes"; +import createBaseViewer from "./baseViewer"; + +/** + * Export a React component directly for React-based development + */ +export { Circular, enzymes as Enzymes, Linear, SeqViz }; + +export default SeqViz; + +export type { SeqVizProps } from "./SeqViz"; + +export type { CircularProps } from "./Circular/Circular"; + +export type { LinearProps } from "./Linear/Linear"; + +/** + * Return a Viewer object with two properties: + * - `render` to an HTML element + * - `setState(options)` to update the viewer's internal state + */ +const Viewer = (element: string | HTMLElement = "root", options: SeqVizProps) => { + const baseViewer = createBaseViewer(element, options); + if (!baseViewer) return; + + const { render, setState } = baseViewer; + + return { + render, + setState, + }; +}; + +export { Viewer }; diff --git a/src/index.ts b/src/index.ts index 67f52d55d..f5899a32b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,10 +1,8 @@ -import * as React from "react"; -import { Root, createRoot } from "react-dom/client"; - import Circular from "./Circular/Circular"; import Linear from "./Linear/Linear"; import SeqViz, { SeqVizProps } from "./SeqViz"; import enzymes from "./enzymes"; +import createBaseViewer from "./baseViewer"; /** * Export a React component directly for React-based development @@ -26,33 +24,10 @@ export type { LinearProps } from "./Linear/Linear"; * - `renderToString` to return an HTML representation of the Viewer */ const Viewer = (element: string | HTMLElement = "root", options: SeqVizProps) => { - // used to keep track of whether to re-render after a "set" call - let root: Root | null = null; - // get the HTML element by ID or use as is if passed directly - let domElement: HTMLElement | null; - if (!document) return; - - if (typeof element === "string") { - if (document.getElementById(element)) { - domElement = document.getElementById(element); - } else { - throw new Error(`Failed to find an element with ID: ${element}`); - } - } else { - domElement = element; - } - let viewer = React.createElement(SeqViz, options, null); + const baseViewer = createBaseViewer(element, options); + if (!baseViewer) return; - /** - * Render the Viewer to the element passed - */ - const render = () => { - if (!root && domElement) { - root = createRoot(domElement); - } - root?.render(viewer); - return viewer; - }; + const { render, setState } = baseViewer; /** * Return an HTML string representation of the viewer @@ -63,20 +38,8 @@ const Viewer = (element: string | HTMLElement = "root", options: SeqVizProps) => // crash in Vite/Rollup browser builds. See: #293 // eslint-disable-next-line @typescript-eslint/no-require-imports const ReactDOMServer = require("react-dom/server"); - return ReactDOMServer.renderToString(viewer); - }; - - /** - * Update the viewer with new settings. Re-renders if render was already called. - */ - const setState = (state: SeqVizProps) => { - options = { ...options, ...state }; - viewer = React.createElement(SeqViz, options, null); - - if (root) { - root.render(viewer); - } - return viewer; + // read `baseViewer.element` (not a destructured copy) so it reflects the latest setState + return ReactDOMServer.renderToString(baseViewer.element); }; return { diff --git a/webpack.config.js b/webpack.config.js index 87e4b36d6..e3b360061 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -59,9 +59,9 @@ const cdnBuild = { }; /** - * npmBuild is the same as CDN build except node_modules are ignored as externals and the output filename differs. + * nodeBuild is the same as CDN build except node_modules are ignored as externals and the output filename differs. */ -const npmBuild = Object.assign({}, cdnBuild, { +const nodeBuild = Object.assign({}, cdnBuild, { mode: "none", devtool: "source-map", optimization: { @@ -81,4 +81,23 @@ const npmBuild = Object.assign({}, cdnBuild, { externals: [nodeExternals({ modulesDir: path.join(__dirname, "node_modules") })], }); -module.exports = [cdnBuild, npmBuild]; +/** + * browserBuild is the same as nodeBuild but built from the browser entrypoint + * (src/index.browser.ts), which never references react-dom/server. + */ +const browserBuild = Object.assign({}, nodeBuild, { + entry: path.join(__dirname, "src", "index.browser.ts"), + output: { + globalObject: "this", + filename: "index.browser.js", + library: { + name: package.name, + type: "umd", + }, + path: path.join(__dirname, "dist"), + publicPath: "/dist/", + umdNamedDefine: true, + }, +}); + +module.exports = [cdnBuild, nodeBuild, browserBuild];