v1.18.0 — exported prop types, nullable breadcrumb hrefs, readonly array props - #26
Merged
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Five changes, all raised by a consumer (
flows.codebar) that stood up avue-tsclane 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 indefineProps<>. Verified through a consumer fixture resolving the package via its ownexportsmap, including thatdefineProps<ButtonProps>()resolves out of the published.d.tsinto correct runtime props.BreadcrumbItem.hrefaccepts null.Breadcrumbsalready renders a non-link crumb whenhrefis falsy — the type was narrower than the behaviour. The interior crumb is now a story, since the behaviour was reachable but undocumented.SelectOptioncarries its value type through the closed-set pickers. Generic with defaultstring | number, notstring: 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.SearchableSelectandComboboxare generic over the value and infer fromoptionsandmodelValue.Selectitself is deliberately not generic: it is a native<select>, its change event carriesHTMLSelectElement.value, and the DOM has already stringified it. ASelectOption<number>there emits"1", not1— typing the emit asTwould 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 aReadonlyArray. 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.1was bumped correctly and then taggedv1.17.0by 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.0installs straight from the tag.verify:versionnow runs on every PR (needsfetch-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
BreadcrumbItem.hrefisstring | null | undefined. Code that reads a crumb's href into astringneeds a fallback. Building crumbs is strictly freer.readonly T[].const steps: Step[] = props.stepsneeds a copy. Passing arrays in is strictly freer.SearchableSelectandComboboxare generic components, sotypeof SearchableSelectis no longer a plainDefineComponentandMeta<typeof …>needs the same untyped treatmentDataTablehas 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
LegacyTonealiases, documented for removal "in the next major release". If you would rather be strict about type-surface SemVer, 2.0.0 is defensible — but thenLegacyToneshould ride along.Verification
Build,
vue-tsc, ESLint and all 243 Playwright tests pass. A newverify:propsbuild step fails the build if a component's props are not declared, not re-exported, or dropped by api-extractor. A deeply-readonlyas constfixture is verified to bind to all eleven array-taking components from a consumer.Release with
npm versionfrom the manifest — do not hand-write the tag.🤖 Generated with Claude Code