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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/great-clouds-move.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/swc-plugin": patch
---

Fix class ID generation when class is bound to a variable
47 changes: 47 additions & 0 deletions packages/swc-plugin-workflow/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,53 @@ export class Vector {
}
```

### Class Expressions with Binding Names

When a class expression is assigned to a variable, the plugin uses the variable name (binding name) for registration, not the internal class name. This is important because the internal class name is only accessible inside the class body.

Input:
```javascript
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";

var Bash = class _Bash {
constructor(command) {
this.command = command;
}

static [WORKFLOW_SERIALIZE](instance) {
return { command: instance.command };
}

static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};
```

Output:
```javascript
import { registerSerializationClass } from "workflow/internal/class-serialization";
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from "@workflow/serde";
/**__internal_workflows{"classes":{"input.js":{"Bash":{"classId":"class//input.js//Bash"}}}}*/;
var Bash = class _Bash {
constructor(command) {
this.command = command;
}
static [WORKFLOW_SERIALIZE](instance) {
return { command: instance.command };
}
static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};
registerSerializationClass("class//input.js//Bash", Bash);
```

Note that:
- The registration uses `Bash` (the variable name), not `_Bash` (the internal class name)
- The `classId` in the manifest also uses `Bash`
- This ensures the registration call references a symbol that's actually in scope at module level

### File Discovery for Custom Serialization

Files containing classes with custom serialization are automatically discovered for transformation, even if they don't contain `"use step"` or `"use workflow"` directives. The discovery mechanism looks for:
Expand Down
33 changes: 28 additions & 5 deletions packages/swc-plugin-workflow/transform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,10 @@ pub struct StepTransform {
module_imports: HashSet<String>,
// Track the current class name for static method transformations
current_class_name: Option<String>,
// Track the binding name when a class expression is assigned to a variable
// e.g., for `var Bash = class _Bash {}`, this would be "Bash"
// This is needed because the internal class name (_Bash) is not in scope at module level
current_class_binding_name: Option<String>,
// Track static method steps that need registration after the class declaration
// (class_name, method_name, step_id, span)
static_method_step_registrations: Vec<(String, String, String, swc_core::common::Span)>,
Expand Down Expand Up @@ -1231,6 +1235,7 @@ impl StepTransform {
current_var_context: None,
module_imports: HashSet::new(),
current_class_name: None,
current_class_binding_name: None,
static_method_step_registrations: Vec::new(),
static_method_workflow_registrations: Vec::new(),
static_step_methods_to_strip: Vec::new(),
Expand Down Expand Up @@ -6266,6 +6271,15 @@ impl VisitMut for StepTransform {
}
}
}
Expr::Class(_) => {
// Track the binding name for class expressions like:
// var Bash = class _Bash {}
// The binding name (Bash) is what's accessible at module scope,
// not the internal class name (_Bash)
// We set the binding name here; it will be used when visit_mut_class_expr
// is called during visit_mut_children_with below
self.current_class_binding_name = Some(name.clone());
}
_ => {}
}
}
Expand Down Expand Up @@ -6365,18 +6379,27 @@ impl VisitMut for StepTransform {

// Handle class expressions to track class name for static methods
fn visit_mut_class_expr(&mut self, class_expr: &mut ClassExpr) {
let class_name = class_expr
// Get the internal class name (used for current_class_name tracking)
let internal_class_name = class_expr
.ident
.as_ref()
.map(|i| i.sym.to_string())
.unwrap_or_else(|| "AnonymousClass".to_string());

// For serialization registration, use the binding name if available
// e.g., for `var Bash = class _Bash {}`, use "Bash" not "_Bash"
// because "_Bash" is not accessible at module scope
let registration_name = self
.current_class_binding_name
.take()
.unwrap_or_else(|| internal_class_name.clone());

let old_class_name = self.current_class_name.take();
self.current_class_name = Some(class_name.clone());
self.current_class_name = Some(internal_class_name.clone());

// Check if class has custom serialization methods (WORKFLOW_SERIALIZE/WORKFLOW_DESERIALIZE)
if self.has_custom_serialization_methods(&class_expr.class) {
self.classes_needing_serialization
.insert(class_name.clone());
self.classes_needing_serialization.insert(registration_name);
}

// Visit the class body (this populates static_step_methods_to_strip)
Expand All @@ -6387,7 +6410,7 @@ impl VisitMut for StepTransform {
let methods_to_strip: Vec<_> = self
.static_step_methods_to_strip
.iter()
.filter(|(cn, _, _)| cn == &class_name)
.filter(|(cn, _, _)| cn == &internal_class_name)
.map(|(_, mn, _)| mn.clone())
.collect();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
// Test class expression where binding name differs from internal class name
// e.g., `var Bash = class _Bash {}` - the registration should use "Bash", not "_Bash"
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';

// Class expression with different binding name
var Bash = class _Bash {
constructor(command) {
this.command = command;
}

static [WORKFLOW_SERIALIZE](instance) {
return { command: instance.command };
}

static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};

// Also test anonymous class expression (no internal name)
var Shell = class {
constructor(cmd) {
this.cmd = cmd;
}

static [WORKFLOW_SERIALIZE](instance) {
return { cmd: instance.cmd };
}

static [WORKFLOW_DESERIALIZE](data) {
return new Shell(data.cmd);
}
};

export { Bash, Shell };
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { registerSerializationClass } from "workflow/internal/class-serialization";
// Test class expression where binding name differs from internal class name
// e.g., `var Bash = class _Bash {}` - the registration should use "Bash", not "_Bash"
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
/**__internal_workflows{"classes":{"input.js":{"Bash":{"classId":"class//input.js//Bash"},"Shell":{"classId":"class//input.js//Shell"}}}}*/;
// Class expression with different binding name
var Bash = class _Bash {
constructor(command){
this.command = command;
}
static [WORKFLOW_SERIALIZE](instance) {
return {
command: instance.command
};
}
static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};
// Also test anonymous class expression (no internal name)
var Shell = class {
constructor(cmd){
this.cmd = cmd;
}
static [WORKFLOW_SERIALIZE](instance) {
return {
cmd: instance.cmd
};
}
static [WORKFLOW_DESERIALIZE](data) {
return new Shell(data.cmd);
}
};
export { Bash, Shell };
registerSerializationClass("class//input.js//Bash", Bash);
registerSerializationClass("class//input.js//Shell", Shell);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { registerSerializationClass } from "workflow/internal/class-serialization";
// Test class expression where binding name differs from internal class name
// e.g., `var Bash = class _Bash {}` - the registration should use "Bash", not "_Bash"
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
/**__internal_workflows{"classes":{"input.js":{"Bash":{"classId":"class//input.js//Bash"},"Shell":{"classId":"class//input.js//Shell"}}}}*/;
// Class expression with different binding name
var Bash = class _Bash {
constructor(command){
this.command = command;
}
static [WORKFLOW_SERIALIZE](instance) {
return {
command: instance.command
};
}
static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};
// Also test anonymous class expression (no internal name)
var Shell = class {
constructor(cmd){
this.cmd = cmd;
}
static [WORKFLOW_SERIALIZE](instance) {
return {
cmd: instance.cmd
};
}
static [WORKFLOW_DESERIALIZE](data) {
return new Shell(data.cmd);
}
};
export { Bash, Shell };
registerSerializationClass("class//input.js//Bash", Bash);
registerSerializationClass("class//input.js//Shell", Shell);
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { registerSerializationClass } from "workflow/internal/class-serialization";
// Test class expression where binding name differs from internal class name
// e.g., `var Bash = class _Bash {}` - the registration should use "Bash", not "_Bash"
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
/**__internal_workflows{"classes":{"input.js":{"Bash":{"classId":"class//input.js//Bash"},"Shell":{"classId":"class//input.js//Shell"}}}}*/;
// Class expression with different binding name
var Bash = class _Bash {
constructor(command){
this.command = command;
}
static [WORKFLOW_SERIALIZE](instance) {
return {
command: instance.command
};
}
static [WORKFLOW_DESERIALIZE](data) {
return new Bash(data.command);
}
};
// Also test anonymous class expression (no internal name)
var Shell = class {
constructor(cmd){
this.cmd = cmd;
}
static [WORKFLOW_SERIALIZE](instance) {
return {
cmd: instance.cmd
};
}
static [WORKFLOW_DESERIALIZE](data) {
return new Shell(data.cmd);
}
};
export { Bash, Shell };
registerSerializationClass("class//input.js//Bash", Bash);
registerSerializationClass("class//input.js//Shell", Shell);
Loading