-
Notifications
You must be signed in to change notification settings - Fork 44
create @xstate/codemods package and v4-to-v5 migration codemod
#332
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
|
|
The initial impl for |
@xstate/codemods package and v4-to-v5 migration codemod
ea9645b to
5fa384e
Compare
with-heart
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did my best to give a high-level explanation of how all of this works currently. I could go on and on though so I'm gonna stop here.
If you have any questions about any of it, don't hesitate to add your question as a comment!
| isMachinePropertyAccessExpression, | ||
| } from '../predicates'; | ||
|
|
||
| export const machineToCreateMachine: ts.TransformerFactory<ts.SourceFile> = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
machineToCreateMachine is the entrypoint for the machine-to-create-machine codemod. In TypeScript land, this is called a TransformerFactory or transformer.
It defines logic for renaming all imports/calls of Machine from the xstate package to createMachine for a single source file. The transformer will eventually be used as part of a call to ts.transform which is a function enabling us to transform the code in a file.
| (context) => (sourceFile) => { | ||
| const { factory } = context; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A TransformerFactory is a function which takes a context object and returns a function which takes a ts.SourceFile (sourceFile) and returns a ts.SourceFile.
Essentially what this means is that for each file, TypeScript gives us a toolbox of tools for working with nodes in the syntax tree (context) and the code from the file as a syntax tree object (ts.SourceFile).
We make changes to sourceFile and then return it. The code from the returned node replaces the file's existing code. (There's actually a lot more to it than that, but we don't need to care about that rn—we'll come back to it much later in this PR)
As far as the context toolbox goes, the only thing we'll be using is factory which is an object containing a create and update function for every type of node that can exist in a TypeScript syntax tree. We'll use these factory functions to modify the nodes in the sourceFile tree!
| export const machineToCreateMachine: ts.TransformerFactory<ts.SourceFile> = | ||
| (context) => (sourceFile) => { | ||
| const { factory } = context; | ||
| return ts.visitNode(sourceFile, visit); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
ts.visitNode is a foundational tool in the TypeScript compiler api. Here's its tooltip description:
Visits a Node using the supplied visitor, possibly returning a new Node in its place.
It's essentially a shortcut for calling visit(sourceFile) with some powerful benefits:
- it infers the return type as the type of the node parameter
- it has an optional
testparameter (3rd param) which can validate that the returned node passes a test - it has an optional
liftparameter (4th param) which takes an array of nodes (ifvisitwere to return an array) and lifts them into a single node
We don't really get to see the use of what makes visitNode so powerful here, but it's worth mentioning because visitNode is used A LOT when using the TypeScript compiler api—basically any time the intent is to possibly modify or replace a single node in the tree.
| const { factory } = context; | ||
| return ts.visitNode(sourceFile, visit); | ||
|
|
||
| function visit(node: ts.Node): ts.Node { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here's our single visitor function for this transformer! We'll use it to visit every node in the syntax tree.
It's important to understand the in the TypeScript compiler api, visiting a node means either returning the node unchanged, removing the node (by returning undefined), or returning an updated version of the node.
Inside of our visitor, we'll use our predicates (more on them later) to look for a few specific types of nodes. If the node is one of those types, we'll return an updated version of the node with codemod-related changes.
If the node isn't one of those types, we'll call visit again on each of its children (ts.visitEachChild). In this way we make visit recursive, meaning that we traverse down through the tree node-by-node.
| if (isMachineNamedImportSpecifier(node)) { | ||
| // if we have `propertyName`, it's using `as` syntax so we need to | ||
| // rename `propertyName`. otherwise we just rename `name`. | ||
| const [propertyName, name] = node.propertyName | ||
| ? [factory.createIdentifier('createMachine'), node.name] | ||
| : [node.propertyName, factory.createIdentifier('createMachine')]; | ||
|
|
||
| return factory.updateImportSpecifier( | ||
| node, | ||
| node.isTypeOnly, | ||
| propertyName, | ||
| name, | ||
| ); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This block applies to ImportSpecifier nodes that match a named import for Machine. It covers these two cases:
// case 1
import { Machine } from 'xstate'
// |---------|
// case 2
import { Machine as M } from 'xstate'
// |--------------|ImportSpecifier has two properties that are important to us:
name: the name that the import is referred to elsewhere in the file.Machinefor case 1,Mfor case 2propertyName: the name of the exported property, only if the import is renamed.undefinedfor case 1,Machinefor case 2
// case 1
import { Machine } from 'xstate'
// name: Machine
// propertyName: undefined
// case 2
import { Machine as M } from 'xstate'
// name: M
// propertyName: MachineDepending on the case, we update either the node's propertyName or name to be Machine and return it.
| if (isMachineCallExpression(node)) { | ||
| return factory.updateCallExpression( | ||
| node, | ||
| factory.createIdentifier('createMachine'), | ||
| node.typeArguments, | ||
| node.arguments, | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This block applies to CallExpression nodes where its expression value is an Identifier of Machine:
const machine = Machine({})
// |---------|Since the isMachineCallExpression already checks that the expression is Machine, we can just update the node by replacing its expression with a new Identifier of createMachine.
| if (isMachinePropertyAccessExpression(node)) { | ||
| return factory.updatePropertyAccessExpression( | ||
| node, | ||
| node.expression, | ||
| factory.createIdentifier('createMachine'), | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This block applies to PropertyAccessExpression nodes where the expression property value is the name of the default xstate import and the name property is Machine:
import xstate from 'xstate'
const machine = xstate.Machine({})
// |----------------|
// also doesn't have to be a fn call
const Machine = xstate.Machine
// |-------------|We update the node's name property with a new Identifer of createMachine and return it.
| ); | ||
| } | ||
|
|
||
| return ts.visitEachChild(node, visit, context); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
visitEachChild calls visit on each of the node's child nodes. This allows us to make visit recursive.
For each node, we either return an updated version of it (if one of the if blocks apply for that node) or we visit each of the node's children and return them.
Since visitEachChild calls visit again on each child, each child will again call visitEachChild for its children. This makes visit recursive, allowing us to traverse through (nearly) every node in the tree just by returning ts.visitEachChild.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Predicates are functions that take a node and return a boolean. They're useful when traversing the tree with visitor functions because they allow us to select specific types of nodes that we want to operate on.
TypeScript itself exports many predicate fns—one for each type of node.
In this module, we combine the ts predicates with a few additional checks of node properties to create functions that allow us to select each type/shape of node our codemod needs in order to do its thing.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The other cool thing about predicates is that all of the TypeScript predicates and some of these predicates return a type predicate (the node is ts.XYZ syntax).
A type predicate narrows the type of its argument, meaning that when used with a conditional check, a less specific type (ts.Node) is narrowed to a more specific type (ts.XYZ).
Here's an example:
import ts from 'typescript'
declare const node: ts.Node
ts.text // type error (because not all nodes have the text property)
if (ts.isIdentifier(node)) {
// node is now of type ts.Identifier
ts.text // totally fine!
}There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PS: These predicates will make a lot more sense if you explore the AST from this sample: https://ts-ast-viewer.com/#code/JYWwDg9gTgLgBAbzgWQIYGMAWwB2BTOAXzgDMoIQ4ByADwGcZUY8qAoUSWRFDbfOVHRRFS5SrQZMW7cNHj1GzURWoKpbVugg4GcEL1wEAvDyyGAFAkIBKTdt36z-E8ks27O+I77G4a5gB0aE54btZAA
|
Any further progress planned for this? We're hoping to try out v5 soon but have enough machines that manually porting them all is gonna be pretty time-consuming. |
|
I'd recommend using XState v4 and v5 side by side and migrating to v5 gradually. You want to make sure that the behavior of your system stays unaffected and that the codemod can only migrate some syntax-oriented things, like renaming To use both you can add such dependencies: "@xstate/react5": "npm:@xstate/[email protected]",
"xstate5": "npm:[email protected]",And if you need an integration package like const fs = require('fs-extra');
const path = require('path');
const rootNodeModules = path.join(__dirname, '..', 'node_modules');
fs.ensureSymlinkSync(
path.join(rootNodeModules, 'xstate5'),
path.join(rootNodeModules, '@xstate', 'react5', 'node_modules', 'xstate'),
); |
|
Added this to the docs: https://stately.ai/docs/migration#how-can-i-use-both-xstate-v4-and-v5 |
|
Oh wow! This is neat. Any plans to revive this? |
I hope to! I've been thinking about this recently - either lint rules and/or a codemod for v5 and future XState versions. |
|
Reviving this would be huge for me, the guy who still has a ton of v4 statecharts with no straightforward way to move to v5 without a huge amount of manual work. |
|
I'm working on an rfc for codemods so we can have a solid plan ❤️ |
This PR resolves #321 by creating the
@xstate/codemodspackage and implementing TypeScript transformers for each of the v4-to-v5 changes.This is currently just a draft as we figure out the ideal way to implement the codemod and handle all of the necessary changes.