Skip to content

v1.18.0 — exported prop types, nullable breadcrumb hrefs, readonly array props - #26

Merged
aicodebar merged 5 commits into
mainfrom
release/v1.18.0
Aug 8, 2026
Merged

v1.18.0 — exported prop types, nullable breadcrumb hrefs, readonly array props#26
aicodebar merged 5 commits into
mainfrom
release/v1.18.0

Conversation

@aicodebar

Copy link
Copy Markdown
Collaborator

Five changes, all raised by a consumer (flows.codebar) that stood up a vue-tsc lane over 152 components for the first time and drove it from 1,566 errors to 169. Nothing was patched consumer-side; the fixes belong here.

There is no runtime change anywhere. Not one template, class, token, prop default or emitted value differs. Every break below is type-only.

What's in it

Every component has a named, exported props type — 73 of them. The package already shipped real generated types, but carried 71 internal __VLS_Props* interfaces and zero exported *Props, so a consumer wrapping a component could not name its prop types and had to re-declare the unions by hand. The supporting types (Tone, Category, SelectOption, DataTableColumn, BreadcrumbItem, TabItem, RowKey, SortState, IconName) were already exported, which is what made the omission conspicuous.

Named interfaces rather than a derived ComponentProps<typeof X> barrel, because a derived conditional type is not statically resolvable by @vue/compiler-sfc — a consumer could import it but not use it in defineProps<>. Verified through a consumer fixture resolving the package via its own exports map, including that defineProps<ButtonProps>() resolves out of the published .d.ts into correct runtime props.

BreadcrumbItem.href accepts null. Breadcrumbs already renders a non-link crumb when href is falsy — the type was narrower than the behaviour. The interior crumb is now a story, since the behaviour was reachable but undocumented.

SelectOption carries its value type through the closed-set pickers. Generic with default string | number, not string: narrowing the default would silently break every existing consumer carrying numeric ids, which the package supports on purpose, and the breakage would surface in consumers rather than here. SearchableSelect and Combobox are generic over the value and infer from options and modelValue.

Select itself is deliberately not generic: it is a native <select>, its change event carries HTMLSelectElement.value, and the DOM has already stringified it. A SelectOption<number> there emits "1", not 1 — typing the emit as T would be a lie the compiler could not catch.

Every array a component accepts is readonly. Vue props cannot be mutated at runtime, so options: SelectOption[] was never a promise the component kept — it only rejected callers holding a ReadonlyArray. Applied to all 13 array-taking components; composables widen their inputs and keep returning mutable arrays, copying once at the boundary.

Version guard. release/v1.16.1 was bumped correctly and then tagged v1.17.0 by hand. The Release workflow's existing tag/manifest guard caught the mismatch and refused to publish — so 1.16.1 never reached the registry and no GitHub Release exists — and none of that helped, because the package is a git dependency and #v1.17.0 installs straight from the tag. verify:version now runs on every PR (needs fetch-depth: 0) and asserts the manifest neither matches an existing tag nor sits behind the highest one. Both failure modes are proven, not assumed.

Breaking changes — type-only, three of them

  1. BreadcrumbItem.href is string | null | undefined. Code that reads a crumb's href into a string needs a fallback. Building crumbs is strictly freer.
  2. Array props are readonly T[]. const steps: Step[] = props.steps needs a copy. Passing arrays in is strictly freer.
  3. SearchableSelect and Combobox are generic components, so typeof SearchableSelect is no longer a plain DefineComponent and Meta<typeof …> needs the same untyped treatment DataTable has always needed. Their own stories are updated. Templates unaffected.

Version: 1.18.0, skipping 1.17.0

Not reclaiming 1.17.0 — that tag is permanent and installable, so re-pointing it would leave two different trees answering to the same install.

Minor rather than major because there is no runtime change and the breaks are narrow and mechanical; and because 2.0.0 is already spoken for by the deprecated LegacyTone aliases, documented for removal "in the next major release". If you would rather be strict about type-surface SemVer, 2.0.0 is defensible — but then LegacyTone should ride along.

Verification

Build, vue-tsc, ESLint and all 243 Playwright tests pass. A new verify:props build step fails the build if a component's props are not declared, not re-exported, or dropped by api-extractor. A deeply-readonly as const fixture is verified to bind to all eleven array-taking components from a consumer.

Release with npm version from the manifest — do not hand-write the tag.

🤖 Generated with Claude Code

A consuming app that wraps an atom could not name the wrapped component's
prop types. The supporting types were all exported — Tone, Category,
SelectOption, DataTableColumn, BreadcrumbItem, TabItem, RowKey, SortState,
IconName — but the props themselves reached dist as 71 anonymous
__VLS_Props interfaces, which nothing can import. So a wrapper re-declared
the unions by hand: `variant: String` compiles in the app and then fails
against `'danger' | 'primary' | …` at the boundary.

Every `defineProps<{ … }>()` type literal becomes an `export interface
<Name>Props`, re-exported from the barrel. dist/index.d.ts now carries 73
named prop interfaces and zero __VLS_Props.

verify:props-exports keeps the three parts in step: the SFC declares the
interface, the barrel re-exports it, and api-extractor carries it into the
bundled declarations. Only the last is observable to a consumer, and a type
dropped from the rollup is invisible until an app tries to import it.

Verified against a consumer fixture resolving the package through its
exports map: vue-tsc accepts `ModalProps['size']` and `PageHeadingProps &
{ … }`, and @vue/compiler-sfc resolves `defineProps<ButtonProps>()` out of
the published .d.ts into correct runtime props.
The template has always rendered a plain <span> for a crumb with a falsy
href — the type just never said so. `href: string` is narrower than the
behaviour, and every caller assembling a trail from optional route data
paid for the gap: typing one wrapper's `breadcrumbs` prop as
BreadcrumbItem[] in a consuming app produced ~64 errors, all of them this
one restated.

Adds the interior non-link crumb as a story, since it was reachable but
undocumented.
`SelectOption.value` was `string | number` for everyone, so every consumer
writing a picked value into a string-typed form field coerced it back with
String() — a dozen sites in one app alone.

SelectOption takes a value-type parameter, and the two controls that hand
the option's value back to the caller — SearchableSelect via
update:modelValue, Combobox via @select — are generic over it. Both infer T
from `options` and `modelValue` together, so a plain `string` model widens T
rather than pinning it to the literal union of an inline options array.

The default stays `string | number`. Narrowing it to `string`, as asked,
would silently break every existing `SelectOption[]` annotation carrying
numeric ids — this package supports those on purpose (Select's modelValue is
`string | number | null`), and the breakage would surface in consumers, not
here.

Select itself stays non-generic, and says why in the source: it is a native
<select>, its change event carries HTMLSelectElement.value, and the DOM has
already stringified that. A SelectOption<number> there emits "1", not 1;
typing the emit as T would be a lie the compiler could not catch.

The two now-generic components take DataTable's untyped-Meta treatment in
their stories — Meta<typeof X> cannot represent a generic component. Neither
story drives the component through args, so nothing is lost.
The stance was not inconsistent so much as absent: no component accepted a
readonly array, so a caller holding one — generated translation types with
readonly leaf arrays, an `as const` fixture, anything frozen — had to copy at
every call site.

Readonly wins, and not as a compromise. Vue props cannot be mutated at
runtime, so `options: SelectOption[]` was never a promise the component kept;
it only filtered out callers whose array happened to be readonly. Declaring
the input readonly says what is already true and accepts strictly more.

Applied to every array prop: Accordion, Breadcrumbs, Chart, Combobox,
DataTable, FileInput, KindLegend, PageHeading, ResourceList,
SearchableSelect, Select, Stepper, Tabs.

Readonly in, mutable out. The headless composables widen their INPUTS
(useSort's rows, usePagination's sliceOf, useSelection's keys and controlled
selection) — a pure widening, so nothing that called them stops compiling —
and keep handing back mutable arrays, copying once at the boundary where a
readonly source has to become one. useSort's unsorted branch now copies
instead of passing the caller's array straight through, which it should have
been doing regardless.

Select spells it `ReadonlyArray<SelectOption>`, and says why inline:
eslint-plugin-vue's inference does not see through `readonly X[] | Record<…>`
and rejects the `() => []` default. Same type, same emitted runtime prop.

`verify:props` (renamed from verify:props-exports) now fails the build on a
mutable array prop, because the stance is only worth anything if it holds for
all of them.

Verified from a consumer: a deeply-readonly `as const` fixture binds to all
eleven array-taking components.
Tag v1.17.0 points at a tree whose package.json says 1.16.1:
`release/v1.16.1` was bumped correctly and then tagged by hand under the
wrong name. The Release workflow's tag/manifest guard caught it and refused
to publish — so 1.16.1 never reached the registry and no GitHub Release was
cut — and none of that mattered, because this package is documented as a git
dependency and `#v1.17.0` installs straight from the tag. Consuming apps
pinned it and got 1.16.1. Nothing was broken; nothing said so either.

The existing guard is not wrong, it is just downstream of the mistake: by the
time it runs, the tag is pushed and permanent. So `verify:version` runs on
every pull request instead, and asserts the manifest neither matches an
existing tag nor sits behind the highest one. Both halves are needed — the
first catches a number already spent, the second a bump that never happened.
Requires `fetch-depth: 0`, since tags are the whole input.

The version goes to 1.18.0, skipping 1.17.0 rather than reclaiming it. A tag
is a release even when the release failed, and re-pointing one would leave
two different trees answering to the same install.

README gains the release checklist that would have prevented this, whose
first line is the one that was skipped: bump with `npm version`, which
derives the tag from the manifest, and never write a tag name by hand.
CHANGELOG records what v1.17.0 actually is, for whoever pins it next.
@aicodebar
aicodebar merged commit 0260c5f into main Aug 8, 2026
2 checks passed
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