From 9cef23686fecb0b74250684ebd51793d5a00971f Mon Sep 17 00:00:00 2001 From: Farzad Yousefzadeh Date: Wed, 3 Jan 2024 21:33:28 +0200 Subject: [PATCH] Extract inline actors (#422) --- apps/extension/server/src/getReferences.ts | 16 +- .../src/__tests__/invoke.test.ts | 52 ++++++ .../src/__tests__/transitions.test.ts | 1 + .../machine-extractor/src/extractAction.ts | 6 +- .../machine-extractor/src/toMachineConfig.ts | 25 ++- packages/machine-extractor/src/types.ts | 3 +- packages/shared/forEachEntity/package.json | 4 + packages/shared/package.json | 14 +- .../src/__tests__/forEachAction.test.ts | 148 +++++++++++++++++- .../shared/src/createIntrospectableMachine.ts | 31 +++- .../{forEachAction.ts => forEachEntity.ts} | 53 ++++--- packages/shared/src/index.ts | 2 +- 12 files changed, 299 insertions(+), 56 deletions(-) create mode 100644 packages/shared/forEachEntity/package.json rename packages/shared/src/{forEachAction.ts => forEachEntity.ts} (77%) diff --git a/apps/extension/server/src/getReferences.ts b/apps/extension/server/src/getReferences.ts index 255ce5b9..5e52a264 100644 --- a/apps/extension/server/src/getReferences.ts +++ b/apps/extension/server/src/getReferences.ts @@ -1,5 +1,5 @@ import { - forEachAction, + forEachEntity, getRangeFromSourceLocation, } from '@xstate/tools-shared'; import { TextDocumentIdentifier } from 'vscode-languageserver'; @@ -25,8 +25,11 @@ export const getReferences = ( const config = cursorHover.machine.toConfig(); if (!config) return []; - // Actions don't matter here so we stub them out - forEachAction(config, () => { + // Actions and actors don't matter here so we stub them out + forEachEntity(config, (entity) => { + if (entity && 'src' in entity) { + return { src: 'anonymous' }; + } return { type: 'anonymous' }; }); @@ -66,8 +69,11 @@ export const getReferences = ( const config = cursorHover.machine.toConfig(); if (!config) return []; - // Actions don't matter here so we stub them out - forEachAction(config, () => { + // Actions and actors don't matter here so we stub them out + forEachEntity(config, (entity) => { + if (entity && 'src' in entity) { + return { src: 'anonymous' }; + } return { type: 'anonymous' }; }); diff --git a/packages/machine-extractor/src/__tests__/invoke.test.ts b/packages/machine-extractor/src/__tests__/invoke.test.ts index 33d5bb9c..2d01cb2d 100644 --- a/packages/machine-extractor/src/__tests__/invoke.test.ts +++ b/packages/machine-extractor/src/__tests__/invoke.test.ts @@ -35,4 +35,56 @@ describe('Invoke', () => { expect(invokes).toHaveLength(1); }); + + it('Should extract inline actors correctly', () => { + const result = extractMachinesFromFile(` + createMachine({ + invoke: [ + {src: 'string source'}, + {src: () => { + console.log('inline function') + }}, + {src: () => () => { + console.log('callback actor') + }}, + {src: fromPromise(() => { + return fetch('https://example.com/...').then((data) => data.json()); + })}, + {src: identifierActor}, + ] + }) + `); + const invoke = result!.machines[0]!.toConfig()?.invoke; + + expect(invoke).toMatchInlineSnapshot(` + [ + { + "kind": "named", + "src": "string source", + }, + { + "kind": "inline", + "src": "() => { + console.log('inline function') + }", + }, + { + "kind": "inline", + "src": "() => () => { + console.log('callback actor') + }", + }, + { + "kind": "inline", + "src": "fromPromise(() => { + return fetch('https://example.com/...').then((data) => data.json()); + })", + }, + { + "kind": "inline", + "src": "identifierActor", + }, + ] + `); + }); }); diff --git a/packages/machine-extractor/src/__tests__/transitions.test.ts b/packages/machine-extractor/src/__tests__/transitions.test.ts index c80a777f..08399d0d 100644 --- a/packages/machine-extractor/src/__tests__/transitions.test.ts +++ b/packages/machine-extractor/src/__tests__/transitions.test.ts @@ -43,6 +43,7 @@ describe('Internal transitions', () => { "states": { "a": { "invoke": { + "kind": "named", "onDone": { "internal": false, }, diff --git a/packages/machine-extractor/src/extractAction.ts b/packages/machine-extractor/src/extractAction.ts index 0dd5547d..4449bed1 100644 --- a/packages/machine-extractor/src/extractAction.ts +++ b/packages/machine-extractor/src/extractAction.ts @@ -270,9 +270,9 @@ export function extractSendToAction( // Todo: support namespace imports and aliased specifiers // import * as actions from 'xstate'; actions.sendTo // import {sendTo as x} from 'xstate' -export const getActionCreatorName = (actionNode: ActionNode) => - t.isCallExpression(actionNode.node) && t.isIdentifier(actionNode.node.callee) - ? actionNode.node.callee.name +export const getCallExpressionName = (node: t.Node) => + t.isCallExpression(node) && t.isIdentifier(node.callee) + ? node.callee.name : undefined; export const getObjectPropertyKey = ( diff --git a/packages/machine-extractor/src/toMachineConfig.ts b/packages/machine-extractor/src/toMachineConfig.ts index 9934c561..6279e21f 100644 --- a/packages/machine-extractor/src/toMachineConfig.ts +++ b/packages/machine-extractor/src/toMachineConfig.ts @@ -1,4 +1,5 @@ import * as t from '@babel/types'; +import { getNameOfDeclaration } from 'typescript'; import { MaybeArrayOfActions } from './actions'; import { CondNode } from './conds'; import { @@ -8,6 +9,8 @@ import { extractRaiseAction, extractSendToAction, extractStopAction, + getCallExpressionName, + getObjectPropertyKey, } from './extractAction'; import { TMachineCallExpression } from './machineCallExpression'; import { StateNodeReturn } from './stateNode'; @@ -133,11 +136,27 @@ const parseStateNode = ( return; } // For now, we'll treat "anonymous" as if this is an inline expression - let src: string | undefined = - invoke.src.declarationType === 'named' ? invoke.src.value : undefined; + let invokeDef: + | { src: string; kind: ExtractorInvokeNodeConfig['kind'] } + | undefined = (() => { + if (invoke.src.declarationType === 'named') { + return { + src: invoke.src.value, + kind: 'named', + }; + } + return { + src: opts!.fileContent.slice( + invoke.src.node.start!, + invoke.src.node.end!, + ), + kind: 'inline', + }; + })(); const toPush: ExtractorInvokeNodeConfig = { - src: src || (() => () => {}), + src: invokeDef.src, + kind: invokeDef.kind, }; if (invoke.id) { diff --git a/packages/machine-extractor/src/types.ts b/packages/machine-extractor/src/types.ts index f91c2466..6ba83d61 100644 --- a/packages/machine-extractor/src/types.ts +++ b/packages/machine-extractor/src/types.ts @@ -14,12 +14,13 @@ export type ExtractrorTransitionNodeConfig = { }; export type ExtractorInvokeNodeConfig = { - src?: string | Function; + src: string; id?: string; autoForward?: boolean; forward?: boolean; onDone?: MaybeArray; onError?: MaybeArray; + kind: 'inline' | 'named'; }; export type ExtractorStateNodeConfig = { diff --git a/packages/shared/forEachEntity/package.json b/packages/shared/forEachEntity/package.json new file mode 100644 index 00000000..9fcf92b0 --- /dev/null +++ b/packages/shared/forEachEntity/package.json @@ -0,0 +1,4 @@ +{ + "main": "dist/xstate-tools-shared-forEachEntity.cjs.js", + "module": "dist/xstate-tools-shared-forEachEntity.esm.js" +} diff --git a/packages/shared/package.json b/packages/shared/package.json index d39a5d7c..d27ec3ce 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -13,14 +13,14 @@ "import": "./dist/xstate-tools-shared.cjs.mjs", "default": "./dist/xstate-tools-shared.cjs.js" }, - "./forEachAction": { + "./forEachEntity": { "types": { - "import": "./forEachAction/dist/xstate-tools-shared-forEachAction.cjs.mjs", - "default": "./forEachAction/dist/xstate-tools-shared-forEachAction.cjs.js" + "import": "./forEachEntity/dist/xstate-tools-shared-forEachEntity.cjs.mjs", + "default": "./forEachEntity/dist/xstate-tools-shared-forEachEntity.cjs.js" }, - "module": "./forEachAction/dist/xstate-tools-shared-forEachAction.esm.js", - "import": "./forEachAction/dist/xstate-tools-shared-forEachAction.cjs.mjs", - "default": "./forEachAction/dist/xstate-tools-shared-forEachAction.cjs.js" + "module": "./forEachEntity/dist/xstate-tools-shared-forEachEntity.esm.js", + "import": "./forEachEntity/dist/xstate-tools-shared-forEachEntity.cjs.mjs", + "default": "./forEachEntity/dist/xstate-tools-shared-forEachEntity.cjs.js" }, "./package.json": "./package.json" }, @@ -48,7 +48,7 @@ "preconstruct": { "entrypoints": [ "./index.ts", - "./forEachAction.ts" + "./forEachEntity.ts" ] } } diff --git a/packages/shared/src/__tests__/forEachAction.test.ts b/packages/shared/src/__tests__/forEachAction.test.ts index 10acc4f8..dbd189ba 100644 --- a/packages/shared/src/__tests__/forEachAction.test.ts +++ b/packages/shared/src/__tests__/forEachAction.test.ts @@ -1,7 +1,8 @@ import { extractMachinesFromFile } from '@xstate/machine-extractor'; -import { forEachAction } from '../forEachAction'; +import { isActorEntity } from '../createIntrospectableMachine'; +import { forEachEntity } from '../forEachEntity'; -describe('forEachAction', () => { +describe('forEachEntity', () => { it('Should visit and replace all actions', () => { const result = extractMachinesFromFile(` createMachine({ @@ -62,8 +63,11 @@ describe('forEachAction', () => { `); const config = result?.machines[0]?.toConfig()!; - forEachAction(config, (action) => { - return { type: 'anonymous' }; + forEachEntity(config, (entity) => { + if (!isActorEntity(entity)) { + return { type: 'anonymous' }; + } + return entity; }); expect(config).toMatchInlineSnapshot(` @@ -76,6 +80,7 @@ describe('forEachAction', () => { }, "initial": "a", "invoke": { + "kind": "named", "onDone": { "actions": { "type": "anonymous", @@ -97,6 +102,7 @@ describe('forEachAction', () => { "type": "anonymous", }, "invoke": { + "kind": "named", "onDone": [ { "actions": { @@ -150,6 +156,9 @@ describe('forEachAction', () => { "exit": { "type": "anonymous", }, + "invoke": { + "type": "anonymous", + }, }, "c": { "entry": { @@ -158,6 +167,9 @@ describe('forEachAction', () => { "exit": { "type": "anonymous", }, + "invoke": { + "type": "anonymous", + }, "on": { "always": { "actions": { @@ -201,11 +213,13 @@ describe('forEachAction', () => { const config = result?.machines[0]?.toConfig()!; - forEachAction(config, (action) => { - if (action?.kind === 'builtin') { - return; + forEachEntity(config, (entity) => { + if (!isActorEntity(entity)) { + if (entity?.kind === 'builtin') { + return; + } } - return action; + return entity; }); expect(config).toMatchInlineSnapshot(` @@ -238,4 +252,122 @@ describe('forEachAction', () => { } `); }); + + it('Should visit and replace all actors', () => { + const result = extractMachinesFromFile(` + createMachine({ + initial: 'a', + invoke: { + src: 'named actor' + }, + states: { + a: { + invoke: [ + {src: () => {}}, + {src: () => () => {}}, + {src: someIdentifier}, + {src: 'another named actor'} + ] + } + } + }) + `); + + const config = result?.machines[0]?.toConfig()!; + + // Only replace inline actors + forEachEntity(config, (entity) => { + if (isActorEntity(entity)) { + if (entity.kind === 'inline') { + return { ...entity, src: 'anonymous' }; + } + } + return entity; + }); + + expect(config).toMatchInlineSnapshot(` + { + "initial": "a", + "invoke": { + "kind": "named", + "src": "named actor", + }, + "states": { + "a": { + "invoke": [ + { + "kind": "inline", + "src": "anonymous", + }, + { + "kind": "inline", + "src": "anonymous", + }, + { + "kind": "inline", + "src": "anonymous", + }, + { + "kind": "named", + "src": "another named actor", + }, + ], + }, + }, + } + `); + }); + + it('Should delete actors', () => { + const result = extractMachinesFromFile(` + createMachine({ + initial: 'a', + invoke: { + src: 'named actor' + }, + states: { + a: { + invoke: [ + {src: () => {}}, + {src: () => () => {}}, + {src: someIdentifier}, + {src: 'another named actor'} + ] + } + } + }) + `); + + const config = result?.machines[0]?.toConfig()!; + + // Only delete inline actors + forEachEntity(config, (entity) => { + if (isActorEntity(entity)) { + if (entity.kind === 'inline') { + return; + } + } + return entity; + }); + + expect(config).toMatchInlineSnapshot(` + { + "initial": "a", + "invoke": { + "kind": "named", + "src": "named actor", + }, + "states": { + "a": { + "invoke": [ + { + "kind": "named", + "src": "another named actor", + }, + ], + }, + }, + } + `); + }); }); diff --git a/packages/shared/src/createIntrospectableMachine.ts b/packages/shared/src/createIntrospectableMachine.ts index d84e6422..183bceba 100644 --- a/packages/shared/src/createIntrospectableMachine.ts +++ b/packages/shared/src/createIntrospectableMachine.ts @@ -1,6 +1,10 @@ -import { MachineExtractResult } from '@xstate/machine-extractor'; +import { + ExtractorInvokeNodeConfig, + ExtractorMachineAction, + MachineExtractResult, +} from '@xstate/machine-extractor'; import { AnyStateMachine, createMachine } from 'xstate'; -import { forEachAction } from './forEachAction'; +import { forEachEntity } from './forEachEntity'; function stubAllWith(value: T): Record { return new Proxy( @@ -11,20 +15,33 @@ function stubAllWith(value: T): Record { ); } +export const isActorEntity = ( + entity: ExtractorMachineAction | ExtractorInvokeNodeConfig | undefined, +): entity is ExtractorInvokeNodeConfig => + !!entity && 'src' in entity && typeof entity.src !== 'undefined'; + export function createIntrospectableMachine( machineResult: MachineExtractResult, ): AnyStateMachine { const config = machineResult.toConfig()!; - forEachAction(config, (action) => { - if (action?.kind === 'named') { - return action.action; + forEachEntity(config, (entity) => { + if (isActorEntity(entity)) { + return { + ...entity, + src: entity.kind === 'inline' ? () => {} : entity.src, + }; + } else if (entity?.kind === 'named') { + return entity.action; } // Special case choose actions for typegen - if (action?.kind === 'inline' && action.action.__tempStatelyChooseConds) { + else if ( + entity?.kind === 'inline' && + entity.action.__tempStatelyChooseConds + ) { return { type: 'xstate.choose', - conds: action.action.__tempStatelyChooseConds, + conds: entity.action.__tempStatelyChooseConds, }; } return; diff --git a/packages/shared/src/forEachAction.ts b/packages/shared/src/forEachEntity.ts similarity index 77% rename from packages/shared/src/forEachAction.ts rename to packages/shared/src/forEachEntity.ts index 61d75919..c8fcf45c 100644 --- a/packages/shared/src/forEachAction.ts +++ b/packages/shared/src/forEachEntity.ts @@ -79,12 +79,16 @@ function getTransitionsOutOfState( return transitions; } -const replaceOrDeleteActions = ( +const replaceOrDeleteEntity = ( host: T, prop: keyof T, - visitor: (action: ExtractorMachineAction | undefined) => any, + visitor: ( + entity: ExtractorMachineAction | ExtractorInvokeNodeConfig | undefined, + ) => any, ) => { - const entity = host[prop] as MaybeArray; + const entity = host[prop] as + | MaybeArray + | MaybeArray; if (Array.isArray(entity)) { for (let i = entity.length - 1; i >= 0; i--) { const val = visitor(entity[i]); @@ -107,19 +111,24 @@ const replaceOrDeleteActions = ( /** * @description Recursively traverses the state node, finds all actions and either replaces them with the new value is visitor returns a truthy value or deletes it */ -const forEachActionRecur = ( +const forEachEntityRecur = ( stateNode: ExtractorStateNodeConfig, - visitor: (action: ExtractorMachineAction | undefined) => any, + visitor: ( + entity: ExtractorMachineAction | ExtractorInvokeNodeConfig | undefined, + ) => any, path: string[], ) => { /** * Entry actions */ - replaceOrDeleteActions(stateNode, 'entry', visitor); + replaceOrDeleteEntity(stateNode, 'entry', visitor); /** * Exit actions */ - replaceOrDeleteActions(stateNode, 'exit', visitor); + replaceOrDeleteEntity(stateNode, 'exit', visitor); + + // Invokes + replaceOrDeleteEntity(stateNode, 'invoke', visitor); const transitions = getTransitionsOutOfState(stateNode, path.join('.')); Object.entries(transitions).forEach(([event, tr]) => { @@ -135,24 +144,24 @@ const forEachActionRecur = ( const invocation = stateNode.invoke[index]; if (Array.isArray(invocation.onDone)) { invocation.onDone.forEach((doneTransition) => { - replaceOrDeleteActions(doneTransition, 'actions', visitor); + replaceOrDeleteEntity(doneTransition, 'actions', visitor); }); } else { const doneTransition = invocation.onDone; if (!doneTransition?.actions) return; - replaceOrDeleteActions(doneTransition, 'actions', visitor); + replaceOrDeleteEntity(doneTransition, 'actions', visitor); } } else { const invocation = stateNode.invoke; if (!invocation) return; if (Array.isArray(invocation.onDone)) { invocation.onDone.forEach((doneTransition) => { - replaceOrDeleteActions(doneTransition, 'actions', visitor); + replaceOrDeleteEntity(doneTransition, 'actions', visitor); }); } else { const doneTransition = invocation.onDone; if (!doneTransition?.actions) return; - replaceOrDeleteActions(doneTransition, 'actions', visitor); + replaceOrDeleteEntity(doneTransition, 'actions', visitor); } } } else if (event.startsWith('error.invoke')) { @@ -167,47 +176,49 @@ const forEachActionRecur = ( const invocation = stateNode.invoke[index]; if (Array.isArray(invocation.onError)) { invocation.onError.forEach((errorTransition) => { - replaceOrDeleteActions(errorTransition, 'actions', visitor); + replaceOrDeleteEntity(errorTransition, 'actions', visitor); }); } else { const errorTransition = invocation.onError; if (!errorTransition?.actions) return; - replaceOrDeleteActions(errorTransition, 'actions', visitor); + replaceOrDeleteEntity(errorTransition, 'actions', visitor); } } else { const invocation = stateNode.invoke; if (!invocation) return; if (Array.isArray(invocation.onError)) { invocation.onError.forEach((errorTransition) => { - replaceOrDeleteActions(errorTransition, 'actions', visitor); + replaceOrDeleteEntity(errorTransition, 'actions', visitor); }); } else { const errorTransition = invocation.onError; if (!errorTransition?.actions) return; - replaceOrDeleteActions(errorTransition, 'actions', visitor); + replaceOrDeleteEntity(errorTransition, 'actions', visitor); } } } // Guarded transitions else if (Array.isArray(tr)) { tr.forEach((group) => { - replaceOrDeleteActions(group, 'actions', visitor); + replaceOrDeleteEntity(group, 'actions', visitor); }); } else { - replaceOrDeleteActions(tr, 'actions', visitor); + replaceOrDeleteEntity(tr, 'actions', visitor); } }); /** * Recurse to child states */ for (const key in stateNode.states) { - forEachActionRecur(stateNode.states[key], visitor, path.concat(key)); + forEachEntityRecur(stateNode.states[key], visitor, path.concat(key)); } }; -export const forEachAction = ( +export const forEachEntity = ( machine: ExtractorMachineConfig, - visitor: (action: ExtractorMachineAction | undefined) => any, + visitor: ( + entity: ExtractorMachineAction | ExtractorInvokeNodeConfig | undefined, + ) => any, ) => { - return forEachActionRecur(machine, visitor, ['machine']); + return forEachEntityRecur(machine, visitor, ['machine']); }; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 549057ef..5d943b94 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -1,6 +1,6 @@ export * from './createIntrospectableMachine'; export * from './filterOutIgnoredMachines'; -export * from './forEachAction'; +export * from './forEachEntity'; export * from './getInlineImplementations'; export * from './getRangeFromSourceLocation'; export * from './getRawTextFromNode';