-
Notifications
You must be signed in to change notification settings - Fork 42
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
create @xstate/codemods
package and v4-to-v5
migration codemod
#332
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
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
test
parameter (3rd param) which can validate that the returned node passes a test - it has an optional
lift
parameter (4th param) which takes an array of nodes (ifvisit
were 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.Machine
for case 1,M
for case 2propertyName
: the name of the exported property, only if the import is renamed.undefined
for case 1,Machine
for case 2
// case 1
import { Machine } from 'xstate'
// name: Machine
// propertyName: undefined
// case 2
import { Machine as M } from 'xstate'
// name: M
// propertyName: Machine
Depending 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/codemods
package 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.