Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/preserve-imports-used-by-hoisted-steps.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"@workflow/swc-plugin": patch
---

Fix three bugs affecting nested step functions that get hoisted out of an enclosing function (workflows in any declaration form, plus regular factory-style functions returning objects with step methods):

1. Module-level imports referenced only by hoisted step bodies were stripped by dead-code elimination, causing a `ReferenceError` at runtime.
2. The step ID generated for nested anonymous steps inside a non-exported workflow declared as `const foo = async () => {}` or `const foo = async function() {}` was not namespaced under the workflow name in step mode, so it did not match the ID looked up by the workflow-mode proxy and caused a runtime "step not found" failure. Steps inside `async function foo()` workflows were already namespaced correctly; this brings the const-arrow and const-fn-expression forms into agreement.
3. The `__internal_workflows` manifest comment reported nested anonymous step IDs without the workflow-name prefix even though the runtime registration and proxy lookup used the prefixed form, so downstream tooling (e.g. builders consuming the manifest) saw the wrong step ID.
228 changes: 166 additions & 62 deletions packages/swc-plugin-workflow/transform/src/lib.rs

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { __private_getClosureVars, registerStepFunction } from "workflow/interna
// https://github.com/vercel/workflow/issues/1365
import { MockLanguageModelV3 } from 'ai/test';
import { xai as xaiProvider } from '@ai-sdk/xai';
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//_anonymousStep9"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//mockModel/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//xai/_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//withForIn/_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//withDoWhile/_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//withShorthandProps/_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//withComputedKey/_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//mockTextModel/_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//withClassExpr/_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//withClassSuper/_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//withClassProp/_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//mockModelWrapped/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//configuredStep/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withOptionalChaining/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withSequenceExpr/_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//withTryCatch/_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//withThrow/_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//withSwitch/_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//withForOf/_anonymousStep9"}}}}*/;
var mockModel$_anonymousStep0 = async ()=>{
const { args } = __private_getClosureVars();
return new MockLanguageModelV3(...args);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
// https://github.com/vercel/workflow/issues/1365
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//_anonymousStep9"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//mockModel/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//xai/_anonymousStep1"},"_anonymousStep10":{"stepId":"step//./input//withForIn/_anonymousStep10"},"_anonymousStep11":{"stepId":"step//./input//withDoWhile/_anonymousStep11"},"_anonymousStep12":{"stepId":"step//./input//withShorthandProps/_anonymousStep12"},"_anonymousStep13":{"stepId":"step//./input//withComputedKey/_anonymousStep13"},"_anonymousStep14":{"stepId":"step//./input//mockTextModel/_anonymousStep14"},"_anonymousStep15":{"stepId":"step//./input//withClassExpr/_anonymousStep15"},"_anonymousStep16":{"stepId":"step//./input//withClassSuper/_anonymousStep16"},"_anonymousStep17":{"stepId":"step//./input//withClassProp/_anonymousStep17"},"_anonymousStep2":{"stepId":"step//./input//mockModelWrapped/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//configuredStep/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withOptionalChaining/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withSequenceExpr/_anonymousStep5"},"_anonymousStep6":{"stepId":"step//./input//withTryCatch/_anonymousStep6"},"_anonymousStep7":{"stepId":"step//./input//withThrow/_anonymousStep7"},"_anonymousStep8":{"stepId":"step//./input//withSwitch/_anonymousStep8"},"_anonymousStep9":{"stepId":"step//./input//withForOf/_anonymousStep9"}}}}*/;
// Bug 1: `new` expressions should have their arguments captured as closure vars
export function mockModel(...args) {
return globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//mockModel/_anonymousStep0", ()=>({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { __private_getClosureVars, registerStepFunction } from "workflow/internal/private";
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"}}}}*/;
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//withTsAs/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//withTsSatisfies/_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//withTsNonNull/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//withTsTypeAssertion/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withTsConstAssertion/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withGenericCall/_anonymousStep5"}}}}*/;
var withTsAs$_anonymousStep0 = async ()=>{
const { config } = __private_getClosureVars();
return config as Config.timeout;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//_anonymousStep5"}}}}*/;
/**__internal_workflows{"steps":{"input.ts":{"_anonymousStep0":{"stepId":"step//./input//withTsAs/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//withTsSatisfies/_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//withTsNonNull/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//withTsTypeAssertion/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//withTsConstAssertion/_anonymousStep4"},"_anonymousStep5":{"stepId":"step//./input//withGenericCall/_anonymousStep5"}}}}*/;
// TypeScript expression wrappers should not prevent closure variable detection.
// The plugin must traverse through `as`, `satisfies`, `!`, type assertions,
// const assertions, and instantiation expressions to reach the inner expression.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import fs from 'fs/promises';
/**__internal_workflows{"steps":{"input.js":{"myFactory/myStep":{"stepId":"step//./input//myFactory/myStep"}}}}*/;
var myFactory$myStep = async function() {
await fs.mkdir('test');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { registerStepFunction } from "workflow/internal/private";
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@vercel/workflow';
/**__internal_workflows{"steps":{"input.js":{"Service#process":{"stepId":"step//./input//Service#process"},"helper":{"stepId":"step//./input//helper"}}},"classes":{"input.js":{"Service":{"classId":"class//./input//Service"}}}}*/;
/**__internal_workflows{"steps":{"input.js":{"Service#process":{"stepId":"step//./input//Service#process"},"helper":{"stepId":"step//./input//Service$process/helper"}}},"classes":{"input.js":{"Service":{"classId":"class//./input//Service"}}}}*/;
var Service$process$helper = async (x)=>x * 2;
export class Service {
static [WORKFLOW_SERIALIZE](instance) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//letArrowStep"},"step":{"stepId":"step//./input//step"},"varArrowStep":{"stepId":"step//./input//varArrowStep"}}}}*/;
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//example/arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//example/letArrowStep"},"step":{"stepId":"step//./input//example/step"},"varArrowStep":{"stepId":"step//./input//example/varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//letArrowStep"},"step":{"stepId":"step//./input//step"},"varArrowStep":{"stepId":"step//./input//varArrowStep"}}}}*/;
/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//./input//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//./input//example/arrowStep"},"helpers/objectStep":{"stepId":"step//./input//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//./input//example/letArrowStep"},"step":{"stepId":"step//./input//example/step"},"varArrowStep":{"stepId":"step//./input//example/varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//example/step");
// Arrow function with const
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Regression test for two related bugs:
//
// 1. (step mode) Imports used by a step that gets hoisted out of a
// workflow body must NOT be stripped by dead-code elimination. The
// workflow body is replaced with a `throw` proxy, so any import
// referenced only by the workflow body (and not by a hoisted step)
// should still be stripped. Truly unused imports should also be
// stripped.
// 2. (cross-mode) The step ID generated for a nested anonymous step
// inside a *non-exported* workflow function must agree between step
// mode (where the step is registered) and workflow mode (where the
// step proxy looks it up). Both must namespace the step under the
// workflow function name (e.g. `step//./input//w/_anonymousStep0`).
import { db } from './db'; // step-mode: kept (used by hoisted step)
import { unused } from './unused'; // step-mode: stripped (truly unused)
import * as logger from './logger'; // step-mode: kept (used by hoisted step)
import { tool, z } from 'some-agent-lib'; // step-mode: stripped (only referenced by replaced workflow body); workflow-mode: kept

async function w() {
'use workflow';
const agent = new WorkflowAgent({
model: 'anthropic/claude-opus-4.5',
tools: () => ({
queryDatabase: tool({
description: 'Query the database',
inputSchema: z.object({ query: z.string() }),
execute: async (input) => {
'use step';
logger.info('querying', input.query);
return db.query(input.query);
},
}),
}),
});
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Regression test for two related bugs:
//
// 1. (step mode) Imports used by a step that gets hoisted out of a
// workflow body must NOT be stripped by dead-code elimination. The
// workflow body is replaced with a `throw` proxy, so any import
// referenced only by the workflow body (and not by a hoisted step)
// should still be stripped. Truly unused imports should also be
// stripped.
// 2. (cross-mode) The step ID generated for a nested anonymous step
// inside a *non-exported* workflow function must agree between step
// mode (where the step is registered) and workflow mode (where the
// step proxy looks it up). Both must namespace the step under the
// workflow function name (e.g. `step//./input//w/_anonymousStep0`).
/**__internal_workflows{"workflows":{"input.js":{"w":{"workflowId":"workflow//./input//w"}}}}*/;
async function w() {
throw new Error("You attempted to execute workflow w function directly. To start a workflow, use start(w) from workflow/api");
}
w.workflowId = "workflow//./input//w";
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { registerStepFunction } from "workflow/internal/private";
// Regression test for two related bugs:
//
// 1. (step mode) Imports used by a step that gets hoisted out of a
// workflow body must NOT be stripped by dead-code elimination. The
// workflow body is replaced with a `throw` proxy, so any import
// referenced only by the workflow body (and not by a hoisted step)
// should still be stripped. Truly unused imports should also be
// stripped.
// 2. (cross-mode) The step ID generated for a nested anonymous step
// inside a *non-exported* workflow function must agree between step
// mode (where the step is registered) and workflow mode (where the
// step proxy looks it up). Both must namespace the step under the
// workflow function name (e.g. `step//./input//w/_anonymousStep0`).
import { db } from './db'; // step-mode: kept (used by hoisted step)
import { unused } from './unused'; // step-mode: stripped (truly unused)
import * as logger from './logger'; // step-mode: kept (used by hoisted step)
import { tool, z } from 'some-agent-lib'; // step-mode: stripped (only referenced by replaced workflow body); workflow-mode: kept
/**__internal_workflows{"workflows":{"input.js":{"w":{"workflowId":"workflow//./input//w"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//w/_anonymousStep0"}}}}*/;
var w$_anonymousStep0 = async (input)=>{
logger.info('querying', input.query);
return db.query(input.query);
};
async function w() {
throw new Error("You attempted to execute workflow w function directly. To start a workflow, use start(w) from workflow/api");
}
w.workflowId = "workflow//./input//w";
registerStepFunction("step//./input//w/_anonymousStep0", w$_anonymousStep0);
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
// Regression test for two related bugs:
//
// 1. (step mode) Imports used by a step that gets hoisted out of a
// workflow body must NOT be stripped by dead-code elimination. The
// workflow body is replaced with a `throw` proxy, so any import
// referenced only by the workflow body (and not by a hoisted step)
// should still be stripped. Truly unused imports should also be
// stripped.
// 2. (cross-mode) The step ID generated for a nested anonymous step
// inside a *non-exported* workflow function must agree between step
// mode (where the step is registered) and workflow mode (where the
// step proxy looks it up). Both must namespace the step under the
// workflow function name (e.g. `step//./input//w/_anonymousStep0`).
import { tool, z } from 'some-agent-lib'; // step-mode: stripped (only referenced by replaced workflow body); workflow-mode: kept
/**__internal_workflows{"workflows":{"input.js":{"w":{"workflowId":"workflow//./input//w"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//w/_anonymousStep0"}}}}*/;
async function w() {
const agent = new WorkflowAgent({
model: 'anthropic/claude-opus-4.5',
tools: ()=>({
queryDatabase: tool({
description: 'Query the database',
inputSchema: z.object({
query: z.string()
}),
execute: globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//w/_anonymousStep0")
})
})
});
}
w.workflowId = "workflow//./input//w";
globalThis.__private_workflows.set("workflow//./input//w", w);
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { __private_getClosureVars, registerStepFunction } from "workflow/internal/private";
import { DurableAgent } from '@workflow/ai/agent';
import { gateway } from 'ai';
/**__internal_workflows{"workflows":{"input.js":{"wflow":{"workflowId":"workflow//./input//wflow"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//_anonymousStep4"},"f":{"stepId":"step//./input//f"},"fn":{"stepId":"step//./input//fn"},"namedStepWithClosureVars":{"stepId":"step//./input//namedStepWithClosureVars"}}}}*/;
/**__internal_workflows{"workflows":{"input.js":{"wflow":{"workflowId":"workflow//./input//wflow"}}},"steps":{"input.js":{"_anonymousStep0":{"stepId":"step//./input//stepWrapperReturnArrowFunction/_anonymousStep0"},"_anonymousStep1":{"stepId":"step//./input//arrowWrapperReturnArrowFunction/_anonymousStep1"},"_anonymousStep2":{"stepId":"step//./input//wflow/_anonymousStep2"},"_anonymousStep3":{"stepId":"step//./input//wflow/_anonymousStep3"},"_anonymousStep4":{"stepId":"step//./input//wflow/_anonymousStep4"},"f":{"stepId":"step//./input//arrowWrapperReturnNamedFunction/f"},"fn":{"stepId":"step//./input//arrowWrapperReturnNamedFunctionVar/fn"},"namedStepWithClosureVars":{"stepId":"step//./input//wflow/namedStepWithClosureVars"}}}}*/;
var stepWrapperReturnArrowFunctionVar$fn = async ()=>{
const { a, b, c } = __private_getClosureVars();
return a + b + c;
Expand Down
Loading