Lesson 15 / 19
Versioning and Release
What counts as breaking in a component interface, deriving the version number from comparing two snapshots, generating the migration guide, and measuring migration cost by call site.
Contents
Comparing the doc’s prop table against the interface left a question open: adding a
width prop to button and removing the icon prop from badge do not carry the
same weight. The first breaks no usage; the second breaks every place that passes that
prop.
Versioning turns this weight difference into a number. Semantic versioning, introduced in the Modules, Tooling and the Ecosystem course, ties a package’s version number to the class of a change. Applying it to the component catalog needs exactly one thing: an exact list of what counts as breaking in a component interface.
What Is Breaking in a Component Interface
A component’s contract is not limited to its props, but its props are the measurable part of it. The following changes are breaking:
- Removing a component. The most visible one; any import breaks immediately.
- Removing a prop. Calls that pass that prop go silently inert.
- Renaming a prop. It looks like a removal combined with an addition, but the migration step is different.
- An optional prop becoming required. Every call that does not pass the prop breaks.
- A default value changing. Every call that never touched the source text sees different behavior.
- A value set narrowing. Calls passing the value that was dropped fall into an undefined state.
Two other classes fall outside this list and are compatible: additive — a new component, a new optional prop, a widened value set — and patch, a behavior fix that does not change the interface.
One important item is missing from the list: the DOM tree structure and class names. If a component’s output tree can be styled from outside with CSS, that tree is also part of the contract, and changing it is breaking. The catalog therefore either brings the tree into the contract explicitly or states explicitly that it does not; a system that does neither produces unnamed breaking changes with every release.
Computing the Diff
The program below takes two snapshots of the catalog, applies the rules above, classifies the changes, derives the version number, and generates the migration guide. Migration cost is measured by the number of call sites each change affects.
// version.mjs — comparing two catalog snapshots and deriving the version number // A snapshot: component -> prop name -> { value set, default, required } const OLD = { version: "3.4.2", component: { button: { type: { values: ["primary", "secondary", "silent"], default: "secondary", required: false }, size: { values: ["small", "medium"], default: "medium", required: false }, icon: { values: null, default: null, required: false } }, badge: { tone: { values: ["neutral", "positive", "warning", "negative"], default: "neutral", required: false }, size: { values: ["small", "medium"], default: "medium", required: false }, icon: { values: null, default: false, required: false } }, dropdown: { size: { values: ["small", "medium"], default: "medium", required: false }, state: { values: null, default: null, required: false } }, modal: { size: { values: ["medium", "large"], default: "medium", required: false }, title: { values: null, default: null, required: false } }, "notification-banner": { tone: { values: ["info", "positive", "warning", "negative"], default: "info", required: false }, dismissible: { values: null, default: true, required: false } }, "loading-indicator": { size: { values: ["small", "medium"], default: "medium", required: false } }, skeleton: { rows: { values: null, default: 3, required: false } }, }, }; const NEW = { component: { button: { type: { values: ["primary", "secondary", "silent", "danger"], default: "secondary", required: false }, size: { values: ["small", "medium"], default: "medium", required: false }, "icon-name": { values: null, default: null, required: false }, width: { values: ["content", "full"], default: "content", required: false } }, badge: { tone: { values: ["neutral", "positive", "warning", "negative"], default: "neutral", required: false }, size: { values: ["small", "medium"], default: "small", required: false } }, dropdown: { size: { values: ["small", "medium"], default: "medium", required: false }, state: { values: null, default: null, required: false }, searchable: { values: null, default: false, required: false }, multiple: { values: null, default: false, required: false } }, modal: { size: { values: ["medium", "large"], default: "medium", required: false }, title: { values: null, default: null, required: true }, dismissible: { values: null, default: true, required: false } }, "notification-banner": { tone: { values: ["positive", "warning", "negative"], default: "warning", required: false }, dismissible: { values: null, default: true, required: false } }, skeleton: { rows: { values: null, default: 3, required: false } }, "number-field": { size: { values: ["small", "medium"], default: "medium", required: false } }, }, }; // Rename is reported by hand; otherwise it looks like a removal + an addition. const RENAMES = [{ component: "button", old: "icon", new: "icon-name" }]; // Call site counts: these determine the migration cost. const USAGE = { button: 412, badge: 188, dropdown: 57, modal: 45, "notification-banner": 63, "loading-indicator": 31, skeleton: 38 }; // Call sites per prop (places that pass that prop explicitly). const PROP_USAGE = { "button.icon": 96, "badge.icon": 24, "badge.size": 61, "notification-banner.tone": 63, "modal.title": 40 }; const sameSet = (a, b) => JSON.stringify(a) === JSON.stringify(b); const findRename = (component, name, dir) => RENAMES.find((r) => r.component === component && r[dir] === name); const changes = []; const add = (cls, component, description, migrationStep, impact) => changes.push({ cls, component, description, migrationStep, impact }); for (const [name, oldProps] of Object.entries(OLD.component)) { const newProps = NEW.component[name]; if (!newProps) { add("breaking", name, "component removed", "use the skeleton component instead", USAGE[name] ?? 0); continue; } for (const [propName, oldProp] of Object.entries(oldProps)) { const renamed = findRename(name, propName, "old"); if (renamed) { add("breaking", name, `prop renamed: ${propName} → ${renamed.new}`, `every call passing ${propName} switches to ${renamed.new}`, PROP_USAGE[`${name}.${propName}`] ?? 0); continue; } const newProp = newProps[propName]; if (!newProp) { add("breaking", name, `prop removed: ${propName}`, `removed from calls passing ${propName}`, PROP_USAGE[`${name}.${propName}`] ?? 0); continue; } if (!oldProp.required && newProp.required) { add("breaking", name, `prop became required: ${propName}`, `a value is added to calls not passing ${propName}`, (USAGE[name] ?? 0) - (PROP_USAGE[`${name}.${propName}`] ?? 0)); } if (oldProp.default !== newProp.default) { add("breaking", name, `default changed: ${propName} ${JSON.stringify(oldProp.default)} → ${JSON.stringify(newProp.default)}`, `calls not passing ${propName} now write the old value explicitly`, (USAGE[name] ?? 0) - (PROP_USAGE[`${name}.${propName}`] ?? 0)); } if (oldProp.values && newProp.values && !sameSet(oldProp.values, newProp.values)) { const dropped = oldProp.values.filter((d) => !newProp.values.includes(d)); const added = newProp.values.filter((d) => !oldProp.values.includes(d)); if (dropped.length) { add("breaking", name, `value set narrowed: ${propName} ${JSON.stringify(dropped)} dropped`, `calls passing ${JSON.stringify(dropped)} move to another value`, PROP_USAGE[`${name}.${propName}`] ?? 0); } if (added.length) add("additive", name, `value set widened: ${propName} ${JSON.stringify(added)} added`, "-", 0); } } for (const propName of Object.keys(newProps)) { if (oldProps[propName]) continue; if (findRename(name, propName, "new")) continue; const cls = newProps[propName].required ? "breaking" : "additive"; add(cls, name, `${cls === "breaking" ? "required " : ""}prop added: ${propName}`, cls === "breaking" ? `${propName} is added to every call` : "-", cls === "breaking" ? (USAGE[name] ?? 0) : 0); } } for (const name of Object.keys(NEW.component)) { if (!OLD.component[name]) add("additive", name, "component added", "-", 0); } const ORDER = { breaking: 0, additive: 1, patch: 2 }; changes.sort((a, b) => ORDER[a.cls] - ORDER[b.cls] || b.impact - a.impact); console.log("class component change"); for (const d of changes) console.log(`${d.cls.padEnd(10)} ${d.component.padEnd(20)} ${d.description}`); const count = { breaking: 0, additive: 0, patch: 0 }; for (const d of changes) count[d.cls]++; console.log(`\nbreaking: ${count.breaking} additive: ${count.additive} patch: ${count.patch}`); // Version derivation: the heaviest class determines which component of the version increases. function nextVersion(current, count) { const [major, minor, patch] = current.split(".").map(Number); if (count.breaking > 0) return `${major + 1}.0.0`; if (count.additive > 0) return `${major}.${minor + 1}.0`; return `${major}.${minor}.${patch + 1}`; } console.log(`current version: ${OLD.version} derived version: ${nextVersion(OLD.version, count)}`); console.log(`if breaking changes were held back: ${nextVersion(OLD.version, { breaking: 0, additive: count.additive, patch: count.patch })}`); // Migration guide: breaking changes only, with the call site count each one affects. console.log("\n— MIGRATION GUIDE —"); let totalImpact = 0; for (const d of changes.filter((x) => x.cls === "breaking")) { totalImpact += d.impact; console.log(`* ${d.component}: ${d.description}\n to do: ${d.migrationStep}\n affected call sites: ${d.impact}`); } console.log(`\ntotal migration cost: ${totalImpact} call sites`); console.log(`average per breaking change: ${(totalImpact / count.breaking).toFixed(1)} call sites`);
class component change
breaking badge default changed: size "medium" → "small"
breaking button prop renamed: icon → icon-name
breaking notification-banner value set narrowed: tone ["info"] dropped
breaking loading-indicator component removed
breaking badge prop removed: icon
breaking modal prop became required: title
breaking notification-banner default changed: tone "info" → "warning"
additive button value set widened: type ["danger"] added
additive button prop added: width
additive dropdown prop added: searchable
additive dropdown prop added: multiple
additive modal prop added: dismissible
additive number-field component added
breaking: 7 additive: 6 patch: 0
current version: 3.4.2 derived version: 4.0.0
if breaking changes were held back: 3.5.0
— MIGRATION GUIDE —
* badge: default changed: size "medium" → "small"
to do: calls not passing size now write the old value explicitly
affected call sites: 127
* button: prop renamed: icon → icon-name
to do: every call passing icon switches to icon-name
affected call sites: 96
* notification-banner: value set narrowed: tone ["info"] dropped
to do: calls passing ["info"] move to another value
affected call sites: 63
* loading-indicator: component removed
to do: use the skeleton component instead
affected call sites: 31
* badge: prop removed: icon
to do: removed from calls passing icon
affected call sites: 24
* modal: prop became required: title
to do: a value is added to calls not passing title
affected call sites: 5
* notification-banner: default changed: tone "info" → "warning"
to do: calls not passing tone now write the old value explicitly
affected call sites: 0
total migration cost: 346 call sites
average per breaking change: 49.4 call sites
Deriving the Version Number
The version number is not a decision but a result: the program finds seven breaking changes, so version 3.4.2 goes to 4.0.0. Had the breaking changes been held back and only the additions shipped, the version would be 3.5.0 — this second line turns the version number from a debatable topic into the result of release scope.
This distinction produces a practical option: if none of the seven breaking changes are mandatory, the additive release can ship right away and the breaking changes accumulate for the next major version. Accumulating them is cheaper than shipping each in its own major version — teams using the catalog migrate once, not seven times.
The rename row shows the boundary of the diff: icon becoming icon-name is
recognized as a rename only because it was reported by hand. Unreported, it would have
produced two rows — “prop removed: icon” and “prop added: icon-name.” The version
number would still read 4.0.0, but the migration guide would be wrong, with no way to
know the removal and the addition are two faces of the same prop. Taking the diff is
automatic; declaring intent is manual.
Measuring Migration Cost
The migration guide is the breaking changes sorted by call site count, and this ranking feeds two decisions.
The first is which breaking change is actually worth it: badge‘s size default
change affects 127 call sites, removing the icon prop affects only 24 — two changes
in the same release differing in cost by more than fivefold. If the default change’s
reason does not justify those 127 sites, it is pulled back and dropped from the
breaking list.
The second is how the migration is carried out. Some steps are mechanical — a
rename is a string substitution, automatable with a codemod. Others require a
decision: which of the 63 calls passing notification-banner’s info tone should
become positive and which warning cannot be decided without looking. Separating
these two classes is what makes the migration-time estimate realistic.
The last row shows an interesting case: notification-banner’s default-change impact
is zero, because all 63 calls using it already pass tone explicitly. The change is
breaking by contract — it bumps the version number — but carries no migration cost.
Contract and cost are separate: the version number looks at the contract, the release
plan at the cost.
Release Shape
Whether the catalog ships as a single package or as a separate package per component changes the distribution of the numbers above.
When shipped as a single package, a breaking change in one component bumps the
whole package’s major version. In the example above, the team using skeleton is
forced onto 4.0.0 because of badge, even though nothing changed for them, and they
still must make a migration decision. In exchange, dependency management stays single
and no cross-version compatibility problem arises.
When shipped as one package per component, each component moves at its own pace and migration decisions separate out. The cost is that different versions of the same system can sit side by side in the dependency tree; if two components bind to different versions of the same token layer, consistency — the system’s reason for existing — is lost.
The choice is made by comparing two numbers: the call sites forced into an unnecessary migration versus the inconsistency produced by side-by-side versions. A single package is cheap when the catalog is small and its components are tightly coupled; separate packages become cheap as the catalog grows and the coupling between components loosens.
The Deprecation Window
A breaking change is not made directly: a deprecation mark is placed first, then removal follows. The gap between them is a window, measured in releases, not on a calendar — “for one major version” is more useful than “for three months,” since it keeps its meaning even if the release cadence changes.
The window’s job is to keep migration from piling up at release time. Had icon been
deprecated one major version earlier, part of its 96 call sites would already have
moved, and the 4.0.0 release’s migration cost would have come out lower. This is also
why the deprecation warning must include the migration step; a warning that only
says “this prop will be removed” speeds up nothing.
Summary
- What counts as breaking in a component interface is a countable list: removing the component, removing or renaming a prop, a prop becoming required, a default changing, and a value set narrowing.
- The version number is not a decision but the result of the heaviest change class in the release; accumulating breaking changes into a single major version is cheaper than shipping each one separately.
- Taking the diff can be automated, but intent declarations like a rename must be made by hand; if they are not reported, the guide comes out wrong.
- Migration cost is measured by the number of affected call sites, and mechanical steps must be separated from steps that require a decision.
- Contract and cost are separate things: a change with zero migration cost can still bump the version number.
- The choice between a single package and one package per component is made by comparing the call sites forced into unnecessary migration against the inconsistency produced by side-by-side versions.
Next Step
The version and migration guide this lesson produced did not ask where the changes
came from; the interface definition was read twice, and something had changed in
between. Who proposed the change, who reviewed it, and by what criterion it was
accepted stayed open. Whose decision was it to add the width prop to button, and how
many days did that decision take? The next lesson builds the contribution process as a
state machine, detects invalid transitions, and measures the time from proposal to
release by breaking it into stages.
To keep your progress and take notes, Log in
My notes
Log in to take notes.