From 1e2776189d533e894882003e1424ac824b5be8db Mon Sep 17 00:00:00 2001 From: Dave Hillier Date: Thu, 30 Jul 2026 07:57:53 +0100 Subject: [PATCH 1/2] Ship a build instead of sources, and keep the server renderer out of client bundles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #140. The package resolved its runtime conditions to src/**, so a consumer's bundler was handed raw .ts/.tsx. Vite consequently did not pre-bundle replot, and its CommonJS dependencies reached the browser unconverted: the dev server of a stock Vite app died on react-dom/server.browser.js and then interval-tree-1d, rendering nothing. Production builds were unaffected, which is why it survived CI. tsc now emits an ESM tree into dist/esm and the runtime conditions point there; src is no longer published. tsc rather than a bundler because the sources already import each other with .js specifiers, so a structure-preserving emit resolves as-is and every dependency stays external without an allowlist. tsc declines to emit the plain-JS sources that have a hand-written .d.ts sibling — 30 of 47 — so scripts/copy-sources.mjs copies those, mirroring copy-declarations.mjs. Without it dist/esm is full of dangling imports. The second half of the issue was react-dom/server reaching client bundles. Two paths pulled it in: - renderTransform.ts imported it to serialize JSX to DOM for the imperative `render` option. It now builds the nodes directly, the inverse of domToJsx. renderJSX only ever emits intrinsic elements and fragments, so no renderer is needed; component elements throw rather than emit something subtly wrong. - plot.ts held both computePlot (imported by ) and the imperative plot() (which does need a renderer). react-dom declares no `sideEffects: false`, so bundlers keep the import of any module they pull in even when the binding is unused. plot() moves to plotDom.ts, leaving computePlot free of it. Measured on a Vite consumer: the dev server now works with no optimizeDeps workaround, and the client bundle drops from 788 kB to 601 kB (gzip 256 kB to 197 kB) with zero react-dom/server modules. The render-transform path had no test coverage, so the walker arrives with four; mutating the attribute mapping fails one, so they bite. Co-Authored-By: Claude Opus 5 (1M context) --- package.json | 20 +++--- scripts/copy-sources.mjs | 45 ++++++++++++ src/index.d.ts | 1 + src/index.ts | 4 +- src/plot.ts | 87 +---------------------- src/plotDom.ts | 96 +++++++++++++++++++++++++ src/react/renderTransform.ts | 89 ++++++++++++++++++++---- test/react-render-transform-test.tsx | 100 +++++++++++++++++++++++++++ tsconfig.esm.json | 17 +++++ 9 files changed, 347 insertions(+), 112 deletions(-) create mode 100644 scripts/copy-sources.mjs create mode 100644 src/plotDom.ts create mode 100644 test/react-render-transform-test.tsx create mode 100644 tsconfig.esm.json diff --git a/package.json b/package.json index f05dbf05..3440acbb 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,19 @@ }, "license": "ISC", "type": "module", - "main": "src/index.ts", - "module": "src/index.ts", + "main": "dist/esm/index.js", + "module": "dist/esm/index.js", "jsdelivr": "dist/replot.umd.min.js", "unpkg": "dist/replot.umd.min.js", "exports": { ".": { "types": "./dist/types/index.d.ts", "umd": "./dist/replot.umd.min.js", - "default": "./src/index.ts" + "default": "./dist/esm/index.js" }, "./react": { "types": "./dist/types/react/index.d.ts", - "default": "./src/react/index.tsx" + "default": "./dist/esm/react/index.js" }, "./package.json": "./package.json" }, @@ -46,11 +46,7 @@ }, "files": [ "dist/**/*.js", - "dist/types/**/*.d.ts", - "src/**/*.d.ts", - "src/**/*.js", - "src/**/*.ts", - "src/**/*.tsx" + "dist/types/**/*.d.ts" ], "scripts": { "test": "yarn test:mocha && yarn test:tsc && yarn test:lint && yarn test:prettier && yarn test:package", @@ -63,7 +59,8 @@ "test:prettier": "prettier --check src test", "test:tsc": "tsc", "build:types": "tsc -p tsconfig.build.json && node scripts/copy-declarations.mjs", - "bundle": "vite build --config vite.bundle.config.ts && MINIFY=1 vite build --config vite.bundle.config.ts && yarn build:types", + "build:esm": "tsc -p tsconfig.esm.json && node scripts/copy-sources.mjs", + "bundle": "vite build --config vite.bundle.config.ts && MINIFY=1 vite build --config vite.bundle.config.ts && yarn build:esm && yarn build:types", "prepublishOnly": "rm -rf dist && yarn bundle", "dev": "vite", "docs:dev": "tsx docs/scripts/ensure-bundle.ts && tsx docs/scripts/build-api-data.ts && vite --config docs/vite.docs.config.ts", @@ -71,8 +68,7 @@ "docs:preview": "vite preview --config docs/vite.docs.config.ts" }, "sideEffects": [ - "./src/index.js", - "./src/index.ts" + "./dist/esm/index.js" ], "devDependencies": { "@arethetypeswrong/cli": "^0.18.2", diff --git a/scripts/copy-sources.mjs b/scripts/copy-sources.mjs new file mode 100644 index 00000000..57d812d6 --- /dev/null +++ b/scripts/copy-sources.mjs @@ -0,0 +1,45 @@ +// Copies the plain-JavaScript sources that `tsc -p tsconfig.esm.json` declines +// to emit (see issue #140). +// +// Most of the core is plain .js with a sibling hand-written .d.ts +// (src/channel.js + src/channel.d.ts, …). TypeScript treats the .d.ts as the +// authoritative module for that specifier and skips compiling the .js, so +// without this step dist/esm is full of dangling imports. The files are +// already ESM, so copying is the whole job. +// +// The companion for declarations is scripts/copy-declarations.mjs. +import {mkdir, copyFile, readdir, access} from "node:fs/promises"; +import {dirname, join, relative} from "node:path"; +import {fileURLToPath} from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const source = join(root, "src"); +const target = join(root, "dist", "esm"); + +async function* sources(dir) { + for (const entry of await readdir(dir, {withFileTypes: true})) { + const path = join(dir, entry.name); + if (entry.isDirectory()) yield* sources(path); + else if (entry.name.endsWith(".js")) yield path; + } +} + +async function exists(path) { + try { + await access(path); + return true; + } catch { + return false; + } +} + +let copied = 0; +for await (const path of sources(source)) { + const destination = join(target, relative(source, path)); + if (await exists(destination)) continue; // tsc emitted it; leave that alone + await mkdir(dirname(destination), {recursive: true}); + await copyFile(path, destination); + copied++; +} + +console.log(`copy-sources: copied ${copied} JavaScript source${copied === 1 ? "" : "s"} into dist/esm`); diff --git a/src/index.d.ts b/src/index.d.ts index 00f3c967..3710a9e3 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -41,6 +41,7 @@ export * from "./marks/vector.js"; export * from "./marks/waffle.js"; export {valueof, column, identity, indexOf} from "./options.js"; export * from "./plot.js"; +export * from "./plotDom.js"; export * from "./projection.js"; export * from "./reducer.js"; export * from "./scales.js"; diff --git a/src/index.ts b/src/index.ts index ee138c0e..e14feabe 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,12 +1,12 @@ import {Mark} from "./mark.js"; -import {plot} from "./plot.js"; +import {plot} from "./plotDom.js"; // Note: this side effect avoids a circular dependency. Mark.prototype.plot = function ({marks = [], ...options} = {}) { return plot({...options, marks: [...marks, this]}); }; -export {plot} from "./plot.js"; +export {plot} from "./plotDom.js"; export {Mark, marks} from "./mark.js"; export {Area, area, areaX, areaY} from "./marks/area.js"; export {Arrow, arrow} from "./marks/arrow.js"; diff --git a/src/plot.ts b/src/plot.ts index 2999f6bf..8e007929 100644 --- a/src/plot.ts +++ b/src/plot.ts @@ -4,7 +4,6 @@ import {createContext} from "./context.js"; import {createDimensions} from "./dimensions.js"; import {createFacets, recreateFacets, facetExclude, facetGroups, facetTranslator, facetFilter} from "./facet.js"; import {pointer, pointerX, pointerY} from "./interactions/pointer.js"; -import {buildAutoLegends, renderLegendElement} from "./react/legends/Legend.js"; import {Mark} from "./mark.js"; import {axisFx, axisFy, axisX, axisY, gridFx, gridFy, gridX, gridY} from "./marks/axis.js"; import {frame} from "./marks/frame.js"; @@ -12,14 +11,12 @@ import {tip} from "./marks/tip.js"; import {isColor, isIterable, isNone, isScaleOptions} from "./options.js"; import {dataify, lengthof, map, yes, maybeIntervalTransform} from "./options.js"; import {createProjection, getGeometryChannels, hasProjection, xyProjection} from "./projection.js"; -import {createScales, createScaleFunctions, autoScaleRange, exposeScales} from "./scales.js"; +import {createScales, createScaleFunctions, autoScaleRange} from "./scales.js"; import {innerDimensions, outerDimensions} from "./scales.js"; import {isPosition, registry as scaleRegistry} from "./scales/index.js"; import {maybeClassName} from "./style.js"; import {initializer} from "./transforms/basic.js"; -import {consumeWarnings, warn} from "./warnings.js"; -import {renderToStaticMarkup} from "react-dom/server"; -import {buildStaticPlotSvg} from "./react/renderStatic.js"; +import {warn} from "./warnings.js"; // Returns the pre-render state needed by both the imperative DOM build path // (used by `plot()` below) and the React JSX path (used by `` in @@ -276,86 +273,6 @@ export function computePlot(options: any = {}): any { }; } -export function plot(options: any = {}) { - const computed: any = computePlot(options); - const {className, scales, scaleDescriptors, context} = computed; - const {style, title, subtitle, caption} = options; - const document = context.document; - const figureHolder: {current: any} = context.figureHolder; - - // Drain warnings emitted during computePlot so the ⚠️ indicator renders and - // the warn() dedupe state is reset (matching the React path). - const warnings = consumeWarnings(); - - // Render the marks to a detached via React's renderJSX — no - // d3-selection. The same renderMarksWith/renderJSX code powers , so - // the imperative and JSX outputs stay in lockstep. We serialize to markup - // and reparse into the target document (which may be a custom jsdom doc). - const markup = renderToStaticMarkup(buildStaticPlotSvg(computed, warnings, options.className)); - const holder = document.createElement("div"); - holder.innerHTML = markup; - const svg: any = holder.firstElementChild; - - // Apply the plot-level style option (string or object), mirroring - // applyInlineStyles on the former imperative path. - if (typeof style === "string") svg.setAttribute("style", style); - else if (style != null) Object.assign(svg.style, style); - - figureHolder.current = svg; - - // Wrap the plot in a figure, if needed. Auto-legends render via the React - // legend components (no d3-selection); serialize each to a DOM node in the - // target document, matching the former createLegends output. - const legends = buildAutoLegends(scaleDescriptors, context, options).map((el) => { - const h = document.createElement("div"); - h.innerHTML = renderToStaticMarkup(el); - return h.firstElementChild; - }); - const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options; - if (figured) { - const fig: any = document.createElement("figure"); - fig.className = `${className}-figure`; - fig.style.maxWidth = "initial"; // avoid Observable default style - if (title != null) fig.append(createTitleElement(document, title, "h2")); - if (subtitle != null) fig.append(createTitleElement(document, subtitle, "h3")); - fig.append(...legends, svg); - if (caption != null) fig.append(createFigcaption(document, caption)); - if ("value" in svg) (fig.value = svg.value), delete svg.value; - figureHolder.current = fig; - } - - figureHolder.current.scale = exposeScales(scales.scales); - // The .legend(key, options) method renders via the React legend components - // (no d3-selection); serialize to a DOM node in the target document. - figureHolder.current.legend = (key: string, legendOptions: any = {}) => { - if (key !== "color" && key !== "opacity" && key !== "symbol") throw new Error(`unknown legend type: ${key}`); - if (!(key in scaleDescriptors)) return; - const el = renderLegendElement(key, legendOptions, scaleDescriptors, context, options); - if (el == null) return; - // Render into the per-call document option if given (e.g. a separate jsdom - // window), else the plot's document. - const targetDoc = legendOptions?.document ?? document; - const h = targetDoc.createElement("div"); - h.innerHTML = renderToStaticMarkup(el); - return h.firstElementChild; - }; - - return figureHolder.current; -} - -function createTitleElement(document, contents, tag) { - if (contents.ownerDocument) return contents; - const e = document.createElement(tag); - e.append(contents); - return e; -} - -function createFigcaption(document, caption) { - const e = document.createElement("figcaption"); - e.append(caption); - return e; -} - function flatMarks(marks) { return marks .flat(Infinity) diff --git a/src/plotDom.ts b/src/plotDom.ts new file mode 100644 index 00000000..a1fe2051 --- /dev/null +++ b/src/plotDom.ts @@ -0,0 +1,96 @@ +import {buildAutoLegends, renderLegendElement} from "./react/legends/Legend.js"; +import {exposeScales} from "./scales.js"; +import {consumeWarnings} from "./warnings.js"; +import {computePlot} from "./plot.js"; +import {renderToStaticMarkup} from "react-dom/server"; +import {buildStaticPlotSvg} from "./react/renderStatic.js"; + +// The imperative entry point: plot() builds real DOM, so it is the only part +// of the core that needs a renderer capable of serializing React elements. +// +// It lives apart from computePlot (src/plot.ts) deliberately. imports +// computePlot, and react-dom declares no `sideEffects: false`, so a bundler +// keeps the react-dom/server import of any module it pulls in — even when the +// binding is unused. Sharing a module with computePlot therefore put the whole +// server renderer in the client bundle of every app that renders a plot +// (issue #140). + +export function plot(options: any = {}) { + const computed: any = computePlot(options); + const {className, scales, scaleDescriptors, context} = computed; + const {style, title, subtitle, caption} = options; + const document = context.document; + const figureHolder: {current: any} = context.figureHolder; + + // Drain warnings emitted during computePlot so the ⚠️ indicator renders and + // the warn() dedupe state is reset (matching the React path). + const warnings = consumeWarnings(); + + // Render the marks to a detached via React's renderJSX — no + // d3-selection. The same renderMarksWith/renderJSX code powers , so + // the imperative and JSX outputs stay in lockstep. We serialize to markup + // and reparse into the target document (which may be a custom jsdom doc). + const markup = renderToStaticMarkup(buildStaticPlotSvg(computed, warnings, options.className)); + const holder = document.createElement("div"); + holder.innerHTML = markup; + const svg: any = holder.firstElementChild; + + // Apply the plot-level style option (string or object), mirroring + // applyInlineStyles on the former imperative path. + if (typeof style === "string") svg.setAttribute("style", style); + else if (style != null) Object.assign(svg.style, style); + + figureHolder.current = svg; + + // Wrap the plot in a figure, if needed. Auto-legends render via the React + // legend components (no d3-selection); serialize each to a DOM node in the + // target document, matching the former createLegends output. + const legends = buildAutoLegends(scaleDescriptors, context, options).map((el) => { + const h = document.createElement("div"); + h.innerHTML = renderToStaticMarkup(el); + return h.firstElementChild; + }); + const {figure: figured = title != null || subtitle != null || caption != null || legends.length > 0} = options; + if (figured) { + const fig: any = document.createElement("figure"); + fig.className = `${className}-figure`; + fig.style.maxWidth = "initial"; // avoid Observable default style + if (title != null) fig.append(createTitleElement(document, title, "h2")); + if (subtitle != null) fig.append(createTitleElement(document, subtitle, "h3")); + fig.append(...legends, svg); + if (caption != null) fig.append(createFigcaption(document, caption)); + if ("value" in svg) (fig.value = svg.value), delete svg.value; + figureHolder.current = fig; + } + + figureHolder.current.scale = exposeScales(scales.scales); + // The .legend(key, options) method renders via the React legend components + // (no d3-selection); serialize to a DOM node in the target document. + figureHolder.current.legend = (key: string, legendOptions: any = {}) => { + if (key !== "color" && key !== "opacity" && key !== "symbol") throw new Error(`unknown legend type: ${key}`); + if (!(key in scaleDescriptors)) return; + const el = renderLegendElement(key, legendOptions, scaleDescriptors, context, options); + if (el == null) return; + // Render into the per-call document option if given (e.g. a separate jsdom + // window), else the plot's document. + const targetDoc = legendOptions?.document ?? document; + const h = targetDoc.createElement("div"); + h.innerHTML = renderToStaticMarkup(el); + return h.firstElementChild; + }; + + return figureHolder.current; +} + +function createTitleElement(document, contents, tag) { + if (contents.ownerDocument) return contents; + const e = document.createElement(tag); + e.append(contents); + return e; +} + +function createFigcaption(document, caption) { + const e = document.createElement("figcaption"); + e.append(caption); + return e; +} diff --git a/src/react/renderTransform.ts b/src/react/renderTransform.ts index 3857a724..6703313f 100644 --- a/src/react/renderTransform.ts +++ b/src/react/renderTransform.ts @@ -1,5 +1,4 @@ -import {createElement as h, Fragment, type ReactNode} from "react"; -import {renderToStaticMarkup} from "react-dom/server"; +import {type ReactNode} from "react"; import {domToJsx, isDomNode} from "./domToJsx.js"; // Bridges the imperative `render` option (a render transform: DOM in, DOM @@ -39,18 +38,82 @@ export function renderTransformJSX( return isDomNode(out) ? domToJsx(out) : (out as ReactNode); } -// Serializes a JSX tree and reparses it in an context so the resulting -// nodes carry the SVG namespace, mirroring how renderStatic.tsx serializes -// the whole plot for the imperative entry point. +// Builds DOM nodes directly from a JSX tree, in the SVG namespace. This is the +// inverse of domToJsx, and deliberately does not go through +// renderToStaticMarkup: importing react-dom/server here would put the whole +// server renderer in the client bundle of every app that renders a plot, for +// the sake of a path only reached when a mark carries an imperative `render` +// transform (see issue #140). +// +// Only the shapes mark.renderJSX produces are supported — intrinsic elements, +// fragments, arrays and text. A component element would need a renderer; +// buildElement throws rather than emitting something subtly wrong. +const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; + function jsxToDom(jsx: ReactNode, document: Document): Node | null { - const holder = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - holder.innerHTML = renderToStaticMarkup(h(Fragment, null, jsx)); - if (holder.childNodes.length === 1) { - const node = holder.firstChild!; - holder.removeChild(node); - return node; - } + const nodes: Node[] = []; + appendJsx(nodes, jsx, document); + if (nodes.length === 1) return nodes[0]!; const fragment = document.createDocumentFragment(); - while (holder.firstChild) fragment.appendChild(holder.firstChild); + for (const node of nodes) fragment.appendChild(node); return fragment; } + +function appendJsx(nodes: Node[], jsx: ReactNode, document: Document): void { + if (jsx == null || typeof jsx === "boolean") return; + if (typeof jsx === "string" || typeof jsx === "number") { + nodes.push(document.createTextNode(String(jsx))); + return; + } + if (Array.isArray(jsx)) { + for (const child of jsx) appendJsx(nodes, child as ReactNode, document); + return; + } + const element = jsx as {type?: unknown; props?: Record}; + const {type, props = {}} = element; + // A fragment contributes its children and nothing of its own. + if (typeof type !== "string") { + if (type != null && typeof type !== "function") { + appendJsx(nodes, props.children as ReactNode, document); + return; + } + throw new Error("render transforms cannot serialize component elements"); + } + nodes.push(buildElement(type, props, document)); +} + +function buildElement(type: string, props: Record, document: Document): Element { + const node = document.createElementNS(SVG_NAMESPACE, type); + for (const [name, value] of Object.entries(props)) { + if (name === "children" || name === "key" || name === "ref") continue; + if (value == null || value === false || typeof value === "function") continue; + if (name === "dangerouslySetInnerHTML") { + node.innerHTML = String((value as {__html?: unknown}).__html ?? ""); + continue; + } + node.setAttribute(attributeName(name), name === "style" ? styleText(value) : String(value)); + } + const children: Node[] = []; + appendJsx(children, props.children as ReactNode, document); + for (const child of children) node.appendChild(child); + return node; +} + +// The inverse of domToJsx's reactAttributeName: React's camelCase props map +// back to the hyphenated attributes SVG expects. aria-*/data-* and already +// hyphenated names pass through, as do namespaced ones (xlink:href). +function attributeName(name: string): string { + if (name === "className") return "class"; + if (name === "htmlFor") return "for"; + if (name.startsWith("aria-") || name.startsWith("data-") || name.includes("-") || name.includes(":")) return name; + return name.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`); +} + +// React accepts the style prop as an object; the DOM wants a string. +function styleText(value: unknown): string { + if (typeof value === "string") return value; + return Object.entries(value as Record) + .filter(([, v]) => v != null && v !== "") + .map(([property, v]) => `${property.startsWith("--") ? property : attributeName(property)}: ${v}`) + .join("; "); +} diff --git a/test/react-render-transform-test.tsx b/test/react-render-transform-test.tsx new file mode 100644 index 00000000..2577adf4 --- /dev/null +++ b/test/react-render-transform-test.tsx @@ -0,0 +1,100 @@ +// @ts-nocheck — JSDOM React tests for the imperative `render` option on the +// JSX path. The interesting part is the DOM the transform's `next` hands back: +// it is built by jsxToDom in src/react/renderTransform.ts, which serializes +// React elements to SVG-namespaced nodes without going through +// renderToStaticMarkup (see issue #140). +import assert from "assert"; +import React from "react"; +import jsdomit from "./jsdom.js"; +import ReactDOM from "react-dom/client"; +import {act} from "react"; +import {Replot, Dot} from "../src/react/index.js"; + +const data = [ + {x: 1, y: 2}, + {x: 2, y: 3}, + {x: 3, y: 1} +]; + +async function mount(node) { + const container = globalThis.document.createElement("div"); + globalThis.document.body.appendChild(container); + let root; + await act(async () => { + root = ReactDOM.createRoot(container); + root.render(node); + }); + await act(async () => {}); + return { + container, + cleanup: async () => { + await act(async () => root.unmount()); + container.remove(); + } + }; +} + +// Renders a plot whose dot mark carries a render transform, and hands the +// transform's `next()` output to the assertions. +async function withNextOutput(assertions, markProps = {}) { + let produced = null; + const render = (index, scales, values, dimensions, context, next) => { + produced = next(index, scales, values, dimensions, context); + return produced; + }; + const {container, cleanup} = await mount( + + + + ); + assert.ok(produced, "expected the render transform to receive DOM from next()"); + try { + assertions(produced, container); + } finally { + await cleanup(); + } +} + +jsdomit("next() returns SVG-namespaced element nodes", async () => { + await withNextOutput((produced) => { + const element = produced.nodeType === 11 ? produced.firstChild : produced; + assert.strictEqual(element.namespaceURI, "http://www.w3.org/2000/svg"); + assert.ok(element.querySelector("circle") ?? element.tagName === "circle", "expected circles in the output"); + }); +}); + +jsdomit("camelCase React props are serialized as hyphenated SVG attributes", async () => { + await withNextOutput( + (produced) => { + const holder = globalThis.document.createElementNS("http://www.w3.org/2000/svg", "svg"); + holder.appendChild(produced.cloneNode(true)); + const painted = holder.querySelector("[stroke-width]") ?? holder.querySelector("g"); + assert.ok(painted, "expected a rendered element"); + // The React prop is strokeWidth; the DOM attribute must be stroke-width, + // and must not appear in its camelCase spelling. + assert.strictEqual(holder.innerHTML.includes("strokeWidth"), false, "camelCase leaked into the DOM"); + }, + {strokeWidth: 3, stroke: "red"} + ); +}); + +jsdomit("className becomes class, and text children survive", async () => { + await withNextOutput( + (produced) => { + const holder = globalThis.document.createElementNS("http://www.w3.org/2000/svg", "svg"); + holder.appendChild(produced.cloneNode(true)); + assert.strictEqual(holder.innerHTML.includes("className"), false, "className leaked into the DOM"); + const titled = holder.querySelector("title"); + if (titled) assert.ok(titled.textContent.length > 0, "expected title text to survive"); + }, + {title: (d) => `point ${d.x}`} + ); +}); + +jsdomit("the rendered plot still contains the transform's output", async () => { + await withNextOutput((produced, container) => { + const svg = container.querySelector("svg"); + assert.ok(svg, "expected the plot to render"); + assert.ok(svg.querySelectorAll("circle").length >= data.length, "expected the dots to reach the document"); + }); +}); diff --git a/tsconfig.esm.json b/tsconfig.esm.json new file mode 100644 index 00000000..cd590391 --- /dev/null +++ b/tsconfig.esm.json @@ -0,0 +1,17 @@ +{ + // JavaScript build. Consumers resolve the runtime conditions in package.json + // to dist/esm rather than to src, so the package ships transpiled ESM and no + // bundler is asked to process a dependency's raw .ts/.tsx (see issue #140). + // + // tsc rather than a bundler: the sources already import each other with .js + // specifiers, so a structure-preserving emit resolves correctly as-is, and + // every dependency stays external without an allowlist to maintain. + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": false, + "outDir": "dist/esm", + "rootDir": "src" + }, + "include": ["src/**/*.js", "src/**/*.ts", "src/**/*.tsx"] +} From 716549c8447f534e89bff87d4fc2e1e849b1b760 Mon Sep 17 00:00:00 2001 From: Dave Hillier Date: Thu, 30 Jul 2026 08:04:04 +0100 Subject: [PATCH 2/2] Resolve the self-referenced package name to sources, not to dist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests import the package by name, and the paths mapping that is supposed to point that at src silently failed to match: under Node16 resolution "index" is not a candidate for a self-reference, so TypeScript fell through to package.json instead. That went unnoticed while main pointed at src/index.ts — it resolved to the same file by a different route. Pointing main at dist/esm/index.js broke it: a fresh checkout has no dist, so tsc could not resolve the package at all. It passed locally only because dist happened to be built, which is exactly the kind of pass that should not be trusted. Mapping to concrete files makes the typecheck independent of build artefacts, so a clean clone typechecks before anything is built. Verified both ways: green with dist absent and with dist present. Co-Authored-By: Claude Opus 5 (1M context) --- tsconfig.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tsconfig.json b/tsconfig.json index db0927fa..c24ee8fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,8 +8,8 @@ "baseUrl": "./src", "jsx": "react-jsx", "paths": { - "@dave-hillier/replot": ["index"], - "@dave-hillier/replot/react": ["react/index"], + "@dave-hillier/replot": ["./index.ts"], + "@dave-hillier/replot/react": ["./react/index.tsx"], "react": ["../node_modules/@types/react"], "react/jsx-runtime": ["../node_modules/@types/react/jsx-runtime"], "react-dom": ["../node_modules/@types/react-dom"],