Breaking changes need a removal path
J05N split navigation from action semantics through coexistence, diagnostics, and consumer evidence instead of treating a cleaner API as permission to strand old code.
J05N had a Button component that could render either a button or a link.
Pass onClick and it rendered <button>. Pass href and it rendered <a>. Add disabled, loading, or newWindow, and internal branches tried to make the result behave sensibly. The API was convenient because several old applications had styled actions and navigation identically.
It was also wrong in ways a prop rename could not repair.
A disabled link is not a native disabled button. A loading navigation and a pending side effect have different state. Keyboard activation semantics come from the underlying element. Opening a new context is not an action variant. Prop combinations could request href, type="submit", and disabled together, leaving the component to guess the user's intention.
The clean design was obvious: separate Button for actions and Link for navigation, with a shared visual recipe where presentation overlapped.
The difficult design was how to remove the old component.
Declaring a major version would communicate incompatibility. It would not classify existing uses, preserve valid intermediate states, or prove when the ambiguous path could leave. J05N needed a removal path, not merely a breaking-change label.
The old API contained missing information
The polymorphic component accepted syntax but did not require intent.
<Button href="/reports" disabled={busy}>
View report
</Button>Was disabled meant to prevent navigation while a report generated? If so, should the destination remain discoverable? Was the current page starting an operation and navigating only after success? Should the control expose pending state? The prop set could not answer.
Another use looked mechanical:
<Button onClick={openHelp} kind="secondary">
Help
</Button>This was a local disclosure action and belonged on a button. A codemod could classify it safely.
The migration inventory found four categories:
clear navigation
clear action
action followed by navigation
ambiguous or invalid mixtureOnly the first two supported automatic conversion. The third needed an explicit operation flow. The fourth needed human review.
The old API's convenience had been purchased by deleting information. Migration could not recreate that information through clever syntax transformation.
My first instinct was to mark the component deprecated immediately so no new uses appeared. That would warn consumers before a complete alternative existed.
J05N changed the sequence:
- Define
ActionButtonandTextLinkcontracts. - Prove shared visual recipes did not collapse semantics.
- Migrate representative action and navigation uses.
- Publish diagnostics for old use categories.
- Mark the old export deprecated with a documented path.
- Migrate all known consumers.
- Prevent new imports.
- Remove the old export and its compatibility code.
The replacement needed to survive real examples first. M31V's publish action tested pending and error states. C62Y's documentation navigation tested ordinary link behavior, new tabs, and visited state. A synthetic fixture tested long labels, icons, form context, and keyboard operation.
This delayed the warning and increased its usefulness. When the old API told a consumer to move, the destination had evidence and migration instructions rather than only architectural confidence.
Deprecation became a phase in an executable plan, not an expression of dissatisfaction.
The new contracts refused invalid combinations
ActionButton preserved native button behavior and required an action-oriented state model:
type ActionButtonProps = {
type?: 'button' | 'submit' | 'reset'
disabled?: boolean
operation?: Idle | Pending
onClick?: (event: MouseEvent) => void
children: ReactNode
}It had no href or newWindow. A pending operation and a disabled control remained distinct. Pending could retain focus and provide status while suppressing unsafe duplicate activation according to the operation contract.
TextLink required href and preserved link behavior:
type TextLinkProps = {
href: string
target?: '_self' | '_blank'
rel?: string
children: ReactNode
}It had no disabled prop. If a destination was not available, the application chose between omitting the link, explaining the unavailable state, or showing a non-interactive value. Styling an anchor as disabled would not make it a button or an honest unavailable destination.
Both could consume shared typography, spacing, focus, and emphasis recipes. The shared layer did not decide element semantics.
The narrower APIs made some consumers more verbose. That verbosity represented previously hidden product decisions.
Coexistence happened at the import boundary
J05N published new components while retaining LegacyButton under the old Button export. Both could appear on one route during migration.
The implementation did not add a global mode that changed every use at once. A package-level switch would make behavior depend on deployment configuration and complicate rollback. Each import named its contract.
import { Button } from 'atlas/legacy'
import { ActionButton, TextLink } from 'atlas'The explicit legacy path made old usage searchable and prevented new documentation examples from normalizing it. Existing imports continued working through a compatibility export for one release, then a codemod moved them to the named legacy module before semantic migration began.
That preparatory codemod changed no rendering. It isolated the historical surface and made later removal evidence more reliable.
Applications could migrate route by route. If M31V exposed a regression, only its changed imports needed rollback. The rest of J05N and other projects kept their new contracts.
Coexistence was valuable because it had a direction. New code could not import from the legacy module under the lint policy, and every existing use appeared in the consumer ledger.
Diagnostics explained the category
A generic deprecation warning—“Button is deprecated”—created noise and little guidance. J05N inspected the old props in development and emitted category-specific messages.
Legacy Button received `href` without action props.
Migrate to TextLink. Mechanical fix available.Legacy Button received `onClick` and no `href`.
Migrate to ActionButton. Confirm operation pending behavior.Legacy Button received both `href` and `onClick`.
Automatic migration is unsafe. Decide whether the control navigates,
performs an action, or models an action whose success leads to navigation.Warnings included source location where the development build could provide it. Repeated renders were deduplicated by call site so the console did not become unusable.
Diagnostics never changed behavior. They observed the old contract and linked to a migration note. Production output omitted them.
The warning text treated ambiguity as a design question rather than suggesting a prop substitution that preserved the mistake.
Warnings were temporary too. Their removal was part of deleting the legacy component, not a permanent runtime cost.
Codemods handled syntax, not intent
The migration script made three safe transformations.
It changed imports to the explicit legacy path. It converted clear href-only cases to TextLink. It converted clear action-only cases to ActionButton while preserving button type and event handler.
Ambiguous cases received a structured comment and report:
pressroom/routes/publish.js:84
href + onClick
requires action/navigation decisionThe codemod refused to infer from handler names. handleOpen might navigate, disclose, or start an operation. It refused to convert a disabled link into styled text automatically because the unavailable-state presentation required context.
Each transformation preserved formatting through the existing code tooling and could run repeatedly without changing already migrated files. A dry run produced counts by category and no writes.
The script had fixture tests for import aliases, prop spreads, conditional hrefs, and JSX member expressions. Prop spreads usually forced review because the source object could contain mixed semantics.
Automation reduced repetition and increased the visibility of judgment. It did not use speed as a reason to fabricate certainty.
R7K1 had a control labeled “Open preview” with both an href and an onClick that recorded an analytics event. This was navigation; telemetry should not redefine the element as an action. It became a normal link with a narrowly scoped observation handler that could fail without preventing navigation.
M31V had “Publish and view,” which started a publish operation and then navigated to the public document. The old component began the operation in onClick while its href could still navigate immediately under some keyboard paths.
The migration turned it into an action button. Pressing it created a durable operation, displayed pending and outcome state, and navigated only after a success receipt supplied the destination. If the outcome was unknown, the interface did not encourage a repeated publish by behaving like a disabled link.
C62Y had a disabled “Next rule” link when no next item existed. The new interface omitted the link and displayed the current position. A nonexistent destination stopped masquerading as a disabled action.
The breaking change repaired product behavior because it forced missing semantics into the open. Preserving every old combination would have missed the reason to change.
Release boundaries followed consumer states
J05N used pre-1.0 versions, but the same principle applied: version numbers communicated package compatibility; they did not choose migration readiness.
The release sequence was:
0.6: add ActionButton and TextLink, no deprecation
0.7: publish migration tooling and categorized warnings
0.8: move old export to legacy path, block new use
0.9: remove legacy export after consumer evidenceIf a consumer remained, 0.9 did not ship merely because the planned month arrived. The removal condition named known applications and fixtures. Conversely, once evidence was complete, the old path did not linger for emotional safety.
Release notes stated behavior, not only exports. They explained that navigation retained normal link semantics, action pending state remained focusable where appropriate, and mixed action-navigation flows required an operation decision.
The removal release included a failure mode for forgotten consumers: importing the legacy path produced a clear build error with the archived migration guide, not a mysterious missing property at runtime.
Semver described the boundary after the plan did the work.
Before removal, J05N gathered several forms of evidence:
- The consumer ledger had no unresolved old instances.
- Static search found no legacy imports outside migration fixtures.
- The lint rule prevented new imports on every project build.
- Development warnings did not appear while exercising inventoried routes.
- Packed-package fixture applications compiled and rendered without the legacy export.
- Visual and interaction scenarios passed for representative new components.
- Removing the legacy module locally did not change the emitted consumer bundles except where expected.
No single check was sufficient. A source search could miss dynamic re-exports. Runtime exercise could miss an unvisited route. A clean build could preserve dead compatibility code.
The evidence matched the known scope of my personal projects; it did not claim unknown external consumers. The package was not broadly published, so the removal decision remained bounded and inspectable. If it had public consumers, the policy would require a longer support and communication window.
Removal confidence came from overlapping evidence tied to declared consumers.
Documentation moved users in one direction
During coexistence, documentation could accidentally keep the old contract alive. Search results and copied examples often outlast a deprecation notice at the top of an API page.
J05N changed current examples to the new components as soon as they became preferred. The legacy page remained reachable from warnings and migration notes, but it no longer appeared in the main component navigation or example index. Its first section stated the supported removal window and linked to the classification guide.
Examples were deliberately separated by intent:
Navigate to a report -> TextLink
Submit this form -> ActionButton with type="submit"
Start an operation -> ActionButton with operation state
Start, then navigate after success -> explicit operation flow
Unavailable destination -> explanation, not a disabled linkThis mattered because an API reference cannot correct a category error if every demonstration begins with visual variants. The documentation taught readers to choose semantics before appearance.
Old deep links received stable redirects to the archived migration page after removal. They did not redirect silently to ActionButton, which would imply that every previous use had been an action. Error messages for legacy imports used the same destination.
Release notes and catalogue search were tested like other migration surfaces. A query for “disabled link” returned guidance explaining the unsupported pattern rather than the removed prop. A query for the old Button name returned the decision record and two replacement paths.
Documentation therefore participated in the one-way transition. Coexistence in code did not require presenting both contracts as equally current.
The goal was for a new consumer to encounter only the maintained model while an existing consumer could still find the exact history needed to leave the old one.
Visual consistency survived semantic separation
Splitting the components risked visual drift. The old polymorphic Button had guaranteed one style implementation.
J05N extracted presentation recipes below the semantic components:
action emphasis roles
inline link roles
focus indicator
icon-label spacing
size and density mappingsActionButton used native button structure with action presentation. TextLink used anchor structure and link presentation. A special call-to-action link could share selected emphasis roles without importing disabled or pending action behavior.
Visual regression scenarios compared old and new uses where presentation was intended to remain stable. Differences were reviewed, not automatically rejected. Link visited state became newly visible in places the old component had suppressed it; that was an intentional semantic improvement.
The design system retained coherent appearance by sharing maintained decisions, not by routing different elements through one behavior switchboard.
This was the central lesson in miniature: reuse can move down a layer when a top-level abstraction owns too much.
A new ActionButton release initially changed event timing in a form. M31V's submit handler observed a disabled state earlier than its old code expected, and one validation message failed to appear.
We rolled back the route import to LegacyButton while keeping its classification, migration note, and tests. The new component received a fix and a focused form fixture. The migration resumed in the next patch.
Rollback did not reset the entire breaking-change project. Because consumers moved independently and the old path still existed under an explicit module, one route could retreat without making new uses acceptable again.
The incident validated coexistence. A flag-day removal would have pressured us to patch forward inside a broad change or revert the whole release.
The rollback receipt also recorded why the old path remained for that consumer. Temporary exceptions require evidence or they become silent permanence.
A removal path is reliable partly because it includes places to stop and return.
Once every consumer migrated, J05N deleted LegacyButton, its prop-normalization branches, categorized warnings, old catalogue examples, codemod support for already completed phases, and compatibility tests. The archived migration guide remained in decision history but disappeared from current component navigation.
Removal shipped separately from the last consumer migration. That separation made the diff easy to inspect: no product behavior was meant to change; only unreachable compatibility left.
Bundle inspection confirmed that the normalizer and warning strings disappeared. Type declarations no longer admitted old prop combinations. The legacy import path failed with the intended package error.
We also removed exceptions from lint configuration. A migration is not complete while policy still contains allowances for code that no longer exists.
The deletion was intentionally boring. All risky semantic work had happened while both paths were available and observable.
This reversed my earlier instinct to make the breaking release the dramatic moment. The best removal is the administrative end of a transition consumers have already completed.
Clean APIs are outcomes, not starting rights
The split API was better. It made native semantics explicit, prevented invalid combinations, improved keyboard behavior, and gave pending operations a truthful model. None of those benefits entitled J05N to delete the old path before its consumers could move.
A breaking version tells a consumer that compatibility changed. A removal path tells both maintainer and consumer how the change can become true safely.
That path includes a replacement with real evidence, classified usage, coexistence, targeted diagnostics, automation that admits ambiguity, consumer-by-consumer migration, rollback, and a verifiable removal condition. It treats compatibility code as temporary infrastructure with an owner and an exit.
The old Button had hidden intent to create convenience. Its migration could not be universally mechanical because the missing intent had to be restored by people who understood each task.
J05N ended with two narrower components and less code. The more important result was a reusable release discipline: do not ask a version number to perform change management.
An API becomes clean when the software depending on its old meaning has safely crossed the boundary—not when the maintainer first becomes dissatisfied with its shape.