Skip to content
academia.sh

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.

Start typing to search.

↑↓ Esc navigate · open · close