Skip to content

spike(GMT-1715): tsdown build — go/no-go criterion #4 - #3407

Draft
dreamwasp wants to merge 2 commits into
mainfrom
cass-tsdown-poc
Draft

spike(GMT-1715): tsdown build — go/no-go criterion #4#3407
dreamwasp wants to merge 2 commits into
mainfrom
cass-tsdown-poc

Conversation

@dreamwasp

Copy link
Copy Markdown
Contributor

Overview

Spike, not for merge. Closes go/no-go criterion #4 for the 1.0 reboot: can
tsdown replace tsc --emitDeclarationOnly + babel ./src --out-dir ./dist, producing
the reboot's decided output shape (bundled per entry + a strict exports map)?

Yes. Written up in TSDOWN-SPIKE.md at the repo root.

Built three real packages — variance (pure TS), gamut-styles (16 .tsx, 20 Emotion
imports) and gamut (426 files, 106 Emotion styled) — each producing bundled ESM

  • CJS and .d.mts/.d.cts, 4–7x faster than the current tsc + babel pair
    (gamut: ~17.3s → 2.3s).

The load-bearing result: no Babel bridge is needed

The concern was that a Phase 1 tsdown migration would need a scoped
@rolldown/plugin-babel bridge for @emotion/babel-plugin, which Phase 2 would then
delete. It doesn't — tsdown built both Emotion-heavy packages with no Babel at all.

Consequence: the build migration is independent of the styling work and can land
first.
That reversed the planned phase ordering.

Second commit: the declarations claim, corrected

8c2ce2b03 corrects the first commit. Bundled declarations don't compose, so the shape
is a hybrid: JS from tsdown, declarations from tsc. The part worth keeping is
why it works rather than that it works —
rolldown-plugin-dts#174
(directives leaking into .d.ts) cannot occur in a hybrid build, because
declarations carry no statements and there is nowhere to put a directive prologue.
Structurally unreachable rather than luckily avoided, so nobody re-verifies it after a
version bump. One exports map serves both output shapes.

Output contract validated end to end

  • Real exports maps resolved through the package name
  • The mono deep-import preserved as a genuine subpath
    (@codecademy/gamut-styles/AssetProvidercreateFontLinks)
  • The old dist/AssetProvider path correctly blocked with
    ERR_PACKAGE_PATH_NOT_EXPORTED

Two findings worth carrying regardless of this spike's fate

  1. Today's Babel output is not valid Node ESM — extensionless specifiers, 10 in
    variance alone. It only works because consumers bundle.
  2. Latent type-only re-export debt that Babel hides and a bundler rejects — 6 names
    across 2 files, fixed here. Neither isolatedModules nor verbatimModuleSyntax is
    enabled anywhere, which is why it accumulated.

⚠️ Known error in TSDOWN-SPIKE.md, not yet corrected in the file

TSDOWN-SPIKE.md:33-34 says @emotion/babel-plugin is "DX, not correctness". That is
wrong.
The three configured options (sourceMap, autoLabel, labelFormat) are
cosmetic, but the plugin also stamps target unconditionally, outside any option
verified in the plugin's own dist, where target is pushed with no guard while label
two lines later is gated on autoLabel !== 'never'.

target is load-bearing for component selectors (${Component} in selector
position). Without it, @emotion/styled returns NO_COMPONENT_SELECTOR, which
@emotion/serialize throws on in development and silently renders as .undefined in
production
. Real exposure: 1 site here
(packages/gamut/src/BarChart/BarRow/elements.tsx:64) and 22 confirmed consumer sites.

The conclusion survives — no Babel bridge is needed — but the supporting sentence
doesn't, and the removal work item is larger than "readable class names".

PR Checklist

  • Related to designs: n/a — build tooling
  • Related to JIRA ticket: GMT-1715
  • Version plan added/updated (or not needed) — not needed, spike
  • I have run this code to verify it works
  • This PR includes unit tests for the code change — no; the spike's assertions are
    the build itself plus the resolution checks in TSDOWN-SPIKE.md
  • This PR includes testing instructions
  • The alpha package of this PR is passing end-to-end tests in all relevant
    Codecademy repositories — no. See limitations.

Testing Instructions

# per-package builds
yarn workspace @codecademy/variance     tsdown
yarn workspace @codecademy/gamut-styles tsdown
yarn workspace @codecademy/gamut        tsdown

# then verify the exports map actually gates resolution
node -e "import('@codecademy/gamut-styles/AssetProvider').then(m => console.log(Object.keys(m)))"
node -e "import('@codecademy/gamut-styles/dist/AssetProvider').catch(e => console.log(e.code))"
#   expect: createFontLinks present, then ERR_PACKAGE_PATH_NOT_EXPORTED

Compare timings against yarn build on main for the same packages.

Limitations — please read before drawing conclusions

  • Bare tsc invocation, and no consumer build has run anywhere. Not mono's Next
    build, not platform's Rspack, not front's Webpack.
  • The hybrid was proven on gamut-styles, whose declaration layout happens to line up
    with its entry names.
    That is a property of that package, not of the approach.
    packages/gamut (426 files) is untested and is where the entry list stops being
    obvious.
  • The exports map here is not yet designed around the inventory of ~66 deep
    dist/ imports across mono (19) and platform (47). Two of our own spikes have already
    broken on it accidentally.

PR Links and Envs

Repository PR Link
Mono none — no consumer build attempted
Monolith none — no consumer build attempted

Related reboot spikes: #3402 (styling proof of concept) · #3405 (Panda under the hood) ·
#3406 (engine-neutral DTCG tokens).

dreamwasp and others added 2 commits August 10, 2026 10:42
The last unspiked criterion from the reboot recommendation §5. It works, and it
answers the question that was blocking Phase ordering.

  package       scope                        current   tsdown   speedup
  variance      pure TS                      17.8s     4.4s     ~4x
  gamut-styles  16 tsx, 20 emotion imports   21.3s     2.8s     ~7x
  gamut         426 files, 106 styled        ~17.3s    2.3s     ~7x

All three emit bundled ESM + CJS plus .d.mts/.d.cts from one run.

HEADLINE: no Babel bridge is needed, so the build migration does NOT depend on
Emotion leaving first. @emotion/babel-plugin is configured only for sourceMap +
autoLabel (DX, not correctness), 0 of 2 snapshot files depend on emotion class
names, and tsdown built both emotion-heavy packages with no Babel at all.

Output shape validated end to end: real exports maps resolving through the
package name, the mono deep-import preserved as a real subpath
(@codecademy/gamut-styles/AssetProvider → createFontLinks), and the old
dist/AssetProvider path correctly blocked with ERR_PACKAGE_PATH_NOT_EXPORTED.

Four findings:

- Today's Babel output is not valid Node ESM — extensionless specifiers, 10 in
  variance alone. It only works because consumers bundle. Corroborates the
  bundled-output decision independently.
- Latent type-only re-export debt that Babel's file-by-file transpile hides:
  6 names across 2 files needed `export type`. Neither isolatedModules nor
  verbatimModuleSyntax is enabled anywhere, which is why it accumulated.
- .css imports need @tsdown/css or externalizing; externalizing matches the
  current build, which already cpy's CSS.
- Packages must migrate together — gamut-styles couldn't load until variance
  was also tsdown-built, because variance's Babel ESM is unresolvable.

Also worth re-testing: tsdown's bundled dts came out SMALLER than tsc's
per-file output (396kB in 1 file vs 481kB across 47), so the RFC's "keep
tsc --emitDeclarationOnly" assumption didn't survive contact.

variance and gamut-styles deliberately point at dist-tsdown, so a whole-repo
build won't work on this branch. That's finding 4 made concrete, not breakage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ompose

An earlier revision of TSDOWN-SPIKE.md suggested tsdown's own dts might remove
the need for tsc --emitDeclarationOnly. That was wrong, and build-tooling-rfc's
original recommendation to keep tsc is correct.

The first measurement was taken while gamut-styles still resolved
@codecademy/variance to its Babel per-file .d.ts. Once variance ships bundled
declarations — which is what migrating variance to tsdown means — the
downstream build fails:

  variance declarations    gamut-styles dts build
  per-file (tsc)           0 errors, 1.6s
  bundled (tsdown)         50 x TS4023 "cannot be named"

The downstream package can't name types the upstream's bundled .d.ts doesn't
re-export. Causation verified by flipping variance's types field back and
forth — 0 vs 50, nothing else changed.

Two related fixes in this commit:

- dts: true alone fails on these packages ("You have references in your
  tsconfig") because the root tsconfig is a references-only shell.
  dts: { tsconfig: 'tsconfig.lib.json' } gets past it.
- variance now uses the finding-driven hybrid: JS from dist-tsdown/,
  declarations from dist/ via the existing tsc step.

Also corrected: I called gamut-styles/dist/AssetProvider the highest-priority
break that must become a public export. It already is one — src/index.ts:3
star-exports it and both createFontLinks and AssetProvider resolve off the
root, verified by importing them. So it's a rename to the root specifier, not
a promotion to a subpath, and shipping the ./AssetProvider subpath would
enshrine one nobody requires.

Credit to the RSC session for hitting the dts wall independently on the same
package and tracing it to variance's bundled .d.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dreamwasp dreamwasp mentioned this pull request Aug 11, 2026
7 tasks
@nx-cloud

nx-cloud Bot commented Aug 11, 2026

Copy link
Copy Markdown

🤖 Nx Cloud AI Fix Eligible

An automatically generated fix could have helped fix failing tasks for this run, but Self-healing CI is disabled for this workspace. Visit workspace settings to enable it and get automatic fixes in future runs.

To disable these notifications, a workspace admin can disable them in workspace settings.


View your CI Pipeline Execution ↗ for commit 8c2ce2b

Command Status Duration Result
nx run-many --target=build --all ❌ Failed 13s View ↗

💡 Dealing with memory or CPU issues? See memory and CPU details with the resource usage add-on ↗.


☁️ Nx Cloud last updated this comment at 2026-08-11 14:02:35 UTC

@codecov

codecov Bot commented Aug 11, 2026

Copy link
Copy Markdown

⚠️ JUnit XML file not found

The CLI was unable to find any JUnit XML files to upload.
For more help, visit our troubleshooting guide.

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.

1 participant