Skip to content

Instantly share code, notes, and snippets.

@donaldpipowitch
Created November 13, 2023 08:46
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save donaldpipowitch/f860f913c600a84ac867e3b1b924b793 to your computer and use it in GitHub Desktop.
Save donaldpipowitch/f860f913c600a84ac867e3b1b924b793 to your computer and use it in GitHub Desktop.
Custom ESLint Rules to support in migrating React Router v5 to v6

Use at your own risk! Feel free to modify. They were customized for our code base and help to automate 90% of the migration our big React Router v5 app.

You will notice some data-exact field on routes. Those are only needed while you have some merge requests still on v5 and some on v6. When your whole code base (including pending merge requests) is using v6 you can safely delete all those ESLint rules and you can just run a string-replace on your code base to remove data-exact="true" and data-exact="false".

My two biggest gotchas during the migration:

Regarding the first point we created this file src/components/root-routes that is also expected by one of the ESLint Rules:

/* eslint-disable rulesdir/rrv6-migrate-root-routes */
// for an easier migration we nest <Routes/> and use absolute paths.
// but this currently requires a workaround.
// see https://github.com/remix-run/react-router/issues/8035#issuecomment-997737565
// with a bit of luck this becomes officially supported (https://github.com/remix-run/react-router/discussions/9841?sort=new).
// but we might want to switch to other patterns in the meantime.
import { FC, useContext, useMemo } from 'react';
import {
  Routes,
  RoutesProps,
  UNSAFE_RouteContext as RouteContext,
} from 'react-router-dom';

export const RootRoutes: FC<RoutesProps> = (props) => {
  const ctx = useContext(RouteContext);
  const value = useMemo(() => ({ ...ctx, matches: [] }), [ctx]);

  return (
    <RouteContext.Provider value={value}>
      <Routes {...props} />
    </RouteContext.Provider>
  );
};
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
'Check if <Redirect to/> is valid. (Needed for React Router v6.)',
},
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.name !== 'Redirect') return;
// check if "to" prop does NOT contain `:`
const toProp = node.attributes.find(
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'to'
);
if (!toProp) return;
const toValue = toProp.value;
if (!toValue) return;
if (toValue.type === 'Literal' && toValue.value.includes(':')) {
context.report({
node,
message:
'<Redirect to/> must not contain ":". (Needed for React Router v6.)',
});
}
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// see https://reactrouter.com/en/main/upgrading/v5#note-on-route-path-patterns
'Check if <Route path/> is valid. (Needed for React Router v6.)',
},
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.name !== 'Route') return;
const pathProp = node.attributes.find(
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'path'
);
if (!pathProp) return;
const pathValue = pathProp.value;
if (!pathValue) return;
if (
pathValue.type === 'Literal' &&
((pathValue.value.endsWith('/') && pathValue.value !== '/') ||
pathValue.value.includes('(') ||
pathValue.value.includes('?') ||
pathValue.value.includes('/*/') ||
(pathValue.value.includes('*') &&
!(pathValue.value.includes('/*') || pathValue.value === '*')))
) {
context.report({
node,
message:
'<Route path/> is not valid. (Needed for React Router v6.)',
});
}
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// this is an rule which helps us to make the migration easier.
// we will not need it later.
'Replace Redirect with Navigate. (Needed for React Router v6.)',
},
fixable: 'code',
},
create(context) {
return {
// replace import of Redirect with Navigate in react-router-dom
ImportDeclaration(node) {
if (node.source.value !== 'react-router-dom') return;
const redirectSpecifier = node.specifiers.find(
(specifier) =>
'imported' in specifier && specifier.imported.name === 'Redirect'
);
if (!redirectSpecifier) return;
context.report({
node: redirectSpecifier,
message: `Redirect is not available in React Router v6. Use Navigate instead.`,
fix(fixer) {
return fixer.replaceText(redirectSpecifier, 'Navigate');
},
});
},
// replace "<Redirect />" with "<Navigate replace />" in code
JSXOpeningElement(node) {
if (node.name.name !== 'Redirect') return;
context.report({
node,
message: 'Redirect should be replaced with Navigate',
fix(fixer) {
return [
fixer.replaceText(node.name, 'Navigate'),
fixer.insertTextAfter(node.name, ' replace'),
];
},
});
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// for an easier migration we nest <Routes/> and use absolute paths.
// but this currently requires a workaround.
// see https://github.com/remix-run/react-router/issues/8035#issuecomment-997737565
'USe <RootRoutes /> instead of <Routes />. (Needed for an easy React Router v6 migration.)',
},
fixable: 'code',
},
create(context) {
return {
JSXElement(node) {
if (node.openingElement.name.name !== 'Routes') return;
try {
const attributes = node.openingElement.attributes.map((attr) =>
context.getSourceCode().getText(attr)
);
context.report({
node,
message:
'<Routes /> must be renamed to <RootRoutes />. (Needed for an easy React Router v6 migration.)',
fix(fixer) {
return [
fixer.replaceText(
node.openingElement,
`<RootRoutes ${attributes.join(' ')}>`
),
fixer.replaceText(node.closingElement, '</RootRoutes>'),
fixer.insertTextBefore(
context.getSourceCode().ast.body[0],
`import { RootRoutes } from "src/components/root-routes";\n`
),
];
},
});
} catch (err) {
// ignore as it only errors in src/components/root-routes itself for some reason
}
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// this is an rule which helps us to make the migration easier.
// we will not need it later, so you can use nested routes.
'Move <Route>children</Route> to <Route element={children}/>. (Needed for React Router v6.)',
},
fixable: 'code',
},
create(context) {
return {
JSXElement(node) {
if (node.openingElement.name.name !== 'Route') return;
const children = node.children.filter((child, index, array) => {
const text = context.getSourceCode().getText(child);
// remove leading and trailing whitespace
if ((index === 0 || index === array.length - 1) && text.trim() === '')
return false;
return true;
});
if (!children.length) return;
const attributes = node.openingElement.attributes.map((attr) =>
context.getSourceCode().getText(attr)
);
const isExpression =
children.length === 1 &&
children[0].type === 'JSXExpressionContainer';
const element =
children.length === 1
? context.getSourceCode().getText(children[0])
: `<>${children
.map((child) => context.getSourceCode().getText(child))
.join('')}</>`;
return context.report({
node,
message: `Move <Route>children</Route> to <Route element={children}/>. (Needed for React Router v6.)`,
fix(fixer) {
return fixer.replaceText(
node,
`<Route ${attributes.join(' ')} element=${
isExpression ? element : `{${element}}`
} />`
);
},
});
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// this is an rule which helps us to make the migration easier.
// we will not need it later. it adds a `data-exact` attribute to all routes
// to keep track of the original `exact` behaviour.
'Remove <Route exact/>. (Needed for React Router v6.)',
},
fixable: 'code',
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.name !== 'Route') return;
const dataExactProp = node.attributes.find(
(attr) =>
attr.type === 'JSXAttribute' && attr.name.name === 'data-exact'
);
if (dataExactProp) return;
const exactProp = node.attributes.find(
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'exact'
);
if (exactProp) {
context.report({
node: exactProp,
message: 'Replace `exact` with `data-exact="true"`.',
fix(fixer) {
return [
fixer.insertTextAfter(exactProp, ' data-exact="true"'),
fixer.remove(exactProp),
];
},
});
} else {
context.report({
node,
message: 'Add `data-exact="false"`.',
fix(fixer) {
return fixer.insertTextAfter(node.name, ' data-exact="false"');
},
});
}
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// this is an rule which helps us to make the migration easier.
// we will not need it later.
'Check if <Route path/> is matching exact behaviour. (Needed for React Router v6.)',
},
fixable: 'code',
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.name !== 'Route') return;
const dataExactProp = node.attributes.find(
(attr) =>
attr.type === 'JSXAttribute' && attr.name.name === 'data-exact'
);
if (!dataExactProp) return;
const pathProp = node.attributes.find(
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'path'
);
// add path="*" if it can't be found (for the migration we don't support path-less routes)
if (!pathProp) {
return context.report({
node,
message: `Write <Route path="*"/> instead of <Route/> (during the migration).`,
fix(fixer) {
return fixer.insertTextAfter(node.name, ' path="*"');
},
});
}
const exact = dataExactProp.value.value === 'true';
const path = pathProp.value.value;
if (!path) return;
if (!exact && !(path.endsWith('/*') || path === '*')) {
return context.report({
node: pathProp.value,
message: `Non-exact route path '${path}' should end with '/*'.`,
fix(fixer) {
return fixer.replaceText(pathProp.value, `"${path + '/*'}"`);
},
});
}
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// this is an rule which helps us to make the migration easier.
// we will not need it later.
'Replace useHistory with useNavigate. (Needed for React Router v6.)',
},
fixable: 'code',
},
create(context) {
return {
// replace import of useHistory with useNavigate in react-router-dom
ImportDeclaration(node) {
if (node.source.value !== 'react-router-dom') return;
const useHistorySpecifier = node.specifiers.find(
(specifier) =>
'imported' in specifier && specifier.imported.name === 'useHistory'
);
if (!useHistorySpecifier) return;
context.report({
node: useHistorySpecifier,
message: `useHistory is not available in React Router v6. Use useNavigate instead.`,
fix(fixer) {
return fixer.replaceText(useHistorySpecifier, 'useNavigate');
},
});
},
// replace "const { push } = useHistory();" or "const { replace } = useHistory();" with "const navigate = useNavigate();" in code
VariableDeclaration(node) {
if (node.declarations.length !== 1) return;
const declarator = node.declarations[0];
if (!declarator.init) return;
if (declarator.init.type !== 'CallExpression') return;
if (declarator.init.callee.type !== 'Identifier') return;
if (declarator.init.callee.name !== 'useHistory') return;
if (declarator.id.type !== 'ObjectPattern') return;
const properties = declarator.id.properties;
if (properties.length !== 1) return;
const proprety = properties[0];
if (proprety.type !== 'Property') return;
if (proprety.key.type !== 'Identifier') return;
const propertyNames = proprety.key.name;
if (propertyNames !== 'push' && propertyNames !== 'replace') {
return;
}
context.report({
node,
message: 'useHistory should be replaced with useNavigate',
fix(fixer) {
return fixer.replaceText(node, 'const navigate = useNavigate();');
},
});
},
// replace push with navigate in code (attenthion: there should be no other function called "push" in the code base)
'CallExpression[callee.name="push"]'(node) {
context.report({
node,
message: `push is not available in React Router v6. Use navigate instead.`,
fix(fixer) {
return fixer.replaceText(node.callee, 'navigate');
},
});
},
// replace "replace(...)" with "navigate(..., { replace: true })" in code
'CallExpression[callee.name="replace"]'(node) {
// console.log(node.arguments[0]);
context.report({
node,
message: `replace is not available in React Router v6. Use navigate instead.`,
fix(fixer) {
return fixer.replaceText(
node,
`navigate(${context
.getSourceCode()
.getText(node.arguments[0])}, { replace: true })`
);
},
});
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
description:
// this is an rule which helps us to make the migration easier.
// we will not need it later.
'Replace useRouteMatch with useMatch. (Needed for React Router v6.)',
},
fixable: 'code',
},
create(context) {
return {
// replace import of useRouteMatch with useMatch in react-router-dom
ImportDeclaration(node) {
if (node.source.value !== 'react-router-dom') return;
const useRouteMatchSpecifier = node.specifiers.find(
(specifier) =>
'imported' in specifier &&
specifier.imported.name === 'useRouteMatch'
);
if (!useRouteMatchSpecifier) return;
context.report({
node: useRouteMatchSpecifier,
message: `useRouteMatch is not available in React Router v6. Use useMatch instead.`,
fix(fixer) {
return fixer.replaceText(useRouteMatchSpecifier, 'useMatch');
},
});
},
// replace useRouteMatch with useMatch in code
'CallExpression[callee.name="useRouteMatch"]'(node) {
context.report({
node,
message: `useRouteMatch is not available in React Router v6. Use useMatch instead.`,
fix(fixer) {
return fixer.replaceText(node.callee, 'useMatch');
},
});
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
// see https://reactrouter.com/en/main/upgrading/v5#upgrade-to-react-router-v51
description: `Every <Route/> needs to be placed inside a <Switch/>. (Needed for React Router v6.)`,
},
},
create(context) {
return {
JSXOpeningElement(node) {
if (node.name.name !== 'Route') return;
// while loop is needed, because of things like `<Switch>{condition && <Route/>}</Switch>`
let parent = node.parent.parent;
while (parent) {
if (parent.type === 'JSXElement') {
if (
parent.openingElement.name.name === 'Switch' || // if still v5
parent.openingElement.name.name === 'Routes' || // if already migrated to v6 (and merging older branches)
parent.openingElement.name.name === 'RootRoutes' // if v6 with workaround for absolute paths
) {
return;
} else {
// found a different parent element, so we can stop looking
break;
}
}
parent = parent.parent;
}
context.report({
node,
message:
'<Route/> must be nested inside <Switch/>. (Needed for React Router v6.)',
});
},
};
},
};
module.exports = rule;
// @ts-check
/** @type {import("eslint").Rule.RuleModule} */
const rule = {
meta: {
docs: {
// see https://reactrouter.com/en/main/upgrading/v5#remove-redirects-inside-switch
// and https://reactrouter.com/en/main/upgrading/v5#refactor-custom-routes
description:
'The only allowed child of a <Switch/> is a <Route/>. (Needed for React Router v6.)',
},
},
create(context) {
return {
JSXOpeningElement(node) {
// ignore routes
if (node.name.name === 'Route') return;
// while loop is needed, because of things like `<Routes>{condition && <Redirect/>}</Routes>`
let parent = node.parent.parent;
let isDirectChildOfSwitch = false;
while (parent) {
if (parent.type === 'JSXElement') {
if (parent.openingElement.name.name !== 'Switch') {
// found a different parent element, so we can stop looking
return;
} else {
isDirectChildOfSwitch = true;
break;
}
}
parent = parent.parent;
}
if (!isDirectChildOfSwitch) return;
context.report({
node,
message:
'The only allowed child of a <Switch/> is a <Route/>. (Needed for React Router v6.)',
});
},
};
},
};
module.exports = rule;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment