Ship a build instead of sources, and keep the server renderer out of client bundles - #141
Merged
Conversation
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 innpm run dev: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.jsonemits an ESM tree intodist/esm; the runtime conditions point there andsrcis no longer published (the tarball drops from 326 files to 290).tsc rather than a bundler: the sources already import each other with
.jsspecifiers, 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
.jssource that has a hand-written.d.tssibling, treating the declaration as the authoritative module. That is 30 of 47 files here, and the result was adist/esmfull of dangling imports.scripts/copy-sources.mjscopies those, mirroring the existingcopy-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:
renderTransform.tsimported it to serialize JSX to DOM for the imperativerenderoption. It now builds nodes directly — the inverse ofdomToJsx.renderJSXonly ever emits intrinsic elements and fragments, so no renderer is required; a component element throws rather than emitting something subtly wrong.plot.tsheld bothcomputePlot(imported by<Plot>, so every consumer) and the imperativeplot()(which genuinely needs a renderer). react-dom declares nosideEffects: false, so a bundler keeps thereact-dom/serverimport of any module it pulls in, even when the binding is unused — tree-shaking could not save us.plot()moves toplotDom.ts, leavingcomputePlotclean.Measured on a real consumer
Tarball installed into the example app, with its
optimizeDepsworkaround removed:Production preview also renders 11 charts with 0 errors, and strict
tsc -bin 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.tsxadds four tests over the DOMnext()hands back — namespace, camelCase-to-hyphenated attributes,classNametoclass, and the output reaching the document. Mutating the attribute mapping fails one of them, so they are not vacuous.yarn test:mocha1567 passing (up from 1563), tsc/lint/prettier clean,publint --strictall good,attwgreen for both entries.Follow-up
replot-vite-examplestill carries theoptimizeDeps.includeworkaround 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 published0.1.0-alpha.0.🤖 Generated with Claude Code