Skip to content

Ship a build instead of sources, and keep the server renderer out of client bundles - #141

Merged
dave-hillier merged 2 commits into
mainfrom
fix-140-ship-build
Jul 30, 2026
Merged

Ship a build instead of sources, and keep the server renderer out of client bundles#141
dave-hillier merged 2 commits into
mainfrom
fix-140-ship-build

Conversation

@dave-hillier

Copy link
Copy Markdown
Owner

Closes #140.

The breakage

The runtime conditions resolved to src/**, so a consumer's bundler was handed raw .ts/.tsx. Vite therefore did not treat replot as an optimisable dependency, and its CommonJS dependencies reached the browser unconverted. A stock Vite app rendered nothing in npm run dev:

The requested module '/node_modules/react-dom/server.browser.js' does not provide an export named 'renderToStaticMarkup'
The requested module '/node_modules/interval-tree-1d/interval-tree.js' does not provide an export named 'default'

Production builds were unaffected, which is exactly why it survived CI and would have hit every new user on their first npm run dev.

Shipping a build

tsc -p tsconfig.esm.json emits an ESM tree into dist/esm; the runtime conditions point there and src is no longer published (the tarball drops from 326 files to 290).

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.

One wrinkle worth knowing about — tsc silently declines to emit a .js source that has a hand-written .d.ts sibling, treating the declaration as the authoritative module. That is 30 of 47 files here, and the result was a dist/esm full of dangling imports. scripts/copy-sources.mjs copies those, mirroring the existing copy-declarations.mjs. Verified afterwards by resolving every relative specifier in the emitted tree: 141 modules, 0 dangling.

Keeping react-dom/server out of client bundles

Two separate paths pulled it in:

  1. renderTransform.ts imported it to serialize JSX to DOM for the imperative render option. It now builds nodes directly — the inverse of domToJsx. renderJSX only ever emits intrinsic elements and fragments, so no renderer is required; a component element throws rather than emitting something subtly wrong.
  2. plot.ts held both computePlot (imported by <Plot>, so every consumer) and the imperative plot() (which genuinely needs a renderer). 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 — tree-shaking could not save us. plot() moves to plotDom.ts, leaving computePlot clean.

Measured on a real consumer

Tarball installed into the example app, with its optimizeDeps workaround removed:

before after
dev server fails, 0 charts 11 charts, 0 console errors
client bundle 788 kB / 256 kB gzip 601 kB / 197 kB gzip
react-dom/server modules bundled 3 0

Production preview also renders 11 charts with 0 errors, and strict tsc -b in the consumer stays clean.

Tests

The render-transform path had no coverage at all, so the walker would have been an unverified rewrite. test/react-render-transform-test.tsx adds four tests over the DOM next() hands back — namespace, camelCase-to-hyphenated attributes, className to class, and the output reaching the document. Mutating the attribute mapping fails one of them, so they are not vacuous.

yarn test:mocha 1567 passing (up from 1563), tsc/lint/prettier clean, publint --strict all good, attw green for both entries.

Follow-up

replot-vite-example still carries the optimizeDeps.include workaround and documents it. It should be removed once a version containing this fix is published — doing it now would break the example against the currently published 0.1.0-alpha.0.

🤖 Generated with Claude Code

dave-hillier and others added 2 commits July 30, 2026 07:57
…client bundles

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 <Plot>) 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
@dave-hillier
dave-hillier merged commit bf7468a into main Jul 30, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Published package breaks the Vite dev server: CJS deps reach the browser unconverted

1 participant