Repository navigation
refactor: split Brushless.res per module to make the package treeshakeable - #7
Merged
Merged
Conversation
…eable
Nothing could be dropped from this package before: importing one 7-line
constructor pulled in 9,314 B of a 9,658 B library. Two causes, both fixed.
genType emitted every export as `BrushlessJS.Condition.equals` — a two-level
member access on a namespace object, which no bundler can prove side-effect
free, so a single import retained the whole graph. That shape was forced by
having every module nested inside one file. Splitting Brushless.res into one
file per module makes genType emit the single-level `ConditionJS.equals` form,
which is individually droppable.
The public API was also namespace *objects*, and an object property is not a
unit a bundler can drop. Each module now has its own entry point, so the same
call syntax treeshakes:
import * as C from 'brushless/condition' // C.equals(...)
rescript.json gets `namespace: true`, which keeps the ReScript-side
`Brushless.Condition.*` paths working and emits no JS. src/index.ts stays as a
compatibility barrel exporting every name it did before.
Bundler switched from tsup to tsdown (rolldown). tsup could not produce a
treeshakeable multi-entry build: with splitting on, entries pull shared chunks
in via bare `import './chunk-*.mjs'` statements and consumers retain the whole
chunk; with it off, entries shake but duplicate every shared leaf module.
rolldown emits named re-exports, so entries share code and still shake — and
the root import became treeshakeable too, which it never was under tsup.
Measured with esbuild (ESM, minified, un-gzipped):
one AttributeName helper 9,314 B -> 73 B
one Condition helper 9,314 B -> 92 B
Condition.build + deps 9,314 B -> 3,750 B
cond + proj + register + path 9,314 B -> 4,419 B
every builder via subpaths 9,314 B -> 5,204 B
root import, one name 9,314 B -> 506 B
Also adds sideEffects: false, and a prepublishOnly build hook — dist/ is
gitignored but published, so publishing from a stale tree shipped a stale
artifact silently.
BREAKING (ReScript consumers only): the shared types moved out of the root
module into Brushless.Types — Brushless.attributeValue_, .attributeValue and
.comparator are now Brushless.Types.*. Everything else resolves as before,
including the C/K/U/P aliases and Condition.Overload. TypeScript consumers are
unaffected; the root import still exports all three.
Verified: 101 tests pass against unchanged spec files; examples/ compiles with
Overload_test at 12 PASS / 0 FAIL; a strict nodenext consumer typechecks
against both the subpaths and the barrel with skipLibCheck off; ESM and CJS
both produce correct expressions at runtime; dist has zero external imports.
josher8a
force-pushed
the
refactor/treeshakeable-module-split
branch
from
July 31, 2026 18:08
7548aa0 to
bd0a41e
Compare
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.
Problem
Importing one 7-line constructor pulled in 9,314 B of a 9,658 B library.
sideEffects: falsealone did not fix it. Two causes:BrushlessJS.Condition.equals. A two-level member access on a namespace object cannot be proven side-effect free, so unused exports survived and each one pinned a whole module. That shape was forced by keeping every module inside one.resfile.C.equalsretained all 20 condition functions.Changes
src/Brushless.resinto one file per module. genType now emits the single-levelConditionJS.equalsform, which is individually droppable.rescript.jsongetsnamespace: true, so ReScript'sBrushless.Condition.*paths keep working. It emits no JS.src/index.tsremains a compatibility barrel with every name it exported before.splitting: trueentries pull shared chunks in via bareimport './chunk-*.mjs'statements and consumers keep the whole chunk (5,262 B whether one function is used or all of them), and withsplitting: falseentries shake but duplicate every shared leaf module (10,109 B for a consumer using all builders, worse than importing everything). rolldown emits named re-exports, so entries share code and still shake.sideEffects: falseand aprepublishOnlybuild hook.dist/is gitignored but published, and the checked-out copy was a month stale with aMarshallermodule that no longer exists in source.Measured
esbuild, ESM, minified, un-gzipped. Whole library is ~9.4 KB.
AttributeNamehelperConditionhelperCondition.build+ depsKeyCondition.build+ depsExisting code gets most of this with no changes: the root import went from 9,314 B to 506 B with the specifier untouched. Per-module entries still win when you reach for a module object, since
C.equalsfrom the root costs 5,468 B against 92 B throughbrushless/condition.Breaking (ReScript consumers only)
The shared types moved out of the root module into
Brushless.Types:Brushless.attributeValue_becomesBrushless.Types.attributeValue_Brushless.attributeValuebecomesBrushless.Types.attributeValueBrushless.comparatorbecomesBrushless.Types.comparatorUnchanged:
Brushless.Condition.*, theC/K/U/Paliases, andCondition.Overload. TypeScript consumers are unaffected, since the root import still exports all three. The one reference in the repo (examples/DefaultMarshaller.res) is updated here.namespace: truecannot re-expose values at the namespace root. The alternative, a hand-written facade withnamespace: false, would leakCondition,Register,Types,C,K,U,Pinto every ReScript consumer's global namespace.Output extensions are now
.mjsand.cjswith.d.mtsand.d.ctsdeclarations. Import specifiers are unchanged.Verified
examples/compiles;Overload_testis 12 PASS / 0 FAILnodenextconsumer typechecks withskipLibCheckoff, covering subpaths, the barrel, and the compat type aliasesdist/has zero external imports; thePrimitive_optionhelper stays inlinednpm pack: 150 files, 175 kB unpacked, 0 spec filesNotes for review
^22.18.0 || >=24.11.0to run. CI is fine: the build job pins Node 22, publish uses 24, and the 18/20 matrix jobs only runbun test.engines.node: >=18is unchanged for consumers, but local builds on Node 18 or 20 no longer work.@rescript/runtimeis referenced solely because of the deprecatedKeyCondition.Anyvariant, which forces ReScript to boxsk?: skCondition. DroppingAnywould remove that dependency entirely. Left alone here since it breaks anyone passingsk: K.any.examples/Expression_test.reshas no assertions. It is a print-only smoke script, not a test.