diff --git a/.changeset/great-clouds-move.md b/.changeset/great-clouds-move.md new file mode 100644 index 0000000000..bad7afe0d9 --- /dev/null +++ b/.changeset/great-clouds-move.md @@ -0,0 +1,5 @@ +--- +"@workflow/swc-plugin": patch +--- + +Fix class ID generation when class is bound to a variable diff --git a/packages/swc-plugin-workflow/spec.md b/packages/swc-plugin-workflow/spec.md index d5ff9291e6..c0f76e188b 100644 --- a/packages/swc-plugin-workflow/spec.md +++ b/packages/swc-plugin-workflow/spec.md @@ -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: diff --git a/packages/swc-plugin-workflow/transform/src/lib.rs b/packages/swc-plugin-workflow/transform/src/lib.rs index f5fded485f..549b153eb5 100644 --- a/packages/swc-plugin-workflow/transform/src/lib.rs +++ b/packages/swc-plugin-workflow/transform/src/lib.rs @@ -355,6 +355,10 @@ pub struct StepTransform { module_imports: HashSet, // Track the current class name for static method transformations current_class_name: Option, + // 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, // 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)>, @@ -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(), @@ -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()); + } _ => {} } } @@ -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) @@ -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(); diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/input.js b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/input.js new file mode 100644 index 0000000000..9baf6d23de --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/input.js @@ -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 }; diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-client.js b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-client.js new file mode 100644 index 0000000000..b9d5498766 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-client.js @@ -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); diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-step.js b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-step.js new file mode 100644 index 0000000000..b9d5498766 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-step.js @@ -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); diff --git a/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-workflow.js b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-workflow.js new file mode 100644 index 0000000000..b9d5498766 --- /dev/null +++ b/packages/swc-plugin-workflow/transform/tests/fixture/class-expression-binding-name/output-workflow.js @@ -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);