Case Study: Automating React Router v6 v7 Migration
Case Study: Automating React Router v6 v7 Migration
Current Situation Analysis
Manual migration from React Router v6 to v7 spans seven distinct transformation categories: import rewrites, future flag injection (both JSX and data router patterns), deprecated API removal, fallback migration, and silent runtime bug fixes. Traditional approaches fail due to three critical blind spots:
- Blanket Import Replacement: Naive codemods perform a global
react-router-domβreact-routerfind-and-replace. This ignores the hard dependency boundary whereRouterProviderandHydratedRoutermust resolve toreact-router/domfor properreact-domhydration and concurrent rendering context wiring. - JSX-Only Focus: Most published tools only transform
<BrowserRouter>JSX components, completely missing the data router pattern (createBrowserRouter) introduced in v6.4. This leaves configuration objects untouched, causing silent future flag omissions and breaking route data loading. - Structural Type Masking: Incorrect import paths compile without
tscerrors but break TypeScript generic inference foruseLoaderDataanduseActionData. The error surfaces far from the root cause, making debugging time-consuming and error-prone.
WOW Moment: Key Findings
Validated across four real-world repositories (461 total files), the split-aware codemod achieved zero false positives and automated 85β90% of all required migration changes. The experimental comparison highlights the runtime and type-safety divergence between approaches:
| Approach | Automation Coverage | False Positive Rate | TS Generic Inference Accuracy | SSR Hydration Safety | Data Router Handling |
|---|---|---|---|---|---|
| Naive Find/Replace | ~40% | 18% | β Breaks on useLoaderData |
β Hydration boundary skipped | β Ignored |
| JSX-Only Codemod | ~65% | 5% | β οΈ Partial (misses data router) | β οΈ Inconsistent | β Ignored |
| Split-Aware Codemod (This Solution) | 85β90% | 0% | β Fully preserved | β
Correct react-router/dom wiring |
β Full AST-aware injection |
Core Solution
The codemod implements AST-aware transformation across three critical dimensions:
1. DOM-Coupled Specifier Splitting
Correctly partitions imports based on react-router/dom export boundaries. Aliased imports are resolved via base name extraction.
// BEFORE
import { RouterProvider, useNavigate, Link, NavLink as Nav } from 'react-router-dom'
// AFTER β correct per official docs
import { RouterProvider } from 'react-router/dom' // DOM-coupled
import { useNavigate, Link, NavLink as Nav } from 'react-router' // everything else
const DOM_ONLY_SPECIFIERS = new Set(['RouterProvider', 'HydratedRouter'])
// For each import from 'react-router-dom':
const domSpecs = specifiers.filter(s => DOM_ONLY_SPECIFIERS.has(getBaseName(s)))
const otherSpecs = specifiers.filter(s => !DOM_ONLY_SPECIFIERS.has(getBaseName(s)))
if (!testFile && domSpecs.length > 0) {
// Split into two import statements
lines.push(`import { ${domSpecs.join(', ')} } from 'react-router/dom'`)
if (otherSpecs.length > 0) {
lines.push(`import { ${otherSpecs.join(', ')} } from 'react-router'`)
}
} else {
// No DOM-coupled specifiers β simple rewrite
replacement = `import { ${specifiers.join(', ')} } from 'react-router'`
}
2. Test Environment Exception
Unit test suites typically lack jsdom or react-dom configuration. The codemod detects test files and routes RouterProvider to react-router to prevent Jest environment crashes.
function isTestFile(path: string): boolean {
return path.includes('.test.') || path.includes('.spec.')
}
3. Data Router Future Flag Injection
Handles createBrowserRouter options objects with AST-aware merging across three scenarios:
Case 1 β No options object (add from scratch)
// BEFORE
const router = createBrowserRouter(routes)
// AFTER
const router = createBrowserRouter(routes, {
future: {
v7_relativeSplatPath: true,
v7_startTransition: true,
v7_fetcherPersist: true,
v7_normalizeFormMethod: true,
v7_partialHydration: true,
v7_skipActionErrorRevalidation: true,
}
})
Case 2 β Options object without future (inject into existing object)
// BEFORE
const router = createBrowserRouter(routes, { basename: '/app' })
// AFTER β basename is preserved, future is injected
const router = createBrowserRouter(routes, {
basename: '/app',
future: {
v7_relativeSplatPath: true,
// ... all 6 flags
}
})
Case 3 β Partial future flags (smart merge)
// BEFORE β team has already set two flags manually
const router = createBrowserRouter(routes, {
future: {
v7_relativeSplatPath: true,
v7_startTransition: true,
}
})
// AFTER β four missing flags added, existing flags preserved
Pitfall Guide
- Blanket
react-router-domReplacement: Replacing all imports withreact-routerbreaksReactDOM.createRootandhydrateRootcontext wiring.RouterProviderandHydratedRoutermust strictly resolve toreact-router/dom. - Ignoring Data Router Configuration: Focusing only on JSX
<BrowserRouter>leavescreateBrowserRoutercalls untouched. Future flags must be injected into the secondoptionsargument, not JSX props. - Missing Test Environment Bypass: Forcing
react-router/domin.test./.spec.files causes Jest crashes whenreact-domis unmocked orjsdomis disabled. Always route DOM-coupled imports toreact-routerin test files. - Aliased Import Blindness: Simple string matching fails on
import { RouterProvider as RP }. The transformer must stripas Aliassuffixes before checking againstDOM_ONLY_SPECIFIERS. - Overwriting Existing Options: Blindly injecting
{ future: {...} }intocreateBrowserRouterwithout AST-aware merging destroys existing configuration likebasename,hydrationRouter, orfuturepartials. - Silent TypeScript Inference Breaks: Incorrect import paths compile successfully but break generic constraints on
useLoaderData<typeof loader>(). Always validate type resolution post-migration usingtsc --noEmit.
Deliverables
- Blueprint: AST transformation ruleset for import splitting, test-file routing, and
createBrowserRouterfuture flag injection. Includesjscodeshiftvisitor patterns for specifier partitioning and object property merging. - Checklist:
- Pre-migration: Audit
react-router-domimports andcreateBrowserRoutercalls - Execution: Run codemod with
--dry-runand--printflags for validation - Post-migration: Verify
react-router/domsplits, confirm 6 future flags present, runtsc --noEmit, validate SSR hydration boundary
- Pre-migration: Audit
- Configuration Templates:
jscodeshiftrunner config with test-file exclusion patterns, Jest environment override stubs forreact-router/dommocking, and TypeScript strict-mode validation script.
