Files
biggus-dickus/docs/superpowers/specs/2026-06-09-bundle-and-tests-design.md

6.4 KiB

Bundle Vendor-Split + Test-Gap Fills — Design

Date: 2026-06-09 Status: Approved (brainstorming) — ready for implementation planning. Issue: #67 (vendor-split the bundle for cache stability/headroom; fill the audit's unit-test + story gaps).

Context

The production build emits a single ~216 KB-gz entry chunk (the largest, measured by scripts/check-bundle-size.mjs, budget 250 KB) that bundles the framework deps (react-dom, react-router 7, @tanstack/react-query, @base-ui/react, i18next) together with app code — so every app edit invalidates the whole chunk's cache. vite.config.ts has no build.rollupOptions. Separately, four well-isolated units have no direct tests, and the composed combobox primitive has no story.

All changes are additive/low-risk; check:size/check:colors/the existing tests are the guards.

Components

1. Vendor split — vite.config.ts

Add a top-level build block (sibling of plugins/resolve/server/test) with manualChunks:

build: {
  rollupOptions: {
    output: {
      manualChunks: {
        react: ["react", "react-dom", "react-router", "react-router-dom"],
        "base-ui": ["@base-ui/react"],
        query: ["@tanstack/react-query"],
        i18n: ["i18next", "react-i18next"],
      },
    },
  },
},
  • The app entry chunk then carries only app code; the listed deps land in cache-stable vendor chunks (react-*.js, base-ui-*.js, query-*.js, i18n-*.js). react-router and react-router-dom are both listed so router code (v7 re-exports react-router internally) stays in one chunk and React isn't duplicated.
  • Only affects pnpm build (production); the Storybook/vitest browser builder is independent, so the test suite is unaffected.
  • check:size measures the largest emitted chunk; after the split the largest is a vendor chunk well under the 250 KB budget (verified in implementation by running pnpm build + pnpm check:size and reporting the chunk sizes). If a chunk is unexpectedly oversized or React duplicates, adjust the groups.

2. Test-gap fills (4 unit tests)

objects/prune-fields.test.ts (new) — pruneFields(fields, localizedTextKeys: Set<string>, defaultLang):

  • scalars pass through; undefined/null/"" top-level values are dropped.
  • a non-array object value whose key is in localizedTextKeys keeps only the defaultLang entry; other-language entries are dropped.
  • a non-array object value whose key is not in localizedTextKeys keeps all (non-empty) language entries.
  • empty inner entries (""/null/undefined) are filtered, and a map left with zero entries is dropped entirely.

components/delete-confirm-dialog.test.tsx (new) — the delete-in-use flow:

  • render <DeleteConfirmDialog description=… onConfirm=… />, open it (click the trigger), click the confirm action.
  • when onConfirm rejects with InUseError(3), the dialog shows the actions.inUse message (containing the count 3) and stays open (the confirm action is still present / the dialog isn't dismissed).
  • when onConfirm resolves, the dialog closes. (DeleteConfirmDialog routes its catch through errorMessageKey, which maps InUseErroractions.inUse with the count — so this also exercises the shared error mapping.)

lib/labels.test.ts (new) — labelText(labels, lang):

  • exact lang match wins; else falls back to the "en" label; else the first label; else "" for an empty array.

lib/format-date.test.ts (new) — formatDate(value, lang):

  • a valid "YYYY-MM-DD" formats via Intl.DateTimeFormat(lang, { dateStyle: "medium" }) and is not shifted off its calendar day (assert the rendered string contains the expected day number for a fixed date + a fixed lang like "en").
  • null"—"; a non-date string (e.g. "not-a-date") is returned unchanged; a non-string non-null (e.g. a number) is String()-ified.

3. Combobox story — components/ui/combobox.stories.tsx (new)

A visual/interactive story for the composed combobox primitive, mirroring the existing ui/* story format (@storybook/react-vite Meta/StoryObj, single-quote + no-semicolon, tags: ['ai-generated'], a play using canvas + storybook/test). A small controlled wrapper renders ComboboxRoot with a handful of string options (ComboboxInput/ComboboxClear/ComboboxTrigger/ComboboxPopup/ComboboxList/ ComboboxItem/ComboboxEmpty), matching how OptionsCombobox composes them. A Default story; the play asserts the input renders and (optionally) typing filters the list. (Runs as a browser test in the Storybook vitest project — a few seconds in CI.)

Error handling / edges

  • manualChunks: Rollup orders chunks by the import graph, so vendor chunks load before the app chunk that imports them — no load-order/duplicate-React risk when react+react-dom+router share one named chunk.
  • formatDate parses \${value}T00:00:00`(local midnight) to avoid a UTC day-shift; the test pins a fixedlangto keepIntl` output deterministic (assert a substring like the day number, not the full locale string, to stay robust across ICU versions).
  • delete-confirm-dialog test drives the Base UI AlertDialog in its portal (within(document.body) for the confirm action), mirroring existing portal-aware tests.

Testing

  • The 4 unit tests run in the jsdom project; the story adds one Storybook browser test.
  • Gate: typecheck/lint/test/build/check:size/check:colors green; check:size reports the new largest (vendor) chunk under budget; no new dependency; no new i18n keys; no codename. en/sv parity unaffected.

Acceptance criteria

  1. vite.config.ts has build.rollupOptions.output.manualChunks splitting react/base-ui/query/i18n into their own chunks; pnpm build succeeds and check:size passes (largest chunk reported, < 250 KB gz, no React duplication).
  2. New tests exist and pass: prune-fields.test.ts, delete-confirm-dialog.test.tsx (InUseError → actions.inUse, stays open), labels.test.ts, format-date.test.ts.
  3. components/ui/combobox.stories.tsx exists with a working Default story (browser-test green).
  4. Full gate green; no new dependency; no new i18n keys; no codename; existing tests unchanged.

Out of scope → follow-ups

  • The buildkit/Dockerfile CI migration (the robust fix for the slow native runner; overlaps #25).
  • Deeper treeshaking/bundle analysis; route-level code-split changes beyond the existing lazy() boundaries.