docs(specs): bundle vendor-split + test-gap fills (#67)
This commit is contained in:
@@ -0,0 +1,111 @@
|
||||
# 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`:
|
||||
```ts
|
||||
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 `InUseError` →
|
||||
`actions.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
|
||||
fixed `lang` to keep `Intl` 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.
|
||||
Reference in New Issue
Block a user