-
Notifications
You must be signed in to change notification settings - Fork 31
High Risk Refactor Guidelines
This document captures proven techniques for executing complex, high-risk refactors in this codebase. These guidelines were derived from the BlockEditor refactor (January 2026) and refined during planning for subsequent refactors (February 2026).
Not all extractions carry equal risk. Structure refactors into phases ordered by increasing risk:
| Phase | Risk Level | What to Extract |
|---|---|---|
| First | Low | Pure utilities (no dependencies), simple hooks (minimal state), trivial components |
| Middle | Medium | Hooks with moderate interdependencies, components with clear boundaries |
| Last | High | Complex hooks with circular dependencies, components with extensive prop threading |
Rationale: Low-risk extractions establish testing infrastructure and build confidence before tackling dangerous changes.
Before writing any extraction code, conduct a thorough investigation phase:
- Map consumers: Build a table of each public export → its consumers. This tells you the blast radius of every extraction.
- Map internal dependencies: Diagram how the module's internals chain together (what calls what, what depends on what).
- Document prop flows between parent and child components.
- Identify circular dependencies that will require architectural solutions.
-
Capture baseline metrics: Record
typechecktime,test:citime/pass count,buildtime, and known flaky tests. Without baselines, you can't detect the soft metric regressions defined in Success Metrics. - Surface cross-cutting decisions: Investigation often reveals design choices that affect multiple phases (e.g., mocking strategy, import style, interface changes). Document each as a named decision with options, tradeoffs, and which phases it gates. Resolve all decisions before Phase 1.
- Identify domain-specific invariants: Beyond "tests pass," identify behavioral contracts the refactor must preserve (e.g., "event X fires with payload Y," "function Z is idempotent"). These become rollback triggers — if violated, stop and revert.
This investigation phase should produce zero code changes but inform all subsequent design decisions.
For existing tests:
- Do NOT modify existing unit tests during the refactor
- Do NOT modify e2e tests at all
- Existing tests (especially e2e) are your contract validators
- If e2e tests pass, the refactor preserves behavior
Exception: Only modify existing tests if they test implementation details that necessarily changed, and only after the extraction is stable.
Every phase that changes code must follow a pre-test / extract / post-test sandwich:
Phase N:
Pre-test → Targeted contract/smoke tests for the specific code being extracted
Extract → Perform the extraction
Post-test → Unit tests for the newly extracted modules
Checkpoint → Baseline + new tests all pass
Pre-testing (contract, smoke): Verify behaviors of the overall parts from the outside before the extraction begins. These tests are scoped to the specific behaviors at risk in the current phase, not the entire system. Pre-tests are disposable safety nets — they exist to catch regressions during the refactor and can be deleted once permanent post-tests provide equivalent coverage.
Post-testing (unit): Verify behaviors of the final modules in isolation after extraction. These tests are permanent — they provide long-term coverage for the extracted modules and will be maintained after the refactor.
Baseline behavioral tests (Phase 0): A broad, shallow smoke suite established before any code changes. This suite runs at every checkpoint across all phases as a regression gate. Baseline tests are disposable — they can be deleted after the refactor lands if permanent post-tests provide better coverage. Per-phase pre-tests supplement the baseline — they do not replace it.
Label test lifecycle intent: Mark each test file or describe block as disposable or permanent. This prevents test debt accumulation and ensures cleanup happens at the end.
Rationale: Front-loading all contract testing into Phase 0 has a weakness — you're writing behavioral tests for risks you haven't fully characterized yet. By the time you reach high-risk phases, Phase 0 tests may not cover the specific interaction boundaries at risk. Per-phase pre-tests scale testing effort with risk: Phase 1 might need only a quick smoke test confirming the component renders, while Phase 3 needs targeted behavioral tests for the specific hook interactions being decomposed.
Every extraction gets its own commit:
✅ "refactor: extract useModalManager hook"
✅ "refactor: extract BlockEditorFooter component"
❌ "refactor: extract useModalManager and useBlockSelection hooks" // Too many changes
Backup branches: Create explicit backup branches before highest-risk phases:
git checkout -b feature-refactor-pre-phase-3
git checkout - # Return to working branchIf an extraction requires new functionality (a new method, interface, or abstraction layer), add it first in the existing structure, test it, then extract in a separate step. Never combine behavioral additions with structural moves.
This often manifests as a "preparation phase" inserted between risk tiers — e.g., adding a keys() method to an interface while code is still in the monolith, then extracting the module that uses it in the next phase. Each step is independently testable and revertible.
Rationale: Mixing "add" with "move" makes it impossible to tell whether a failure is caused by the new behavior or the structural change.
If e2e tests fail after an extraction:
- STOP immediately — do not attempt inline fixes
-
Document the failure in a dedicated file with:
- Which phase failed
- Full error output
- Analysis of likely cause
- Consider reverting to the last passing checkpoint
- Re-analyze before retrying
Heroic debugging during a refactor often makes things worse. A clean rollback is faster than a broken fix.
When to use: Logic that performs calculations or transformations without side effects.
Characteristics:
- Takes data in, returns data out
- No React hooks, no state, no DOM
- Easy to unit test in isolation
Example:
// Before: inline in component
const processedSteps = steps.map(step => { /* complex logic */ });
// After: extracted utility
import { processSteps } from './utils/step-processor';
const processedSteps = processSteps(steps);Testing: Write comprehensive unit tests covering edge cases. This is low-risk and high-value test coverage.
When to use: State management that doesn't depend on other custom hooks.
Characteristics:
- Uses only built-in React hooks (useState, useCallback, useMemo)
- Self-contained state with clear boundaries
- No external service dependencies
Example:
// Before: scattered state in component
const [isOpen, setIsOpen] = useState(false);
const [selectedId, setSelectedId] = useState<string | null>(null);
// After: consolidated in hook
const selection = useSelection();
// selection.isOpen, selection.selectedId, selection.open(), selection.close()Testing: Use renderHook from @testing-library/react for isolated hook testing.
When to use: Hooks that need access to other hooks or services but shouldn't create them.
Characteristics:
- Receives dependencies via parameters
- Returns actions that operate on injected dependencies
- Avoids circular dependency by receiving callbacks
Example:
// Hook receives its dependencies
export function useRecordingActions(deps: {
state: RecordingState;
editor: EditorActions;
onClear: () => void; // Callback breaks circular dependency
}) {
const { state, editor, onClear } = deps;
const stopRecording = useCallback(() => {
// Use injected dependencies
editor.addBlocks(state.recordedSteps);
onClear(); // Call injected callback
}, [state, editor, onClear]);
return { stopRecording };
}When to use: When Hook A needs Hook B's clear function, but Hook B needs Hook A's state.
Solution: Split into three layers where each layer only depends on the previous:
┌─────────────────────────────────────────────────────┐
│ Layer 1: State (no dependencies) │
│ const state = useRecordingState(); │
├─────────────────────────────────────────────────────┤
│ Layer 2: Persistence (reads state) │
│ const persistence = usePersistence({ │
│ state: state, │
│ onRestore: state.restore, │
│ }); │
├─────────────────────────────────────────────────────┤
│ Layer 3: Actions (receives state + clear callback) │
│ const actions = useActions({ │
│ state: state, │
│ onClear: persistence.clear, │
│ }); │
└─────────────────────────────────────────────────────┘
Key insight: The cycle is broken because Layer 3 receives persistence.clear as a callback, rather than importing the persistence hook directly.
When to use: Before extracting a component that receives many props.
Process:
- Define the interface in
types.tsfirst (zero runtime risk) - Document which operations belong together
- Commit the interface
- Extract the component using the interface
- Update consumers to pass the consolidated object
Example:
// Step 1: Define interface (commit this alone)
export interface BlockOperations {
onEdit: (block: Block) => void;
onDelete: (id: string) => void;
onMove: (from: number, to: number) => void;
// ... 25 more operations grouped logically
}
// Step 2: Extract component using interface
interface BlockListProps {
blocks: Block[];
operations: BlockOperations; // Single prop replaces 29 individual props
}After every extraction checkpoint, run the full verification sequence:
# 1. Format code (prevents CI failures)
npm run prettier
# 2. Type checking
npm run typecheck
# 3. Linting
npm run lint
# 4. Unit tests
npm run test:ci
# 5. End-to-end tests (critical - validates behavior preservation)
npm run e2eDo not proceed to the next phase until all checks pass.
Document all data-testid attributes that e2e tests depend on:
| Test ID | Purpose | Must Preserve |
|---|---|---|
data-testid="block-editor" |
Main container | Yes |
data-testid="block-palette" |
Footer palette | Yes |
data-testid="view-mode-toggle" |
Preview toggle | Yes |
When extracting components, ensure these IDs remain in the DOM at the same structural level.
If the codebase uses window globals (a code smell, but reality):
- Search for ALL consumers before removal
- Replace with React Context if globals serve cross-component communication
- If globals are internal to one component, simple state replacement suffices
| Symptom | Likely Cause | Solution |
|---|---|---|
| TypeScript: Property 'X' does not exist | Missing prop in interface | Add to interface |
| E2E: Cannot find element | Test ID not preserved | Check data-testid in extracted component |
| Circular dependency error | Hook A imports Hook B which imports Hook A | Use three-layer architecture |
| Modal doesn't open | Event handler not wired | Verify callback prop names match |
| State not clearing | Cleanup function missing dependency | Check useCallback dependency array |
| Infinite loop | Object/array in useEffect deps | Use useMemo or extract primitives |
When planning a large refactor, structure your plan with:
- Build consumer map: each public export → list of importing files
- Diagram internal dependency chain (what calls what inside the module)
- Document current prop flows between parent and child components
- Identify circular dependencies
- List test IDs and external contracts that must be preserved
- Capture baseline metrics (
typechecktime,test:citime/count,buildtime, flaky tests) - Document cross-cutting decisions with options, tradeoffs, and affected phases (resolve before Phase 1)
- Identify domain-specific invariants that become rollback triggers (beyond "tests pass")
- Investigate existing contract/behavioral test coverage
- Create broad, shallow smoke tests for overall component behavior (mark as disposable)
- Perform a principal engineer level code review of the resulting tests, with an eye towards maintainability. Ensure tests focus on behavior, not implementation
- Meta-validate: Introduce a deliberate bug (e.g., break a key function), verify at least one baseline test fails, revert. Document which test caught it. If no test fails, the baseline has gaps — fix before proceeding.
- Update risk estimates in the planning document, and produce an assessment: do these tests reduce the risk of a refactor? Where and how?
- This baseline suite runs at every checkpoint across all subsequent phases
- Pre-test: Smoke tests for specific utilities/hooks being extracted (lightweight — confirm renders, basic behavior)
- Extract pure utilities
- Extract simple hooks
- Extract trivial components
- Post-test: Unit tests for each newly extracted module
- Checkpoint: Baseline + new tests all passing
- Pre-test: Contract tests for the specific interaction boundaries being split
- Create backup branch before highest-risk item
- Extract hooks with moderate dependencies
- Extract components with clear prop boundaries
- Post-test: Unit tests for each newly extracted module
- Checkpoint: Baseline + new tests all passing
- Pre-test: Targeted behavioral tests for specific hook interactions and state flows being decomposed
- Create backup branch before starting
- Extract complex interdependent hooks (use dependency injection)
- Extract components with extensive prop threading (use interface consolidation)
- Post-test: Unit tests for each newly extracted module
- Checkpoint: Baseline + new tests all passing
- Update baseline test imports to new paths. If test logic must change (not just imports), STOP — this indicates a behavioral regression.
- Migrate consumers in blast-radius order: lightest consumers first (fewest imports), heaviest last. Checkpoint after each tier.
- Pre-deletion verification: Search for all remaining references to the old module (
git grep). Should return zero results in source code before you delete anything. - Delete the old module. No backward-compat facade — consumers are already migrated.
- Remove dead code and unused imports.
- Final verification: all baseline + phase tests passing.
These are the most frequent causes of refactor failures. Treat them as hard rules, not suggestions.
-
Improving while refactoring — This is the single biggest source of refactor failures. When you see a bug or a better pattern during extraction, the temptation to fix it inline is strong. Don't. Extract code as-is. Document the improvement opportunity as an accepted tradeoff — a named note with rationale for why you're leaving it imperfect — and move on. Without this record, the next person can't tell whether an imperfection was intentional or accidental. Mixing improvements with structural changes destroys the ability to isolate what went wrong.
-
Skipping checkpoints — Every checkpoint must pass. Never proceed with "I'll fix it later." A failing checkpoint means the current extraction introduced a problem. Fix it now or revert.
-
Batching unrelated changes — Each logical extraction should be a separate commit. This allows precise rollback if needed. If you can't describe the commit in one sentence, it's too big.
-
Assuming instead of verifying — If unsure whether a function is called from another module, check it. Don't assume. The investigation phase exists to build a map of reality, not to confirm assumptions.
-
Overconfidence in tests — Tests written in Phase 0 are for previously-untested code. They might be wrong. If behavior seems off, stop and investigate — the test may be asserting incorrect behavior.
-
Ignoring anomalies — If you see something unexpected during extraction, document it. The next phase (or the next agent) might need to know.
-
Force-fitting the plan — If reality diverges from the plan, document the divergence and stop if it's significant. Don't bulldoze through obstacles. A plan that doesn't match the code is worse than no plan.
-
Retrying the same approach — If the same extraction fails twice with different implementations, stop. This usually indicates a misunderstanding of the dependency graph, not an implementation problem. Re-investigate before trying again.
Track these metrics in the execution log for each phase.
| Metric | Threshold | Action on failure |
|---|---|---|
test:ci pass rate |
100% | Stop. Do not proceed. |
| TypeScript compilation | Zero errors | Stop. Fix before continuing. |
| Circular dependencies | Zero | Stop. Redesign extraction. |
| e2e tests | All passing | Stop. Revert and re-analyze. |
| Metric | Warning threshold | Notes |
|---|---|---|
test:ci runtime |
>30% increase | Acceptable if new tests were added; investigate if not. |
| TypeScript compilation time | >20% increase | May indicate excessive re-exports or type complexity. |
| Lines of code per file | >250 lines | Target, not hard limit. Flag files that grew during refactor. |
| Total line count | >10% increase | Refactors should be roughly line-neutral. Large increases suggest duplication. |
Append a log entry to the refactor plan after completing each phase. This structured format ensures knowledge transfers between phases (and between agents/sessions).
## Phase [N] Execution Log
**Timestamp**: [ISO 8601]
**Status**: ✅ PASSED | ❌ FAILED | ⚠️ COMPLETED WITH WARNINGS
### Findings
- Architectural details not obvious from the plan
- Unexpected dependencies or coupling
### Decisions made
- Why approach X over Y; deviations from plan and rationale
### Invariants validated
- ✓ [specific behavioral contract you verified]
### Anomalies & warnings
- ⚠️ [anything surprising — the next phase needs to know]
### Accepted tradeoffs
- [imperfection left intentionally, with rationale]
### Checkpoint
- [ ] `npm run typecheck` ✅
- [ ] `npm run test:ci` ✅
- [ ] `npm run lint` ✅
- [ ] Git status clean
### Files modified
- Created: [list]
- Modified: [list]
- Deleted: [list]
### Next phase recommendations
- [specific guidance for the next phase or agent]Stop immediately and consult the user if:
- The plan is fundamentally flawed — e.g., a circular dependency prevents the planned extraction order
- Tests reveal bugs in existing code — fixing bugs is out of scope for a structural refactor
- Performance regression >30% — measured by test runtime or compilation time
- Dynamic imports break — and you can't determine the correct relative path
- Global state initialization order differs from expected — and breaks functionality
- Consumer code behavior must change — the refactor must be transparent to consumers
- A domain-specific invariant is violated — any rollback trigger identified in Phase -1 fires
- The same extraction fails twice with different approaches — re-investigate the dependency graph
When stopping:
- Preserve the current state (don't rollback yet)
- Document the issue in the execution log with a clear status marker
- Provide diagnosis and recommendation
- Wait for user guidance
Not every refactor needs the full protocol. For medium-complexity refactors (3-5 files, clear boundaries, no circular dependencies), use this streamlined version:
What to keep:
- Phase -1 investigation (abbreviated — focus on consumer search and dependency mapping)
- Post-testing for every extraction (unit tests for new modules are always required)
- Verification protocol after each extraction
- Emergency stops (these always apply)
- Common pitfalls (these always apply)
What to skip:
- Per-phase pre-testing — baseline smoke tests from Phase 0 are sufficient
- Backup branches — a single backup before starting is enough
- Success metrics — track informally, don't log formally
- Phase 0 as a separate phase — write baseline tests as part of Phase 1 pre-work
When to escalate to the full protocol:
- You discover circular dependencies during investigation
- The extraction touches shared state or global singletons
- More than 5 files are affected
- You hit an emergency stop condition
These guidelines exist on a spectrum:
| Complexity | Files touched | Characteristics | Protocol |
|---|---|---|---|
| Trivial | 1-2 | Rename, add prop, extract small utility | Just make the change |
| Medium | 3-5 | Clear boundaries, no circular deps | Lightweight variant (above) |
| High | 6+ | State/hook extraction, circular deps, shared globals | Full protocol |
Rule of thumb: If the refactor touches more than 5 files or involves circular dependencies, use the full protocol. If it touches 3-5 files with clear boundaries, use the lightweight variant. Otherwise, just make the change.