Skip to content

Fix: emit one module per file so subpath imports stay small - #194

Merged
dwene merged 3 commits into
mainfrom
fix/per-module-build
Oct 27, 2026
Merged

dwene merged 3 commits into
mainfrom
fix/per-module-build

Conversation

@dwene

@dwene dwene commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Problem

Importing ui/checkbox.js, ui/dialog.js or ui/tabs.js from the published dist loaded the shared filter-selector-<hash>.js chunk. That chunk holds the data-table filter, ui/calendar, react-day-picker, date-fns and input-otp, so an app that only needed a checkbox shipped the date picker and filter UI too. 360training's checkout pays about 35–40 KB gzip for this on every step.

Root cause: those three components imported cn from '../ui', the whole UI barrel (slider.tsx used '../ui/'). That created a cycle (barrel → data-table-filter → filter-selector → checkbox), and Rollup resolved it by merging the modules into one shared chunk. The checkbox code itself lived inside filter-selector-<hash>.js.

Changes

  • Source: cn moved to src/ui/utils/cn.ts. It is still re-exported from ui/utils and ui, so the public API is unchanged. Every component now imports ./utils/cn directly. Leaf components also stop pulling in the ui/utils barrel, which brought zod, react-router's useSearchParams and the data-table URL parsers.
  • Build (vite.config.ts):
    • preserveModules + preserveModulesRoot: 'src': one output file per source module and no hash-named shared chunks. hoistTransitiveImports: false keeps each file's import list to its direct dependencies.
    • Externals now come from package.json dependencies + peerDependencies, subpaths included, instead of a hand-maintained list. Before, the list had drifted, so cmdk, @radix-ui/react-select, react-tabs, react-slider and react-separator were inlined, along with a copy of react-dom pulled in through cmdk's Radix primitives. All of these are declared dependencies, so consumers already install them.
    • libphonenumber-js and react-stately are imported by the source but not declared as dependencies, so they stay bundled as before. They're emitted under dist/_vendor/ because npm strips nested node_modules directories from published tarballs.
  • package.json: "sideEffects": false, so barrel imports like import { Button } from '@lambdacurry/forms/ui' can be tree-shaken. Nothing in src has module-level side effects or CSS imports.
  • Changeset: minor (0.24.0). The public API is unchanged, but the dist layout and the set of inlined dependencies change, so ^0.23.0 consumers opt in rather than getting it automatically.

Verification

Import closure from the built dist

Every file each entry loads, following relative imports transitively:

entry before after
ui/checkbox.js 46 files, 345.9 KB raw / 90.0 KB gzip 2 files (checkbox.js, utils/cn.js), 1.3 KB / 0.7 KB gzip
ui/dialog.js 46 files, 349.0 KB / 90.9 KB gzip 2 files, 3.4 KB / 1.1 KB gzip
ui/tabs.js 46 files, 345.9 KB / 90.0 KB gzip 2 files, 1.8 KB / 0.8 KB gzip
remix-hook-form/checkbox.js 10 files, 11.7 KB / 4.7 KB gzip 6 files, 8.8 KB / 3.4 KB gzip
ui/button.js 6 files, 5.0 KB / 2.3 KB gzip 2 files, 2.1 KB / 0.9 KB gzip

After the change, the closures of ui/checkbox, ui/dialog and ui/tabs contain no calendar, data-table-filter, command, react-day-picker, date-fns, input-otp or lodash code. Their only external imports are their own Radix primitive, lucide-react (checkbox and dialog), clsx, tailwind-merge and react.

Consumer bundle (esbuild, minified + gzip, react / react-router / RHF / zod external)

consumer import before after
@lambdacurry/forms/ui/checkbox 114.9 KB 11.4 KB
@lambdacurry/forms/ui/dialog 115.4 KB 11.2 KB
@lambdacurry/forms/ui/tabs 114.9 KB 11.6 KB
@lambdacurry/forms/remix-hook-form/checkbox 13.5 KB 13.0 KB
{ Checkbox } from '@lambdacurry/forms/remix-hook-form' 117.3 KB 13.0 KB
{ Button } from '@lambdacurry/forms/ui' 115.4 KB 8.2 KB

These numbers include the third-party code each import pulls in, such as Radix, lucide and tailwind-merge.

Total dist JS

Before: 106 files, 533 KB. After: 140 files, 390 KB. The new files are mostly the per-module libphonenumber-js code under _vendor/; the drop comes from no longer inlining declared dependencies.

Public API unchanged

  • All 102 non-chunk .js entry files from the old dist still exist at the same paths with identical export names, checked with es-module-lexer. All 104 .d.ts files are still present. The only additions are ui/utils/cn.{js,d.ts}, ui/data-table-filter/locales/en.json.js and _vendor/**.
  • package.json exports are untouched.
  • Every built module (104 of 104) imports successfully in Node 22, as did the old build (106 of 106).

Repo checks

  • yarn build: passes, with no empty-chunk warnings.
  • yarn lint: passes.
  • yarn test: 34 suites and 52 tests pass. Storybook resolves the package from src, so this covers the cn import refactor; dist was verified separately above.
  • yarn workspace @lambdacurry/forms type-check: passes. @lambdacurry/forms-docs type-check fails on main too, with the same 2 errors in select-alignment.stories.tsx; this PR doesn't touch them.

Notes for reviewers

  • remix-hook-form/data-table-router-form.js still has some bare import "…" lines. They come from importing the ui/data-table-filter barrel, which that module really does depend on, so I left them.
  • CI fix (separate commit): bumps the root @playwright/test to ^1.64.1, and with it the playwright that @storybook/test-runner resolves. ubuntu-latest is now Ubuntu 26.04, and Playwright 1.55 won't install Chromium there, so the test job failed before running any tests. That would happen on main too. Playwright 1.61+ supports 26.04. Locally, Chromium installs without any platform override and all 52 tests pass on 1.64.1.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features
    • The forms package is now published as separate source modules, enabling more efficient tree-shaking and potentially smaller application bundles.
    • Declared dependencies are kept external in the package build, while existing public exports and subpath imports remain unchanged.
  • Bug Fixes
    • Improved component build reliability by making utility imports resolve consistently.

ui/checkbox, ui/dialog and ui/tabs imported `cn` from the `ui` barrel,
which formed a cycle through data-table-filter. Rollup resolved it by
merging them into a shared filter-selector chunk, so importing a
checkbox loaded the calendar, react-day-picker, date-fns and input-otp.

- Move `cn` into ui/utils/cn and import it directly everywhere, so leaf
  components no longer pull zod, react-router or filter sync helpers.
- Build with preserveModules (no hashed shared chunks) and externalize
  every declared dependency/peer instead of a hand-maintained list.
  Undeclared bundled code goes to dist/_vendor, since npm strips
  nested node_modules from tarballs.
- Declare "sideEffects": false so barrel imports tree-shake.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-27T13:09:55.156084Z 7ab1891 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@coderabbitai

coderabbitai Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Important

Review skipped

Auto incremental reviews are disabled on this repository.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: 71b87472-63d7-44d8-9624-deafd6f5c8c3

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Repository UI; Enforced overrides: Organization
  • Review profile: CHILL
  • Plan: Essentials
  • Run ID: d817223c-7053-4fdd-9014-1ead83f53b32
Commits

Reviewing files that changed from the base of the PR and between e719e3f and 7ab1891.

Files selected for processing: 40
  • .changeset/per-module-build.md
  • packages/components/package.json
  • packages/components/src/ui/badge.tsx
  • packages/components/src/ui/button.tsx
  • packages/components/src/ui/calendar.tsx
  • packages/components/src/ui/checkbox-field.tsx
  • packages/components/src/ui/checkbox.tsx
  • packages/components/src/ui/command.tsx
  • packages/components/src/ui/data-table-filter/components/filter-actions.tsx
  • packages/components/src/ui/data-table-filter/components/filter-selector.tsx
  • packages/components/src/ui/data-table-filter/components/filter-value.tsx
  • packages/components/src/ui/data-table/data-table-faceted-filter.tsx
  • packages/components/src/ui/data-table/data-table.tsx
  • packages/components/src/ui/date-picker-field.tsx
  • packages/components/src/ui/dialog.tsx
  • packages/components/src/ui/dropdown-menu.tsx
  • packages/components/src/ui/form.tsx
  • packages/components/src/ui/label.tsx
  • packages/components/src/ui/otp-input-field.tsx
  • packages/components/src/ui/otp-input.tsx
  • packages/components/src/ui/password-field.tsx
  • packages/components/src/ui/phone-input-field.tsx
  • packages/components/src/ui/phone-input.tsx
  • packages/components/src/ui/popover.tsx
  • packages/components/src/ui/radio-group-field.tsx
  • packages/components/src/ui/radio-group-item-field.tsx
  • packages/components/src/ui/radio-group.tsx
  • packages/components/src/ui/select.tsx
  • packages/components/src/ui/separator.tsx
  • packages/components/src/ui/slider.tsx
  • packages/components/src/ui/switch-field.tsx
  • packages/components/src/ui/switch.tsx
  • packages/components/src/ui/table.tsx
  • packages/components/src/ui/tabs.tsx
  • packages/components/src/ui/text-field.tsx
  • packages/components/src/ui/text-input.tsx
  • packages/components/src/ui/textarea.tsx
  • packages/components/src/ui/utils/cn.ts
  • packages/components/src/ui/utils/index.ts
  • packages/components/vite.config.ts

Included review availability: This review used your included allowance. 1 included review remains after this review. Your included PR review attempts over the past 7 days set your current allowance at 3 reviews per hour.

Walkthrough

The component package build now preserves source modules, externalizes dependency package names and subpaths, and rewrites vendor paths. The cn helper has its own module, and component imports target it. Package metadata and a patch changeset were added.

Changes

Component package build

Layer / File(s) Summary
Extract cn helper
packages/components/src/ui/utils/*, packages/components/src/ui/*.tsx, packages/components/src/ui/data-table*/**
The cn implementation is in its own module. The utilities barrel re-exports it, and component imports point directly to the module.
Configure package build output
packages/components/vite.config.ts, packages/components/package.json, .changeset/per-module-build.md
The Vite build externalizes dependencies and preserves source modules. Package metadata sets sideEffects to false, and the changeset records a patch release and describes the build changes.

Priority: Pending latest changes

Estimated code review effort: 3 (Moderate) | ~20 minutes

Change: Bug fix

Suggested reviewers: jaruesink

Merge Risk: Minimal · previous review 7ab18

The reviewed build and module changes present no confirmed merge-blocking risk.

Pre-merge checks: Passed 4 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Docstring Coverage Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within survi… Write docstrings for the functions missing them to satisfy the coverage threshold.
Passed Passed checks: 4 passed
Check name Status Explanation
Description Check Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check Passed The title "Fix: emit one module per file so subpath imports stay small" matches the main change. The PR switches the Vite build to preserveModules so each source file emits its own module. The stated …
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Title check

Explanation

The title "Fix: emit one module per file so subpath imports stay small" matches the main change. The PR switches the Vite build to preserveModules so each source file emits its own module. The stated goal is that subpath imports such as ui/checkbox.js, ui/dialog.js, and ui/tabs.js stop loading unrelated shared chunks. The title is specific and conveys the primary change. The raw summary also covers supporting work (moving cn to ui/utils/cn.ts, sideEffects false, externalizing dependencies), which the title does not need to list. The word "Fix" is a minor framing choice, since the change reads more like a build restructuring than a bug fix, but this does not make the title misleading.

Full details: Docstring Coverage

Explanation

Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Documentation-only edits and deletions within surviving functions are included; unchanged diff context and fully deleted functions are excluded. Analyzed 1 functions across 38 files. (2 skipped: 2 unsupported.)

Resolution

Write docstrings for the functions missing them to satisfy the coverage threshold.

Finishing Touches: 💡 1
📝 Generate docstrings: 💡
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

A rabbit checks each module’s trail
The class names hop without a fail
The build keeps files in rows
And marks which way each package goes
Then nibbles clover by the code

Comment @coderabbitai help to get the list of available commands.

@github-actions

Copy link
Copy Markdown
Contributor

📝 Storybook Preview: View Storybook

This preview will be updated automatically when you push new changes to this PR.

Note: The preview will be available after the workflow completes and the PR is approved for deployment.

ubuntu-latest now resolves to 26.04, where Playwright 1.55 refuses to
install Chromium, so the test job failed before running any tests.
Playwright 1.61+ supports 26.04. @storybook/test-runner accepts
playwright ^1.14.0, so this is a root devDependency + lockfile bump.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dwene

dwene commented Oct 27, 2026

Copy link
Copy Markdown
Contributor Author

@currybot-lc please review

Context:

  • Fixes the shared filter-selector-<hash>.js chunk being loaded by ui/checkbox, ui/dialog and ui/tabs. Root cause was import { cn } from '../ui' (barrel → cycle through data-table-filter). cn now lives in ui/utils/cn.ts and is still re-exported from ui / ui/utils.
  • Build switched to preserveModules, externals derived from package.json deps + peers, "sideEffects": false. Undeclared bundled deps (libphonenumber-js, react-stately) go to dist/_vendor/ since npm strips nested node_modules.
  • Public API check: all 102 published .js entries keep identical export names; all .d.ts files present; exports map untouched.
  • Second commit (8bef5c7) bumps Playwright to 1.64.1 because ubuntu-latest is now 26.04 and 1.55 can't install Chromium there. CI test now passes on the 26.04 image (52/52).

Worth a close look: vite.config.ts (externals predicate, entryFileNames rewrite for _vendor) and whether "sideEffects": false is safe for every module.

@currybot-lc

currybot-lc Bot commented Oct 27, 2026 •

Copy link
Copy Markdown

🍛 Currybot Review — completed

Reviewed head 8bef5c7d56620da62a54ff68b4d06cb4f242a9c8.

Outcome: No blocking findings. Merge Confidence: 9/10; recommendation: ready.

All 42 changed paths were accounted for. Focused package builds, packing, public-export/declaration compatibility, import-closure, and browser/Node consumer-bundle checks passed. Current-head CI passed 34 suites / 52 tests. An isolated installed-tarball React Router application runtime was not exercised.

Read the full PR review.

@currybot-lc currybot-lc Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🍛 Currybot Review

📋 Context

PR #194 publishes one module per file so small subpath imports avoid unrelated UI code. This is a full initial review of all 42 changed files at head 8bef5c7d56620da62a54ff68b4d06cb4f242a9c8, against base and merge base e719e3fc769cef2b76d6e2175e24e7f02a822a91. No generated files changed; yarn.lock was checked for dependency declaration consistency without reading its full body.

Related guidance: base AGENTS.md, base Cursor rules, constitution, and tech stack. Ticket and DevAgent plan: N/A; none was supplied or linked.

🔍 Gap Analysis

No findings. There are no prior Currybot findings or review threads. Earlier bot summaries reported no confirmed code findings at an older head. Existing feedback — Addressed by explanation: CodeRabbit’s generic 80% docstring warning is not a base-revision repository gate; the helper implementation is moved unchanged. No code defect remains from that warning.

🧪 Code Quality

No findings. The external predicate matches a declared package exactly or followed by /; a loose prefix could incorrectly treat another package with the same leading name as external. Module preservation emits source modules separately and rewrites nested node_modules paths to _vendor. The packed relative-import closure has zero missing targets, confirming those rewritten paths remain valid in the tarball.

Base comparison and package checks: The base build had 106 JavaScript files: all 102 old non-hash entry paths remain in the head pack; the four absent files are old hash chunks. All 104 base declaration paths remain; the head pack has 105 declarations. Old retained entry export-name sets were unchanged. The head pack contains 140 JavaScript files, including 36 _vendor modules and two new non-vendor files. Package metadata and export mappings resolve to packed targets; packed JavaScript and declaration relative imports have zero missing targets. The patch Changeset is present.

sideEffects: false: The flag is at package.json:36. Source inspection found no CSS imports, required bare imports, or module-level global registration. displayName assignments set local component metadata; 'use client' directives do not perform runtime registration. The built remix-hook-form/data-table-router-form.js does contain bare imports, but their inspected local targets contain no required global effect; declared external dependencies retain their own package side-effect metadata. The 36 packed _vendor modules comprise 34 libphonenumber-js and two @react-stately modules. Their inspected initialization includes local function/prototype setup, with no required bare import, CSS, or global registration found. Browser and Node esbuild bundles from the packed files retain all 34 phone vendor modules when PhoneNumberInput is used and both stately modules when Select is used, through both leaf and ui barrel imports. These checks found no current pruning regression for those exports.

Actual sandbox checks: Immutable Yarn install, focused package build, pack, relative-import and export-target checks passed. All 140 built JavaScript modules imported in Node 24. Focused browser and Node bundles resolved checkbox, dialog, tabs, form checkbox, selected barrel imports, phone input, and select; small leaf imports avoided calendar and data-table-filter inputs. The root Playwright declaration and lockfile agree on 1.64.1.

Head CI, separately: The test job passed 34 suites and 52 tests; quality checks passed lint and package type-check; the preview build passed. Storybook aliases the package to source, so its tests do not establish published-tarball behavior. Author-reported size figures remain claims; the focused consumer bundles support the import-boundary improvement.

Additional checks not performed: an isolated installed-tarball React Router SSR application runtime, feature verification, or E2E QA. Focused Node consumer bundles passed; no SSR defect was demonstrated. The sandbox runtime was Node 24, versus CI’s Node 22.9.0.

🔧 Simplicity Audit

No violations or missing justifications; numerical deduction: 0. The package-derived external predicate and _vendor filename rewrite address the observed build and packing constraints, with explanations at their use sites. The change uses existing Vite/Rollup options without an added abstraction layer.

🌟 Highlights

📊 Merge Confidence: 9/10

Recommendation: ready. Head CI and focused base, build, pack, import-closure, export, and consumer-bundle checks found no blocking issue. A successful isolated installed-tarball React Router SSR consumer check would raise confidence to 10/10 by closing the remaining runtime boundary; it is not a merge blocker on the evidence reviewed.

The dist layout changes and several dependencies move from inlined to
external. In 0.x, ^0.23.0 consumers would pick up a patch automatically,
so ship it as 0.24.0 to make the upgrade opt-in.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@dwene
dwene merged commit c58f687 into main Oct 27, 2026
6 checks passed

This branch was successfully deployed

1 active deployment
pr-preview — b05bedbf Deployed Oct 27, 2026 by dwene via deploy #614
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