Merge pull request #28680 from schultzp2020/jsx-transformation-guide

docs: add new JSX Transform guide
This commit is contained in:
Patrik Oldsberg
2025-04-15 16:04:53 +02:00
committed by GitHub
7 changed files with 650 additions and 49 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/cli': patch
---
Included a reference to the JSX transform guide in the warning about using the default React import.
@@ -535,3 +535,4 @@ zsh
scrollable
severities
intellij
tsconfig
@@ -0,0 +1,468 @@
# Migrating to the New JSX Transform using a Codemod
## Using the Codemod
While a codemod for the New JSX Transform was originally introduced in the [Introducing the New JSX Transform](https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) article, it is no longer functional. A working solution, inspired by the original, is detailed below:
1. **Create the transform file**
Create a file named `transform.js` in the root directory of your Backstage project.
```js
/**
* (c) Facebook, Inc. and its affiliates. Confidential and proprietary.
*
* @format
*/
module.exports = function (file, api, options) {
const j = api.jscodeshift;
const printOptions = options.printOptions || {};
const root = j(file.source);
const destructureNamespaceImports = options.destructureNamespaceImports;
// <https://github.com/facebook/jscodeshift/blob/master/recipes/retain-first-comment.md>
function getFirstNode() {
return root.find(j.Program).get('body', 0).node;
}
// Save the comments attached to the first node
const firstNode = getFirstNode();
const { comments } = firstNode;
function isVariableDeclared(variable) {
return (
root
.find(j.Identifier, {
name: variable,
})
.filter(
path =>
path.parent.value.type !== 'MemberExpression' &&
path.parent.value.type !== 'QualifiedTypeIdentifier' &&
// Added this
path.parent.value.type !== 'TSQualifiedName' &&
path.parent.value.type !== 'JSXMemberExpression',
)
.size() > 0
);
}
// Get all paths that import from React
const reactImportPaths = root
.find(j.ImportDeclaration, {
type: 'ImportDeclaration',
})
.filter(path => {
return (
(path.value.source.type === 'Literal' ||
path.value.source.type === 'StringLiteral') &&
(path.value.source.value === 'React' ||
path.value.source.value === 'react')
);
});
// get all namespace/default React imports
const reactPaths = reactImportPaths.filter(path => {
return (
path.value.specifiers.length > 0 &&
path.value.importKind === 'value' &&
path.value.specifiers.some(
specifier => specifier.local.name === 'React',
)
);
});
if (reactPaths.size() > 1) {
throw Error(
'There should only be one React import. Please remove the duplicate import and try again.',
);
}
if (reactPaths.size() === 0) {
return null;
}
const reactPath = reactPaths.paths()[0];
// Reuse the node so that we can preserve quoting style.
const reactLiteral = reactPath.value.source;
const isDefaultImport = reactPath.value.specifiers.some(
specifier =>
specifier.type === 'ImportDefaultSpecifier' &&
specifier.local.name === 'React',
);
// Check to see if we should keep the React import
const isReactImportUsed =
root
.find(j.Identifier, {
name: 'React',
})
.filter(path => {
return path.parent.parent.value.type !== 'ImportDeclaration';
})
.size() > 0;
// local: imported
const reactIdentifiers = {};
const reactTypeIdentifiers = {};
let canDestructureReactVariable = false;
if (
isReactImportUsed &&
(isDefaultImport || destructureNamespaceImports)
) {
// Checks to see if the react variable is used itself (rather than used to access its properties)
canDestructureReactVariable =
root
.find(j.Identifier, {
name: 'React',
})
.filter(path => {
return path.parent.parent.value.type !== 'ImportDeclaration';
})
.filter(
path =>
!(
path.parent.value.type === 'MemberExpression' &&
path.parent.value.object.name === 'React'
) &&
!(
path.parent.value.type === 'QualifiedTypeIdentifier' &&
path.parent.value.qualification.name === 'React'
) &&
!(
// Added this
(
path.parent.value.type === 'TSQualifiedName' &&
path.parent.value.left.name === 'React'
)
) &&
!(
path.parent.value.type === 'JSXMemberExpression' &&
path.parent.value.object.name === 'React'
),
)
.size() === 0;
if (canDestructureReactVariable) {
// Add React identifiers to separate object so we can destructure the imports
// later if we can. If a type variable that we are trying to import has already
// been declared, do not try to destructure imports
// (ex. Element is declared and we are using React.Element)
root
.find(j.QualifiedTypeIdentifier, {
qualification: {
type: 'Identifier',
name: 'React',
},
})
.forEach(path => {
const id = path.value.id.name;
if (path.parent.parent.value.type === 'TypeofTypeAnnotation') {
// This is a typeof import so it isn't actually a type
reactIdentifiers[id] = id;
if (reactTypeIdentifiers[id]) {
canDestructureReactVariable = false;
}
} else {
reactTypeIdentifiers[id] = id;
if (reactIdentifiers[id]) {
canDestructureReactVariable = false;
}
}
if (isVariableDeclared(id)) {
canDestructureReactVariable = false;
}
});
// Added this
root
.find(j.TSQualifiedName, {
left: {
type: 'Identifier',
name: 'React',
},
})
.forEach(path => {
const id = path.value.right.name;
reactIdentifiers[id] = id;
// We don't tend to use type imports
// Comment line above out and uncomment this to use type imports
// Also ignoring typeof imports?
// reactTypeIdentifiers[id] = id
// if (reactIdentifiers[id]) {
// canDestructureReactVariable = false
// }
if (isVariableDeclared(id)) {
canDestructureReactVariable = false;
}
});
// Add React identifiers to separate object so we can destructure the imports
// later if we can. If a variable that we are trying to import has already
// been declared, do not try to destructure imports
// (ex. createElement is declared and we are using React.createElement)
root
.find(j.MemberExpression, {
object: {
type: 'Identifier',
name: 'React',
},
})
.forEach(path => {
const property = path.value.property.name;
reactIdentifiers[property] = property;
if (
isVariableDeclared(property) ||
reactTypeIdentifiers[property]
) {
canDestructureReactVariable = false;
}
});
// Add React identifiers to separate object so we can destructure the imports
// later if we can. If a JSX variable that we are trying to import has already
// been declared, do not try to destructure imports
// (ex. Fragment is declared and we are using React.Fragment)
root
.find(j.JSXMemberExpression, {
object: {
type: 'JSXIdentifier',
name: 'React',
},
})
.forEach(path => {
const property = path.value.property.name;
reactIdentifiers[property] = property;
if (
isVariableDeclared(property) ||
reactTypeIdentifiers[property]
) {
canDestructureReactVariable = false;
}
});
}
}
if (canDestructureReactVariable) {
// replace react identifiers
root
.find(j.QualifiedTypeIdentifier, {
qualification: {
type: 'Identifier',
name: 'React',
},
})
.forEach(path => {
const id = path.value.id.name;
j(path).replaceWith(j.identifier(id));
});
// Added this
root
.find(j.TSQualifiedName, {
left: {
type: 'Identifier',
name: 'React',
},
})
.forEach(path => {
const id = path.value.right.name;
j(path).replaceWith(j.identifier(id));
});
root
.find(j.MemberExpression, {
object: {
type: 'Identifier',
name: 'React',
},
})
.forEach(path => {
const property = path.value.property.name;
j(path).replaceWith(j.identifier(property));
});
root
.find(j.JSXMemberExpression, {
object: {
type: 'JSXIdentifier',
name: 'React',
},
})
.forEach(path => {
const property = path.value.property.name;
j(path).replaceWith(j.jsxIdentifier(property));
});
// Add exisiting React imports to map
reactImportPaths.forEach(path => {
const specifiers = path.value.specifiers;
for (let i = 0; i < specifiers.length; i++) {
const specifier = specifiers[i];
// get all type and regular imports that are imported
// from React
if (specifier.type === 'ImportSpecifier') {
if (
path.value.importKind === 'type' ||
specifier.importKind === 'type'
) {
reactTypeIdentifiers[specifier.local.name] =
specifier.imported.name;
} else {
reactIdentifiers[specifier.local.name] = specifier.imported.name;
}
}
}
});
const regularImports = [];
Object.keys(reactIdentifiers).forEach(local => {
const imported = reactIdentifiers[local];
regularImports.push(
j.importSpecifier(j.identifier(imported), j.identifier(local)),
);
});
const typeImports = [];
Object.keys(reactTypeIdentifiers).forEach(local => {
const imported = reactTypeIdentifiers[local];
typeImports.push(
j.importSpecifier(j.identifier(imported), j.identifier(local)),
);
});
if (regularImports.length > 0) {
j(reactPath).insertAfter(
j.importDeclaration(regularImports, reactLiteral),
);
}
if (typeImports.length > 0) {
j(reactPath).insertAfter(
j.importDeclaration(typeImports, reactLiteral, 'type'),
);
}
// remove all old react imports
reactImportPaths.forEach(path => {
// This is for import type React from 'react' which shouldn't
// be removed
if (
path.value.specifiers.some(
specifier =>
specifier.type === 'ImportDefaultSpecifier' &&
specifier.local.name === 'React' &&
(specifier.importKind === 'type' ||
path.value.importKind === 'type'),
)
) {
j(path).insertAfter(
j.importDeclaration(
[j.importDefaultSpecifier(j.identifier('React'))],
reactLiteral,
'type',
),
);
}
j(path).remove();
});
} else {
// Remove the import because it's not being used
// If we should keep the React import, just convert
// default imports to named imports
let isImportRemoved = false;
const specifiers = reactPath.value.specifiers;
for (let i = 0; i < specifiers.length; i++) {
const specifier = specifiers[i];
if (specifier.type === 'ImportNamespaceSpecifier') {
if (!isReactImportUsed) {
isImportRemoved = true;
j(reactPath).remove();
}
} else if (specifier.type === 'ImportDefaultSpecifier') {
if (isReactImportUsed) {
j(reactPath).insertAfter(
j.importDeclaration(
[j.importNamespaceSpecifier(j.identifier('React'))],
reactLiteral,
),
);
}
if (specifiers.length > 1) {
const typeImports = [];
const regularImports = [];
for (let x = 0; x < specifiers.length; x++) {
if (specifiers[x].type !== 'ImportDefaultSpecifier') {
if (specifiers[x].importKind === 'type') {
typeImports.push(specifiers[x]);
} else {
regularImports.push(specifiers[x]);
}
}
}
if (regularImports.length > 0) {
j(reactPath).insertAfter(
j.importDeclaration(regularImports, reactLiteral),
);
}
if (typeImports.length > 0) {
j(reactPath).insertAfter(
j.importDeclaration(typeImports, reactLiteral, 'type'),
);
}
}
isImportRemoved = true;
j(reactPath).remove();
}
}
if (!isImportRemoved) {
return null;
}
}
// If the first node has been modified or deleted, reattach the comments
const firstNode2 = getFirstNode();
if (firstNode2 !== firstNode) {
firstNode2.comments = comments;
}
return root.toSource(printOptions);
};
```
2. **Execute the transformation**
To apply the necessary changes, execute the following command twice from your root Backstage directory. First, run it for your packages, and then again for your plugins and any additional directories. Remember to adjust the paths to the transform script and parser source accordingly.
```console
npx jscodeshift --verbose=2 --ignore-pattern="**/node_modules/**" --parser ts --extensions=tsx,ts,jsx,js --transform ./path/to/the/transform.js --destructureNamespaceImports=true --parser=tsx ./path/to/src/
```
3. **Verify and clean up imports**
Review the codebase for any remaining instances of `import * as React from 'react'` or `import React from 'react'`. Replace these with named imports where possible, such as:
```tsx
import { useState, useEffect } from 'react';
```
If retaining the default React import is absolutely necessary, use the following syntax instead:
```tsx
import { default as React } from 'react';
```
+74
View File
@@ -0,0 +1,74 @@
---
id: jsx-transform-migration
title: Transitioning to the New JSX Transform
description: A guide to migrating your project to the New JSX Transform
---
Backstage core libraries currently support React 18. We are actively evaluating the upgrade to React 19, which introduces significant changes, including making the [New JSX Transform](https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html) mandatory.
## What this means for you
- **If you are already using the New JSX Transform:** You are not impacted by this change, regardless of your React version (17, 18, or when 19 becomes available for Backstage).
- **If you are NOT using the New JSX Transform (likely if you're importing React like `import React from 'react'`):** You will need to adopt it before upgrading to React 19. This typically involves changing your imports, as the New JSX Transform doesn't require importing the entire React namespace to use JSX. This is recommended even on React 17 or 18, as it was a change introduced with React 17. Although a best practice since React 17, Backstage did not adopt this transform when it upgraded.
## Action Required
While upgrading to React 19 within Backstage is not yet officially supported, it's recommended to proactively adopt the New JSX Transform if you haven't already. This will ensure a smoother transition when React 19 support is introduced and improve compatibility with the current React ecosystem.
## Timeline
We are currently evaluating React 19 and will provide further guidance on the upgrade path and timelines soon. For now, you can prepare by adopting the New JSX Transform.
## Migration Process
### Updating React Imports
Find and replace all occurrences of `import * as React from 'react'` and `import React from 'react'` with named imports like:
```tsx
import { useState, useEffect } from 'react';
```
If you must preserve the default React import for compatibility reasons, you can use:
```tsx
import { default as React } from 'react';
```
To streamline this process, consider using an automated codemod. Instructions are available in this [migration guide](https://github.com/backstage/backstage/blob/master/contrib/docs/tutorials/jsx-migration-codemod.md).
### Updating Configuration Files
To ensure compatibility when using `@backstage/cli`, you must update your `tsconfig.json` to use the new JSX transforms.
#### TypeScript Configuration (TSConfig)
Update the `compilerOptions.jsx` setting in `tsconfig.json` to `react-jsx`. This file is typically located in the root directory.
```json filename="tsconfig.json"
{
"extends": "@backstage/cli/config/tsconfig.json",
...
"compilerOptions": {
// highlight-remove-next-line
"jsx": "react",
// highlight-add-next-line
"jsx": "react-jsx"
}
}
```
In the future, this setting will change to `preserve` once React 17 is fully deprecated.
##### Explanation of `compilerOptions.jsx` Values
- The `react` mode: This mode converts JSX into `React.createElement` calls, making it directly usable by React. The code doesn't require a separate JSX transformation step, and the output files will use the `.js` extension.
- The `react-jsx` mode: Introduced with React 17, this mode automatically handles the JSX transformation, allowing you to use JSX without needing to import `React` in each file.
- The `preserve` mode: This option leaves the JSX code untouched, embedding it directly into the output files. This is useful when you're planning to process the JSX with another tool like Babel later. The resulting files will use the `.jsx` extension to indicate the presence of JSX.
## Additional Resources
- [Introducing the New JSX Transform](https://legacy.reactjs.org/blog/2020/09/22/introducing-the-new-jsx-transform.html)
- [React 19 Upgrade Guide](https://react.dev/blog/2024/04/25/react-19-upgrade-guide)
+99 -47
View File
@@ -1,74 +1,126 @@
---
id: react18-migration
title: Migrating to React 18
description: Additional resources for the Material UI v5 migration guide specifically for Backstage
description: A guide to migrating your project to React 18
---
The Backstage core libraries and plugins are compatible with all versions of React from v16.8 to v18. This means that you can migrate projects at your own pace. We do however encourage you to do so sooner rather than later, both to keep up with the evolving ecosystem, but also because React 18 brings performance improvements, in particular in tests.
:::info
This guide has been updated to include steps for removing support for React 16, as it is now deprecated.
:::
The Backstage core libraries and plugins are compatible with all versions of React from v17 to v18. This means that you can migrate projects at your own pace. We do however encourage you to do so sooner rather than later, both to keep up with the evolving ecosystem, but also because React 18 brings performance improvements, in particular in tests.
## Migration
_Before diving in, this is a heads-up that for large projects this can be a tricky migration due to the fact that it is hard to break down into a gradual migration. In practice the difficult part of this migration is switching to the new version of the `@testing-library/react` package for tests, since there is no overlapping support across major React versions, more on that later._
### Switching to React 18
### Upgrading to React 18
To switch a project to React 18, there are generally three changes that need to be made.
#### Backstage Instance
1. Update the resolutions in your root `package.json` to the new versions of `@types/react` and `@types/react-dom`:
To migrate a Backstage instance to React 18, follow these steps:
```json title="package.json"
"resolutions": {
// highlight-remove-next-line
"@types/react": "^17",
// highlight-remove-next-line
"@types/react-dom": "^17",
// highlight-add-next-line
"@types/react": "^18",
// highlight-add-next-line
"@types/react-dom": "^18",
},
```
1. Modify the `resolutions` section in your root `package.json` to reference the latest versions of `@types/react` and `@types/react-dom`:
2. Update the `react` and `react-dom` dependencies in your `packages/app/package.json` to the new versions:
```json title="package.json"
"resolutions": {
// highlight-remove-start
"@types/react": "^17",
"@types/react-dom": "^17",
// highlight-remove-end
// highlight-add-start
"@types/react": "^18",
"@types/react-dom": "^18",
// highlight-add-end
},
```
```json title="packages/app/package.json"
"dependencies": {
...
// highlight-remove-next-line
"react": "^17.0.2",
// highlight-remove-next-line
"react-dom": "^17.0.2",
// highlight-add-next-line
"react": "^18.0.2",
// highlight-add-next-line
"react-dom": "^18.0.2",
...
},
```
2. Update the `react` and `react-dom` dependencies in `packages/app/package.json`:
3. Update `packages/app/src/index.tsx` to use the new `react-dom/client` API to render the app:
```json title="packages/app/package.json"
"dependencies": {
...
// highlight-remove-start
"react": "^17.0.2",
"react-dom": "^17.0.2",
// highlight-remove-end
// highlight-add-start
"react": "^18.0.2",
"react-dom": "^18.0.2",
// highlight-add-end
...
},
```
```tsx title="packages/app/src/index.tsx"
import '@backstage/cli/asset-types';
// highlight-remove-next-line
import ReactDOM from 'react-dom';
// highlight-add-next-line
import ReactDOM from 'react-dom/client';
import App from './App';
3. Adjust `packages/app/src/index.tsx` to use the `react-dom/client` API for rendering:
// highlight-remove-next-line
ReactDOM.render(<App />, document.getElementById('root'));
// highlight-add-next-line
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
```
```tsx title="packages/app/src/index.tsx"
import '@backstage/cli/asset-types';
// highlight-remove-next-line
import ReactDOM from 'react-dom';
// highlight-add-next-line
import ReactDOM from 'react-dom/client';
import App from './App';
Once these steps are done you should be able to run your app and see it working as before, except now using React 18.
// highlight-remove-next-line
ReactDOM.render(<App />, document.getElementById('root'));
// highlight-add-next-line
ReactDOM.createRoot(document.getElementById('root')!).render(<App />);
```
#### Backstage Frontend Plugin
1. Update the `devDependencies` and `peerDependencies` for `react`, `react-dom`, and `@types/react` in your plugin's `package.json`:
```json title="plugins/<plugin-name>/package.json"
"devDependencies": {
...
// highlight-remove-start
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
// highlight-remove-end
// highlight-add-start
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
// highlight-add-end
...
},
```
```json title="plugins/<plugin-name>/package.json"
"peerDependencies": {
...
// highlight-remove-start
"@types/react": "^16.13.1 || ^17.0.0",
"react": "^16.13.1 || ^17.0.0",
"react-dom": "^16.13.1 || ^17.0.0",
// highlight-remove-end
// highlight-add-start
"@types/react": "^17.0.0 || ^18.0.0",
"react": "^17.0.0 || ^18.0.0",
"react-dom": "^17.0.0 || ^18.0.0",
// highlight-add-end
...
},
```
After completing these updates, your application and plugins should function as before, now utilizing React 18.
:::note
Be sure to update your lockfile after modifying your `package.json` files.
:::
### TypeScript Errors
When upgrading to React 18 you are likely to see a fair number of TypeScript type errors. A summary of the breaking changes can be found in the [Pull Request that introduced them](https://github.com/DefinitelyTyped/DefinitelyTyped/pull/56210). A codemod is also provided to help with the migration.
Run `yarn tsc:full` to asses the damage.
Run `yarn tsc:full` to assess the damage.
The good news is that these errors can be fixed while still staying on React 17. If you have a large number of errors to fix you can address as few or many is you like at a time and merge them into your main branch **without** the version bumps from step 1. This lets you gradually migrate the types in your project while not yet fully moving over to React 18. Once all type breakages are fixed you can move on to the next step of migrating tests.
+1
View File
@@ -563,6 +563,7 @@ export default {
'tutorials/yarn-migration',
'tutorials/migrate-to-mui5',
'tutorials/auth-service-migration',
'tutorials/jsx-transform-migration',
],
},
],
+2 -2
View File
@@ -249,13 +249,13 @@ function createConfigForRole(dir, role, extraConfig = {}) {
'warn',
{
message:
'React default imports are deprecated. Follow the x migration guide for details.',
'React default imports are deprecated. Follow the https://backstage.io/docs/tutorials/jsx-transform-migration migration guide for details.',
selector:
"ImportDeclaration[source.value='react'][specifiers.0.type='ImportDefaultSpecifier']",
},
{
message:
'React default imports are deprecated. Follow the x migration guide for details. If you need a global type that collides with a React named export (such as `MouseEvent`), try using `globalThis.MouseHandler`.',
'React default imports are deprecated. Follow the https://backstage.io/docs/tutorials/jsx-transform-migration migration guide for details. If you need a global type that collides with a React named export (such as `MouseEvent`), try using `globalThis.MouseHandler`.',
selector:
"ImportDeclaration[source.value='react'] :matches(ImportDefaultSpecifier, ImportNamespaceSpecifier)",
},