Skip to content

Fix main-entry type re-exports failing under bundler resolution#29

Merged
MaxGhenis merged 1 commit into
mainfrom
fix/types-resolution-from-main-entry
May 9, 2026
Merged

Fix main-entry type re-exports failing under bundler resolution#29
MaxGhenis merged 1 commit into
mainfrom
fix/types-resolution-from-main-entry

Conversation

@MaxGhenis
Copy link
Copy Markdown
Contributor

Summary

Since 0.4.0, import { Badge } from "@policyengine/ui-kit" (or any other primitive/layout/chart/visualization/input/display/utils/assets symbol) fails type-check in any consumer with moduleResolution: "bundler" (Next.js, Vite, anything modern):

Type error: Module '"@policyengine/ui-kit"' has no exported member 'Badge'.

This was masked because nothing in this repo exercises the package as a consumer, and consumers were pinned at ^0.3.1 (which didn't have the conflict). Surfaced when bumping consumers to ^0.7.0 / ^0.8.0.

Root cause

The Vite multi-entry build emits dist/<name>.js and dist/<name>.cjs at the top of dist/, while tsc --emitDeclarationOnly emits dist/<name>/index.d.ts in folders. Both shapes coexist:

dist/
  index.d.ts                  <- export * from './primitives'  ← ambiguous
  primitives.js               (Vite ESM output, no types)
  primitives.cjs              (Vite CJS output, no types)
  primitives/
    index.d.ts                (tsc types, has Badge)

With moduleResolution: "bundler", TypeScript's resolution algorithm prefers the file over the folder, so ./primitives resolves to ./primitives.js (no types) and every export * silently drops the re-exported symbols. The main-entry .d.ts ends up effectively empty.

Fix

Change src/index.ts re-exports from ./<name> to ./<name>/index. tsc emits the same path verbatim into dist/index.d.ts, and the explicit folder pin forces folder resolution. Verified with a fresh tsc --noEmit against a file:-installed build of this branch:

import { Badge, Skeleton, Container, Button } from "@policyengine/ui-kit";
// ✓ no errors

Subpath imports (@policyengine/ui-kit/primitives) were unaffected — the exports map already pinned those to the index.d.ts folder path. No runtime change. Only the .d.ts re-export paths shift.

Test plan

  • bun run typecheck clean
  • bun run test — 26 files, 292 tests pass
  • bun run build clean
  • Reproduced the failure locally on 0.8.0; reproduced the fix against this branch with tsc --noEmit from a clean consumer with moduleResolution: "bundler"

Why this is urgent

Five PRs across PolicyEngine consumer repos failed CI immediately after their ^0.3.1 → ^0.8.0 bumps merged with this exact symptom (council-tax-ctr-map#1, cost-dashboard#26, economic-parameter-atlas#1, crfb-tob-impacts#116, spm-calculator#27 — all open as I file this). Once 0.9.0 ships those bumps just need a re-trigger.

🤖 Generated with Claude Code

Since 0.4.0 the dist/ tree contains both Vite-emitted dist/<name>.js/.cjs
files and tsc-emitted dist/<name>/index.d.ts folders side by side. With
both on disk, TypeScript's `bundler` module resolution prefers the file
over the folder, so `dist/index.d.ts`'s `export * from './primitives'`
walks into `./primitives.js` (no types) and silently drops every symbol.

Net effect: every consumer importing primitives/layout/charts/visualization/
inputs/display/utils/assets/theme symbols from the main entry of ui-kit
@>=0.4.0 fails type-check with "Module '@policyengine/ui-kit' has no
exported member 'Badge'" (and the same for Skeleton, Container, Button,
…).

Fix: pin the source re-exports to the explicit `./<name>/index` folder
path. TypeScript's bundler resolution then walks into the folder and
finds the types. Verified locally with a fresh `tsc --noEmit` against a
file:-installed build of this branch.

Subpath imports (`@policyengine/ui-kit/primitives`) were unaffected because
the `exports` map already pins them to the index.d.ts folder path. No
runtime change — Vite's emit and the package's `exports` map are
unchanged. Only the .d.ts re-export paths shift.

See microsoft/TypeScript#52146 for the upstream
ambiguity.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vercel
Copy link
Copy Markdown

vercel Bot commented May 9, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
policyengine-ui-kit Ready Ready Preview, Comment May 9, 2026 1:21pm

Request Review

@policyengine policyengine Bot added the ⚙️ Engineering... PolicyEngine's GitHub agent is working on this label May 9, 2026
@MaxGhenis MaxGhenis merged commit ac1f9a4 into main May 9, 2026
4 checks passed
@MaxGhenis MaxGhenis deleted the fix/types-resolution-from-main-entry branch May 9, 2026 13:22
Copy link
Copy Markdown
Contributor

@policyengine policyengine Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Excellent fix for a critical TypeScript resolution issue.

Analysis:
The PR correctly identifies and fixes the root cause: with moduleResolution: "bundler", TypeScript prefers files over folders when resolving paths. Since the Vite build outputs dist/primitives.js (no types) alongside dist/primitives/index.d.ts (with types), the ambiguous export * from './primitives' in src/index.ts resolves to the file, silently dropping all type exports.

The fix:
Changing all re-exports from './<name>' to './<name>/index' forces TypeScript to use folder resolution, correctly finding the .d.ts files. This is a surgical, zero-runtime-impact fix that only affects the emitted type declaration paths.

Verification:

  • ✅ Typecheck passes cleanly on this branch
  • ✅ The fix is minimal and precisely targeted
  • ✅ Comment in src/index.ts:1 clearly explains the issue for future maintainers
  • ✅ Changelog entry is comprehensive and accurate
  • ✅ No changes to runtime behavior or public API surface
  • ✅ Subpath imports were already correct via exports map

Impact:
This unblocks all five consumer PRs that failed with Module has no exported member errors after bumping from 0.3.1 to 0.8.0. The fix is backward-compatible and doesn't require consumer changes.

Ship it.

@policyengine policyengine Bot removed the ⚙️ Engineering... PolicyEngine's GitHub agent is working on this label May 9, 2026
MaxGhenis added a commit that referenced this pull request May 9, 2026
The 0.8.1 fix in #29 only patched the main-entry re-exports. The same
file-shadowing-folder ambiguity affected src/legacy/index.ts, where
'export * from "./tokens"' was silently dropping every legacy color/
typography/spacing/chartColors symbol from `@policyengine/ui-kit/legacy`.

Mirror the fix: pin the re-exports to './tokens/index' and './charts/index'.
Verified with a fresh tsc --noEmit against a file:-installed build.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request May 9, 2026
The 0.8.1 fix in #29 only patched the main-entry re-exports. The same
file-shadowing-folder ambiguity affected src/legacy/index.ts, where
'export * from "./tokens"' was silently dropping every legacy color/
typography/spacing/chartColors symbol from `@policyengine/ui-kit/legacy`.

Mirror the fix: pin the re-exports to './tokens/index' and './charts/index'.
Verified with a fresh tsc --noEmit against a file:-installed build.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
MaxGhenis added a commit that referenced this pull request May 9, 2026
* Restore legacy colors.blue/colors.success and add consumer-types harness

Adversarial review of the merged 0.8.x line surfaced three real follow-ups:

1. The `/legacy` shim's `colors` was missing `blue` (Tailwind sky 50–900)
   and `success` (#22C55E) that @policyengine/design-system 0.2.0/0.3.0
   shipped. The snapshot copied into src/legacy/ for 0.8.0 came from a
   later workspace build that had already dropped both. Two consumer
   migrations (PolicyEngine/cbo-baseline-tracker#3,
   PolicyEngine/uk-spring-statement-2026#22) had to backfill the missing
   values locally — the canary that the shim wasn't a true 1:1 mirror.
   Restored under "Semantic colors" / "Blue accent palette" with a
   comment noting the migration story.

2. The migration JSDoc in src/legacy/index.ts told consumers to map
   `colors.gray[N]` → `palette.gray[N]` and `colors.text.warning` →
   `rootColorsLight['--text-warning']` without flagging that both
   silently change visible hex values (legacy gray is Tailwind-3
   #6B7280 etc.; canonical is Slate #64748B etc. — and `--text-warning`
   was darkened from Mantine orange.9 to Tailwind orange-700 in 0.6.0
   for WCAG AA). Annotated each pair as same-hex or value-shifting so
   bulk sed-replace doesn't silently regress consumers.

3. The bundler-resolution drop fixed in #29 / #30 (dist/<name>.js
   shadowing dist/<name>/index.d.ts under moduleResolution: "bundler")
   was masked because nothing in this repo type-checked the package as
   an external consumer. Added tests/consumer-types/ with a fixture
   exercising the main entry, the legacy/ subpath, and per-feature
   subpaths, plus a tsconfig with paths to dist/ and a vitest spawn of
   tsc --noEmit. CI now runs `bun run build` before `bun run test` so
   the harness sees fresh dist/ output.

Legacy color tests pin colors.blue[50/500/600/900] and colors.success
exactly so a future shim regression fails locally instead of in 18+
consumers. 295 tests pass (was 293, +2 for blue/success).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Address round 2 nits: colors.info value drift, skip-hint, soften JSDoc

- src/legacy/index.ts: flag colors.info → semanticFills.info as a
  value-shifting pair (design-system 0.3.0 had Ant blue #1890FF; 0.4.0+
  and this shim use PE teal-700 #2C7A7B for brand consistency). Consumers
  bumping from 0.3.x will see a one-time blue→teal shift on info usages.
  Also softened the module-level JSDoc framing from 'shim mirroring
  design-system' to acknowledge that some values shifted between 0.3.0
  and 0.4.0 and that --text-{warning,error,success} are shim-only
  accessibility variants.
- tests/consumer-types/typecheck.test.ts: switch from describe.skipIf to
  an explicit it.skip with an actionable hint ("run `bun run build`")
  so a dev running `bun run test` cold sees why the harness didn't
  fire instead of silently missing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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