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/swc-destructuring-default-dce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/swc-plugin": patch
---

Fix dead-code elimination stripping module-scope declarations referenced only by a destructuring-default initializer (e.g. `const { ttl = TTL } = options;`), which caused a runtime `ReferenceError` when the default fired.
4 changes: 2 additions & 2 deletions packages/swc-plugin-workflow/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Note: File extensions are stripped from local paths for cleaner IDs.

In step mode, step function bodies are kept intact and registered using an inline IIFE that stores them in a global registry via `Symbol.for("@workflow/core//registeredSteps")`, with no module imports. Workflow functions throw an error if called directly (since they should only run in the workflow runtime).

After the step-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are preserved (unlike workflow mode where they are replaced with proxies), imports, helper functions, and other declarations referenced from step bodies are also preserved. However, code that is reachable only from workflow bodies that were replaced with throwing stubs can still be removed.
After the step-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are preserved (unlike workflow mode where they are replaced with proxies), imports, helper functions, and other declarations referenced from step bodies are also preserved. However, code that is reachable only from workflow bodies that were replaced with throwing stubs can still be removed. A reference counts even when it appears only inside a destructuring-default initializer — e.g. `const { ttl = TTL } = options;` counts as a use of `TTL`, so the declaration is not stripped.

Object property step functions are hoisted to module-level variables and the original call site is replaced with a reference to the hoisted variable, making `.stepId` accessible at the call site.

Expand Down Expand Up @@ -438,7 +438,7 @@ export async function subtract(a, b) {

In workflow mode, step function bodies are replaced with a `globalThis[Symbol.for("WORKFLOW_USE_STEP")]` call. Workflow functions keep their bodies and are registered with `globalThis.__private_workflows.set()`.

After the workflow-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are replaced with step proxies, imports, helper functions, nested steps, and other pure statements that were only referenced from those original step bodies become eligible for removal. Exports and any identifiers still referenced by the transformed workflow code are preserved.
After the workflow-mode rewrite, the transform also runs a dead code elimination (DCE) pass. Because step bodies are replaced with step proxies, imports, helper functions, nested steps, and other pure statements that were only referenced from those original step bodies become eligible for removal. Exports and any identifiers still referenced by the transformed workflow code are preserved. A reference counts even when it appears only inside a destructuring-default initializer — e.g. `const { ttl = TTL } = options;` counts as a use of `TTL`, so the declaration is not stripped.

### Step Functions

Expand Down
67 changes: 65 additions & 2 deletions packages/swc-plugin-workflow/transform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4998,8 +4998,14 @@ impl<'a> VisitMut for ComprehensiveUsageCollector<'a> {
}
}

// Only visit the initializer, not the variable name pattern
// This prevents marking the variable name itself as "used"
// Only visit the initializer, not the variable name pattern, to avoid
// marking the binding name itself as "used". However, destructuring
// patterns can embed default-value initializer expressions (e.g. the
// `TTL` in `const { ttl = TTL } = options;`) that reference other
// declarations. Those references live inside the name pattern, so they
// must be counted as uses or DCE will strip the still-referenced
// declaration and produce a runtime ReferenceError (issue #2396).
self.visit_pat_default_initializers(&mut var_decl.name);
if let Some(init) = &mut var_decl.init {
init.visit_mut_with(self);
}
Expand All @@ -5008,6 +5014,63 @@ impl<'a> VisitMut for ComprehensiveUsageCollector<'a> {
noop_visit_mut_type!();
}

impl<'a> ComprehensiveUsageCollector<'a> {
/// Visit only the default-value initializer expressions (and computed keys)
/// embedded in a binding pattern, without marking the pattern's binding
/// names as "used". Destructuring defaults such as the `TTL` in
/// `const { ttl = TTL } = options;` would otherwise be invisible to the
/// usage collector, causing DCE to strip the referenced declaration.
fn visit_pat_default_initializers(&mut self, pat: &mut Pat) {
match pat {
// A plain binding name has no embedded initializer to visit, and we
// deliberately do not mark the binding name itself as used.
Pat::Ident(_) => {}
Pat::Array(array) => {
for elem in array.elems.iter_mut().flatten() {
self.visit_pat_default_initializers(elem);
}
}
Pat::Object(obj) => {
for prop in &mut obj.props {
match prop {
ObjectPatProp::KeyValue(kv) => {
// A computed key (e.g. `{ [k]: v }`) references identifiers.
if let PropName::Computed(computed) = &mut kv.key {
computed.expr.visit_mut_with(self);
}
self.visit_pat_default_initializers(&mut kv.value);
}
// `{ ttl = TTL }` — `key` is the binding name (skip),
// `value` is the default initializer expression.
ObjectPatProp::Assign(assign) => {
if let Some(value) = &mut assign.value {
value.visit_mut_with(self);
}
}
ObjectPatProp::Rest(rest) => {
self.visit_pat_default_initializers(&mut rest.arg);
}
}
}
}
// `[a = TTL]` / `{ x: y = TTL }` — `left` is the binding pattern
// (which may itself contain defaults), `right` is the default value.
Pat::Assign(assign) => {
self.visit_pat_default_initializers(&mut assign.left);
assign.right.visit_mut_with(self);
}
Pat::Rest(rest) => {
self.visit_pat_default_initializers(&mut rest.arg);
}
// Expression patterns (e.g. `[obj.prop] = ...`) reference identifiers.
Pat::Expr(expr) => {
expr.visit_mut_with(self);
}
Pat::Invalid(_) => {}
}
}
}

impl VisitMut for StepTransform {
fn visit_mut_program(&mut self, program: &mut Program) {
// First pass: collect step functions
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Regression test for issue #2396: dead-code elimination must not strip a
// module-scope declaration that is referenced only by a destructuring-default
// initializer (e.g. `const { ttl = TTL } = options;`). The reference lives
// inside the binding pattern, which the usage collector previously skipped,
// so the declaration was pruned and the surviving code threw a runtime
// ReferenceError when the default fired.
//
// Both consts below are referenced ONLY through destructuring defaults and
// must survive in both step and workflow mode.

// Referenced from a destructuring default inside a class static method.
const TTL = 1000;

// Referenced from a destructuring default inside a plain top-level function,
// to prove the bug is class-independent.
const RETRIES = 3;

// Truly unused module-scope const — this one SHOULD be stripped by DCE, to
// confirm the fix only preserves declarations that are actually referenced.
const UNUSED = 'dead';

async function s(x) {
'use step';
return x;
}

export class C {
static make(options = {}) {
const { ttl = TTL } = options;
return ttl;
}
}

export function plain(options = {}) {
const { retries = RETRIES } = options;
return retries;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/**__internal_workflows{"steps":{"input.js":{"s":{"stepId":"step//./input//s"}}}}*/;
// Regression test for issue #2396: dead-code elimination must not strip a
// module-scope declaration that is referenced only by a destructuring-default
// initializer (e.g. `const { ttl = TTL } = options;`). The reference lives
// inside the binding pattern, which the usage collector previously skipped,
// so the declaration was pruned and the surviving code threw a runtime
// ReferenceError when the default fired.
//
// Both consts below are referenced ONLY through destructuring defaults and
// must survive in both step and workflow mode.
// Referenced from a destructuring default inside a class static method.
const TTL = 1000;
// Referenced from a destructuring default inside a plain top-level function,
// to prove the bug is class-independent.
const RETRIES = 3;
async function s(x) {
return x;
}
(function(__wf_fn, __wf_id) {
var __wf_sym = Symbol.for("@workflow/core//registeredSteps"), __wf_reg = globalThis[__wf_sym] || (globalThis[__wf_sym] = new Map());
__wf_reg.set(__wf_id, __wf_fn);
__wf_fn.stepId = __wf_id;
Object.defineProperty(__wf_fn, "name", {
value: "s",
configurable: true
});
})(s, "step//./input//s");
export class C {
static make(options = {}) {
const { ttl = TTL } = options;
return ttl;
}
}
export function plain(options = {}) {
const { retries = RETRIES } = options;
return retries;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**__internal_workflows{"steps":{"input.js":{"s":{"stepId":"step//./input//s"}}}}*/;
// Regression test for issue #2396: dead-code elimination must not strip a
// module-scope declaration that is referenced only by a destructuring-default
// initializer (e.g. `const { ttl = TTL } = options;`). The reference lives
// inside the binding pattern, which the usage collector previously skipped,
// so the declaration was pruned and the surviving code threw a runtime
// ReferenceError when the default fired.
//
// Both consts below are referenced ONLY through destructuring defaults and
// must survive in both step and workflow mode.
// Referenced from a destructuring default inside a class static method.
const TTL = 1000;
// Referenced from a destructuring default inside a plain top-level function,
// to prove the bug is class-independent.
const RETRIES = 3;
var s = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step//./input//s");
export class C {
static make(options = {}) {
const { ttl = TTL } = options;
return ttl;
}
}
export function plain(options = {}) {
const { retries = RETRIES } = options;
return retries;
}
Loading