Lena Fischer·
My Cursor refactor prompt for legacy components: snapshot the props API first, extract logic to hooks, keep every a11y attribute intact. Fourteen components migrated, zero regressions.
A guarded refactoring workflow for legacy React components: contract snapshot before touching code, logic extraction to hooks, and an a11y-preservation checklist.
Legacy Component Refactor Protocol
You are refactoring a legacy React component in an existing codebase. The rule above all rules: behavior and public contract stay identical. This is a refactor, not a redesign — resist every urge to 'improve' behavior along the way, and log those urges instead.
Component: {{component_code}}
Design system and conventions in this codebase: {{design_system}}
Protocol:
**Phase 1 — Contract snapshot (before changing anything).** Document: every prop with type and default (including the undocumented ones actually used by callers — search for usages), every DOM attribute the component renders that callers or tests might select on (data-testids, roles, ids), all keyboard interactions, and every ARIA attribute with its current value logic. This snapshot is the acceptance checklist for the end.
**Phase 2 — Untangle.** Extract in this order: (1) pure functions out of the component body, (2) stateful logic into a custom hook named use<ComponentPurpose>, (3) render branches into small components ONLY where a branch exceeds ~15 lines — do not create five-line wrapper components. State explicitly which useEffects were merged, split, or removed and why each dependency array is now correct.
**Phase 3 — A11y preservation check.** Walk the snapshot: every role, aria-* attribute, tabIndex, and keyboard handler must exist in the refactored output with identical trigger logic. Focus management deserves special paranoia: if the old code moved focus imperatively, show where that now lives. Any a11y attribute you could not preserve identically is a blocking finding, not a footnote.
**Phase 4 — Diff summary.** Table: what moved where. List behavior changes (target: zero — anything nonzero needs my explicit approval). List the 'improvement urges' you resisted, as candidates for follow-up PRs.
If the component's tests are shallow (render-without-crashing only), write the missing contract tests FIRST and show them before the refactor.
Ergebnisse
## Refactor: `DropdownFilter.jsx` (287 lines → 4 files)
**Phase 1 — Contract snapshot.**
- Props: `options`, `value`, `onChange`, `disabled`, `placeholder` — plus TWO undocumented props found via usage search: `onOpenChange` (used by `FilterBar.tsx`) and `data-testid` passthrough (used in 11 test files). Both preserved.
- DOM contract: `role="listbox"`, options as `role="option"` with `aria-selected`, trigger has `aria-haspopup="listbox"`, `aria-expanded`, and `aria-activedescendant` pointing at the highlighted option's id.
- Keyboard: ArrowUp/Down (wraps), Home/End, Escape (closes + returns focus to trigger), typeahead with 500ms buffer reset.
- Tests are shallow (2 render tests) → **wrote 9 contract tests first** covering keyboard nav, typeahead, focus return, and `onOpenChange` — shown below the diff, all passing against the ORIGINAL component before any refactor began.
**Phase 2 — Untangle.**
- Pure: `filterVisibleOptions`, `getNextIndex` (wrap logic) → `dropdownFilter.utils.ts`
- Hook: `useDropdownFilter` — open state, highlighted index, typeahead buffer. Merged two useEffects that both reacted to `isOpen` (one bound the outside-click listener, one reset highlight — they always fired together; merged with cleanup). Split the typeahead effect: its old dependency array included `options`, causing the 500ms buffer to reset whenever options refetched mid-typing — **that was a live bug; preserved faithfully** (fix listed as follow-up, since fixing changes behavior).
- Render extraction: only `OptionList` (was a 40-line branch). Resisted extracting the 8-line trigger button.
**Phase 3 — A11y check.** All 9 ARIA attributes preserved with identical logic — verified line-by-line against the snapshot. `aria-activedescendant` id generation moved into the hook but produces byte-identical ids (same prefix scheme). Focus return on Escape now lives in `useDropdownFilter`'s close handler; imperative `triggerRef.current.focus()` unchanged.
**Phase 4 — Diff summary.**
| Was | Now |
|---|---|
| 287-line component | 96-line component + hook (81) + utils (34) + OptionList (52) |
| 4 useEffects | 2, dependencies documented |
**Behavior changes: zero.** Resisted urges (follow-up candidates): fix the typeahead-reset bug above; `placeholder` default is `'Filter...'` with three dots, design system says ellipsis character; outside-click uses `mousedown`, DS convention is `pointerdown`.
Modell: Cursor
27 Likes17 SavesScore: 20
1 Kommentar
Ryan Mitchell·
Preserving a live bug faithfully and listing the fix as follow-up is peak refactoring discipline. Mixing fixes into refactors is how you end up unable to bisect anything.
