Skip to content
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

Draft
wants to merge 3 commits into
base: main
Choose a base branch
from

Conversation

with-heart
Copy link
Contributor

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.

@changeset-bot
Copy link

changeset-bot bot commented Apr 27, 2023

⚠️ No Changeset found

Latest commit: 5fa384e

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@with-heart
Copy link
Contributor Author

The initial impl for machine-to-create-machine is super naive. We're traversing for each node in the tree that needs to be renamed and renaming it, which works but it's a lot of effort compared to what we could do with the ts language server or ts-morph. Going to investigate those alternatives next I think

@with-heart with-heart changed the title initialize @xstate/codemods package create @xstate/codemods package and v4-to-v5 migration codemod Apr 27, 2023
Copy link
Contributor Author

@with-heart with-heart left a 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> =
Copy link
Contributor Author

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.

Comment on lines +9 to +10
(context) => (sourceFile) => {
const { factory } = context;
Copy link
Contributor Author

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);
Copy link
Contributor Author

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 (if visit 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 {
Copy link
Contributor Author

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.

Comment on lines +14 to +28
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,
);
}

Copy link
Contributor Author

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 2
  • propertyName: 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.

Comment on lines +29 to +36
if (isMachineCallExpression(node)) {
return factory.updateCallExpression(
node,
factory.createIdentifier('createMachine'),
node.typeArguments,
node.arguments,
);
}
Copy link
Contributor Author

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.

Comment on lines +38 to +44
if (isMachinePropertyAccessExpression(node)) {
return factory.updatePropertyAccessExpression(
node,
node.expression,
factory.createIdentifier('createMachine'),
);
}
Copy link
Contributor Author

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);
Copy link
Contributor Author

@with-heart with-heart Apr 29, 2023

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

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!
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tivac
Copy link

tivac commented Dec 19, 2023

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.

@Andarist
Copy link
Member

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 cond to guard.

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 @xsttae/react (or some other) then you need to link it with the correct XState version after you install the packages. You can do this with a script 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'),
);

@davidkpiano
Copy link
Member

@leggomuhgreggo
Copy link

Oh wow! This is neat. Any plans to revive this?

@davidkpiano
Copy link
Member

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.

@tivac
Copy link

tivac commented Sep 24, 2024

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.

@with-heart
Copy link
Contributor Author

I'm working on an rfc for codemods so we can have a solid plan ❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

v5 migration codemod
5 participants