Skip to content

⚡ perf: lazy-load heavy leaves so the root barrel stays small - #742

Open
futjesus wants to merge 1 commit into
mainfrom
perf/lazy-heavy-leaves
Open

futjesus wants to merge 1 commit into
mainfrom
perf/lazy-heavy-leaves

Conversation

@futjesus

@futjesus futjesus commented Sep 7, 2026

Copy link
Copy Markdown
Collaborator

Why

import('@konstructio/ui') pulled the whole barrel's dependency graph statically. The static import closure of dist/index.js (following every import … from './x.js' recursively) was 3,950 KB across 369 files, dominated by leaves most screens never render:

chunk size source
libphonenumber-*.js 1,898 KB google-libphonenumber (PhoneNumberInput)
phone-number.provider-*.js 320 KB country-flag-icons React flags + i18n-iso-countries (PhoneNumberInput)
utils-*.js 256 KB country-locale-map (Filter date dropdown label formatting)
dist-*.js 244 KB chart.js + react-chartjs-2 (PieChart, LineChart)
DayPicker-*.js 90 KB react-day-picker (DatePicker, DateRangePicker)
table.provider-*.js 77 KB @tanstack/react-table (VirtualizedTable)
FlagSelectorList-*.js 36 KB @tanstack/react-virtual flag list (PhoneNumberInput)

What

Heavy leaves are now loaded lazily inside the library. Public API is unchanged: same named exports from the root barrel, same props, same types.

  • PhoneNumberInput, DatePicker, PieChart, LineChart, VirtualizedTable are thin wrappers that render their implementation (components/<Name>Content) through React.lazy inside a Suspense boundary. Fallbacks keep the DOM footprint (label + input-sized box, calendar-sized card, chart height, <section class="kvt">) and are marked aria-busy.
  • DateRangePicker keeps presets/inputs/month headers static and lazy-loads only the react-day-picker grid (CalendarMonthContent); the placeholder height is derived from the month's week count.
  • Filter's getLocale loads country-locale-map on first use (useLocale hook); until it resolves it returns en-US, which is also the result for the default countryCode="US", so the common case renders identically.
  • VirtualizedTable statics (TruncateText, Actions, Events) are imported by file so the static graph no longer reaches the tanstack-backed context barrel.
  • DatePicker.types.ts imports countries-and-timezones as import type (it was only ever used in a type position).
  • PieChart's canvas now has an accessible name (title/subtitle, or Pie chart) — the role="img" canvas had no alternative text before.

No wrapper touches window/document at module scope, so the wrappers stay SSR-safe for the Next.js host; during SSR React renders the Suspense fallback (or the content when streaming) and hydrates the lazy chunk on the client.

Result

Static closure of dist/index.js: 3,950.7 KB (369 files) → 955.5 KB (340 files), −76 %. Total dist/*.js is unchanged (3,610 KB); the difference is now reachable only through import():

libphonenumber-*.js, phone-number.provider-*.js, FlagSelectorList-*.js, country-locale-map-*.js, DayPicker-*.js, the chart.js chunk, table.provider-*.js.

Remaining static weight is core UI and out of scope here: motion/react (160 KB, Modal/ButtonGroup/DateRangePicker — candidate for LazyMotion + m), react-router-dom bundled into the Breadcrumb chunk (56 KB — it is a peer dependency but not in rollup external), tailwind-merge (42 KB), floating-ui/radix, prop-types via react-feather (22 KB).

Caveats

  • Consumers see a brief aria-busy placeholder on the first mount of a lazy component (later mounts are synchronous once the chunk is cached).
  • Tests that render a lazy component must await the first query (findBy*); the affected suites do that and warm the content module in beforeAll so cold module transforms do not eat the findBy timeout.
  • DateRangePicker › should keep the built-in windows when presets is omitted fails on main too (off by exactly one hour, DST-dependent) and is untouched here.

Validation

  • npm run lint — pass
  • npm run check:types — pass
  • npm run check:prettier — pass
  • npm run test — see PR checks (locally only the pre-existing DST failure above)
  • npm run build — pass; verified dist/index.js has no static path to the chunks listed above and each wrapper emits a real import() to its content chunk.

Every heavy leaf (google-libphonenumber, country-flag-icons, i18n-iso-countries,
country-locale-map, react-day-picker, chart.js, @tanstack/react-table) was in
the static import closure of dist/index.js, so import('@konstructio/ui') paid
for PhoneNumberInput, the date pickers, the charts and VirtualizedTable even
when a screen rendered none of them.

PhoneNumberInput, DatePicker, PieChart, LineChart and VirtualizedTable are now
thin wrappers that render their implementation through React.lazy inside a
Suspense boundary with a same-footprint aria-busy fallback. DateRangePicker
lazy-loads only the react-day-picker grid. Filter's getLocale loads
country-locale-map on first use and falls back to en-US, which is also the
result for the default countryCode. Public exports, props and types are
unchanged and no wrapper touches window at module scope.

Static closure of dist/index.js: 3,950.7 KB (369 files) -> 955.5 KB (340
files). Tests that render a lazy component now await the first query and warm
the content module in beforeAll. The PieChart canvas gets an accessible name,
which the role=img element lacked.

This branch has not been deployed

No deployments
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