Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Apply workflow function transformation in "step" mode - #420

Merged
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode
Nov 27, 2025
Merged

Apply workflow function transformation in "step" mode#420
TooTallNate merged 5 commits into
mainfrom
11-25-apply_workflow_function_transformation_in_step_mode

Conversation

@TooTallNate

@TooTallNateTooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
Member

Enhanced the SWC plugin to transform workflow functions in "step" mode, ensuring they throw appropriate errors when called directly.

This allows the start() function to be used within step function to trigger new workflow runs.

What changed?

This PR updates the SWC plugin to properly handle workflow functions in "step" mode:

  • Workflow functions now throw an error when called directly in step mode, with a message instructing users to use start(functionName) instead
  • Fixed object property step functions to include parent function context in their IDs
  • Improved step ID generation to maintain proper hierarchical relationships
  • Added proper workflowId assignments to workflow functions in step mode
  • Ensured nested step functions within workflow functions are correctly processed and hoisted

Why make this change?

Previously, workflow functions in step mode weren't being transformed, which could lead to unexpected behavior when users tried to call them directly in a step function. This change ensures a consistent developer experience by providing clear error messages when workflow functions are called directly in a step function. The workflowId property is attached in "step" mode so that the start() function may be used.

@changeset-bot

changeset-botBot commented Nov 25, 2025

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 0435e44

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 11 packages
NameType
@workflow/swc-pluginPatch
@workflow/buildersPatch
@workflow/cliPatch
@workflow/nextPatch
@workflow/nitroPatch
@workflow/rollupPatch
@workflow/sveltekitPatch
workflowPatch
@workflow/world-testingPatch
@workflow/nuxtPatch
@workflow/aiPatch

Not sure what this means? Click here to learn what changesets are.

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

@vercel

vercelBot commented Nov 25, 2025

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

ProjectDeploymentPreviewCommentsUpdated (UTC)
example-nextjs-workflow-turbopackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-nextjs-workflow-webpackReadyReadyPreviewCommentNov 27, 2025 7:28am
example-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-express-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-fastify-workflowErrorErrorNov 27, 2025 7:28am
workbench-hono-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nitro-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-nuxt-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-sveltekit-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workbench-vite-workflowReadyReadyPreviewCommentNov 27, 2025 7:28am
workflow-docsReadyReadyPreviewCommentNov 27, 2025 7:28am

@TooTallNateGraphite App

TooTallNate commented Nov 25, 2025

Copy link
Copy Markdown
MemberAuthor

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Default export workflow functions are not being transformed in step mode. They should have their bodies replaced with error throws like exported named workflow functions do.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index 5a054fe..b0a5a17 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -4531,7 +4531,14 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(+ &mut fn_expr.function.body,+ );+ self.workflow_functions_needing_id+ .push((name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5769,7 +5776,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, just remove the directive
@@ -5917,6 +5929,50 @@ impl VisitMut for StepTransform {
}
decl.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if let DefaultDecl::Fn(fn_expr) = &mut decl.decl {+ let is_workflow = self.workflow_function_names.contains("default");++ // In Step mode, replace workflow function body with throw after processing nested steps+ if matches!(self.mode, TransformMode::Step) {+ if is_workflow {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ }+ }
}
_ => {
decl.visit_mut_children_with(self);
@@ -5942,7 +5998,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6043,7 +6104,12 @@ impl VisitMut for StepTransform {
match self.mode {
TransformMode::Step => {
- // Workflow functions are not processed in step mode+ // In step mode, remove the directive for now+ // We'll visit children to process nested steps,+ // then replace the body with throw error afterwards+ self.remove_use_workflow_directive(&mut fn_expr.function.body);+ self.workflow_functions_needing_id+ .push((const_name.clone(), fn_expr.function.span));
}
TransformMode::Workflow => {
// In workflow mode, convert to const declaration
@@ -6137,6 +6203,88 @@ impl VisitMut for StepTransform {
}
expr.visit_mut_children_with(self);
++ // After visiting, process the function again for cleanup+ if matches!(self.mode, TransformMode::Step) {+ let is_workflow = self.workflow_function_names.contains("default");+ if is_workflow {+ match &mut *expr.expr {+ Expr::Fn(fn_expr) => {+ // Replace workflow function body with error throw+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ if let Some(body) = &mut fn_expr.function.body {+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ body.stmts = vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })];+ }+ }+ Expr::Arrow(arrow_expr) => {+ // Replace arrow body with throw error+ let const_name = self.workflow_export_to_const_name+ .get("default")+ .map(|s| s.to_string())+ .unwrap_or_else(|| "default".to_string());+ let error_msg = format!(+ "You attempted to execute workflow {} function directly. To start a workflow, use start({}) from workflow/api",+ const_name, const_name+ );+ let error_expr = Expr::New(NewExpr {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ callee: Box::new(Expr::Ident(Ident::new(+ "Error".into(),+ DUMMY_SP,+ SyntaxContext::empty(),+ ))),+ args: Some(vec![ExprOrSpread {+ spread: None,+ expr: Box::new(Expr::Lit(Lit::Str(Str {+ span: DUMMY_SP,+ value: error_msg.into(),+ raw: None,+ }))),+ }]),+ type_args: None,+ });+ arrow_expr.body = Box::new(BlockStmtOrExpr::BlockStmt(BlockStmt {+ span: DUMMY_SP,+ ctxt: SyntaxContext::empty(),+ stmts: vec![Stmt::Throw(ThrowStmt {+ span: DUMMY_SP,+ arg: Box::new(error_expr),+ })],+ }));+ }+ _ => {}+ }+ }+ }
}
fn visit_mut_module_decl(&mut self, decl: &mut ModuleDecl) {

Analysis

Default export workflow functions not transformed in step mode

What fails: Workflow functions exported as default exports (e.g., export default async function defaultWorkflow() { 'use workflow'; ... }) are not having their bodies replaced with error throws in step mode, while named exports and const exports receive this transformation.

How to reproduce:

The issue is evident by comparing test fixture output files. The input file packages/swc-plugin-workflow/transform/tests/fixture/workflow-client-property/input.js contains:

exportasyncfunctionmyWorkflow(){'use workflow';// ...}exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// ...}

When transformed in step mode, the named export myWorkflow correctly becomes:

exportasyncfunctionmyWorkflow(){thrownewError("You attempted to execute workflow myWorkflow function directly...");}

But the default export defaultWorkflow incorrectly remains:

exportdefaultasyncfunctiondefaultWorkflow(){'use workflow';// original body unchanged}

Expected behavior: The default export should also throw an error in step mode, matching the behavior of named exports. This was the intent of commit ef73dc7 "Apply workflow function transformation in 'step' mode" which updated the test fixture's expected output to show default exports throwing errors.

Root cause: The code in visit_mut_export_default_decl() (line 5771-5773) had Step mode handling that did nothing with a comment stating "Workflow functions are not processed in step mode". This was incorrect - the PR that introduced this function explicitly intended for workflow functions to throw errors in step mode. The named export handler visit_mut_export_decl() correctly implements this transformation, but the default export handler did not.

Fix implemented:

  • Added Step mode handling in visit_mut_export_default_decl to remove the workflow directive and track the function for workflowId assignment
  • Added post-processing after visiting children to replace the function body with an error throw
  • Applied the same fix to visit_mut_export_default_expr for arrow function default exports
  • Applied the same fix to variable declarations with workflow functions in visit_mut_export_decl's Var handling
  • All fixes follow the same pattern used for named function exports

The transformation now properly handles all three forms of default exports:

  1. export default async function name() { 'use workflow'; ... }
  2. export default async () => { 'use workflow'; ... }
  3. Function expressions assigned to default export

All now correctly throw errors in step mode, consistent with named exports and const exports.

Fix on Vercel

@TooTallNate
TooTallNate marked this pull request as ready for review November 25, 2025 20:18
@TooTallNate
TooTallNateforce-pushed the 11-25-apply_workflow_function_transformation_in_step_mode branch from df38802 to c1ed746CompareNovember 25, 2025 23:03

@vercelvercelBot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Additional Suggestion:

Metadata key-StepId mismatch for nested object properties: When object property step functions are nested inside workflow functions, their stepIds include the parent workflow name (e.g., step//input.js//example/helpers/objectStep), but the metadata keys don't (e.g., "helpers/objectStep"), causing inconsistency.

View Details
📝 Patch Details
diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs
index c4fc27c..9c3fbf6 100644
--- a/packages/swc-plugin-workflow/transform/src/lib.rs+++ b/packages/swc-plugin-workflow/transform/src/lib.rs@@ -206,7 +206,7 @@ pub struct StepTransform {
anonymous_fn_counter: usize,
// Track object properties that need to be converted to initializer calls in workflow mode
// (parent_var_name, prop_name, step_id)
- object_property_workflow_conversions: Vec<(String, String, String)>,+ object_property_workflow_conversions: Vec<(String, String, String, Option<String>)>,
// Current context: variable name being processed when visiting object properties
#[allow(dead_code)]
current_var_context: Option<String>,
@@ -1459,6 +1459,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1477,6 +1478,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key,
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -1527,6 +1529,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Workflow => {
@@ -1536,6 +1539,7 @@ impl StepTransform {
parent_var_name.to_string(),
prop_key.to_string(),
step_id,
+ self.current_workflow_function_name.as_ref().cloned(),
));
}
TransformMode::Client => {
@@ -2651,8 +2655,12 @@ impl StepTransform {
.collect();
// Add object property step functions to metadata
- for (parent_var, prop_name, step_id) in &self.object_property_workflow_conversions {- let key = format!("{}/{}", parent_var, prop_name);+ for (parent_var, prop_name, step_id, workflow_name) in &self.object_property_workflow_conversions {+ let key = if let Some(wf_name) = workflow_name {+ format!("{}/{}/{}", wf_name, parent_var, prop_name)+ } else {+ format!("{}/{}", parent_var, prop_name)+ };
steps_entries.push(format!("\"{}\":{{\"stepId\":\"{}\"}}", key, step_id));
}
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js
index de7b584..cb77670 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-step.js@@ -1,5 +1,5 @@
import { registerStepFunction } from "workflow/internal/private";
-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
// Function declaration step
async function example$step(a, b) {
return a + b;
diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js
index bee6fcd..9a91339 100644
--- a/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js+++ b/packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js@@ -1,4 +1,4 @@-/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;+/**__internal_workflows{"workflows":{"input.js":{"example":{"workflowId":"workflow//input.js//example"}}},"steps":{"input.js":{"arrowStep":{"stepId":"step//input.js//arrowStep"},"example/helpers/objectStep":{"stepId":"step//input.js//example/helpers/objectStep"},"letArrowStep":{"stepId":"step//input.js//letArrowStep"},"step":{"stepId":"step//input.js//step"},"varArrowStep":{"stepId":"step//input.js//varArrowStep"}}}}*/;
export async function example(a, b) {
var step = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//input.js//example/step");
// Arrow function with const

Analysis

Metadata key-StepId mismatch for nested object properties in workflows

What fails: Object property step functions nested inside workflow functions generate inconsistent metadata keys. The stepId includes the workflow name (e.g., "step//input.js//example/helpers/objectStep"), but the metadata key doesn't (e.g., "helpers/objectStep"), causing a mismatch that breaks tools relying on metadata key-to-stepId matching.

How to reproduce:

  1. Create a workflow with nested object property step functions
  2. Build with the transform plugin
  3. Examine the __internal_workflows metadata comment in output
  4. Compare metadata keys vs stepIds for object properties

Result: In packages/swc-plugin-workflow/transform/tests/fixture/nested-step-in-workflow/output-workflow.js, the metadata shows:

"helpers/objectStep": {"stepId": "step//input.js//example/helpers/objectStep"}

The key lacks the "example/" workflow prefix that appears in the stepId.

Expected: Metadata key should be "example/helpers/objectStep" to match the hierarchical structure of the stepId when nested in a workflow.

Root cause: The object_property_workflow_conversions vector stored (parent_var_name, prop_name, step_id) but discarded the workflow name after using it to generate the stepId. Metadata generation only had access to parent_var_name and prop_name, not the workflow context.

Fix: Extended the tuple to store (parent_var_name, prop_name, step_id, workflow_name) so metadata generation can include the workflow prefix when present, matching the format used during step ID creation.

Fix on Vercel

@VaguelySeriousVaguelySerious left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🚢

Sign up for freeto 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.

2 participants

@TooTallNate@VaguelySerious