Skip to content

[swc-plugin] DCE strips module-scope declarations referenced only by destructuring defaults → ReferenceError #2396

@pranaygp

Description

@pranaygp

Summary

The SWC plugin's dead-code-elimination (DCE) usage analysis does not count identifier references that appear as default values inside a destructuring pattern (e.g. const { ttl = TTL } = options;). When a module-scope const is referenced only through such a destructuring default, the DCE pass treats it as unused and strips it from the emitted bundle — while keeping the code that references it. The result is a runtime ReferenceError: <const> is not defined.

This is mode-independent (reproduces in both step and workflow mode) and class-independent (a plain top-level function exhibits it too). It is a distinct root cause from the hoisting/ordering bug fixed in #1944 — see "Relation to prior fixes" below.

Minimal reproduction

The smallest input that triggers it — a module-scope const referenced only from a destructuring default initializer inside a retained function/method, in a file that also contains a "use step" (or "use workflow") function so the DCE pass runs:

const TTL = 1000;

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

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

How I ran it

Added this as a fixture under packages/swc-plugin-workflow/transform/tests/fixture/ and ran the existing fixture harness in update mode to capture the actual transform output:

cd packages/swc-plugin-workflow/transform
UPDATE=1 cargo test -p swc_workflow --test fixture <fixture_name>

Actual output (step mode) — TTL is gone, C.make still references it

/**__internal_workflows{"steps":{"input.js":{"s":{"stepId":"step//./input//s"}}}}*/;
async function s(x) {
    return x;
}
(function(__wf_fn, __wf_id) { /* step registration IIFE */ })(s, "step//./input//s");
export class C {
    static make(options = {}) {
        const { ttl = TTL } = options;   // <-- TTL no longer declared anywhere
        return ttl;
    }
}

The const TTL = 1000; declaration has been removed from the top of the module, but C.make still reads TTL in its destructuring default.

Runtime ReferenceError (verified end-to-end through the vitest + SWC pipeline)

Reproduced the runtime failure by building an equivalent file through @workflow/vitest (which runs the real SWC plugin) and calling the method with no options so the default fires:

ReferenceError: DEFAULT_TTL_MS is not defined
 ❯ Function.create workflows/zz-repro-const-strip.ts:15:21
    13| export class ReproSwitch {
    14|   static async create(id: string, options: { ttlMs?: number } = {}) {
    15|     const { ttlMs = DEFAULT_TTL_MS } = options;
      |                     ^
    16|     const run = await start(reproWorkflow, [id, ttlMs]);

Expected vs actual

  • Expected: a module-scope declaration referenced by surviving (non-stripped) code is preserved in the bundle. const { ttl = TTL } = options; counts as a use of TTL.
  • Actual: the destructuring-default reference is invisible to the usage collector, so the declaration is pruned, producing ReferenceError at runtime when the default value is used.

What I cross-checked (so the scope is precise)

  • If the same const is also referenced anywhere outside a destructuring default — e.g. const finalTtl = ttlMs === undefined ? TTL : ttlMs;, or read inside the step body — it is kept. The bug is specific to references that occur only in destructuring-default initializers.
  • It reproduces for a plain exported function too (not just class methods), so it is not about class traversal.

Root-cause hypothesis (confirmed location)

packages/swc-plugin-workflow/transform/src/lib.rs, in ComprehensiveUsageCollector::visit_mut_var_declarator (currently ~lines 5001–5006). To avoid marking the binding name of a declaration as "used", the collector visits only the initializer and deliberately skips the entire name pattern:

// Only visit the initializer, not the variable name pattern
// This prevents marking the variable name itself as "used"
if let Some(init) = &mut var_decl.init {
    init.visit_mut_with(self);
}

For an object/array destructuring pattern, the default-value initializer expressions live inside var_decl.name (the part being skipped), e.g. the TTL in { ttl = TTL }. Because the whole name pattern is skipped, those references are never added to used_identifiers, and remove_dead_code then prunes the still-referenced module-scope declaration.

The collector has no visit_mut_class/visit_mut_class_method override, and visit_mut_ident would normally record any reference it visits — so the gap is purely that destructuring-default initializers are never reached by the walk.

Relation to prior fixes

This is the same symptom class as #1944 ("Preserve imports referenced by hoisted nested steps") — DCE removing a declaration that surviving code still references — but a different root cause. #1944 was about DCE ordering relative to step hoisting (imports referenced by hoisted step bodies); this one is about the usage collector never traversing destructuring-default initializers at all, and reproduces with no nested/hoisted steps involved. #1935 (lexical this capture) is adjacent only in that it also tightened reference-detection inside parameter default/destructuring positions, which is the same blind spot in a different visitor.

Real-world impact

  • Shipped (and worked around) in the kill-switch pattern (PR [docs] Patterns: a tiered library of tested, installable Workflow patterns #1858). KillSwitch.create() used const { ttlMs = DEFAULT_TTL_MS, graceMs = DEFAULT_GRACE_MS } = options; against two module-scope consts; the consts were stripped from the bundle and .create() threw ReferenceError when called without explicit options. Fixed by inlining the literals into create().
  • Latent in workbench/vitest/workflows/cookbook/distributed-abort-controller.ts, which still has the exact shape (const { ttlMs = DEFAULT_TTL_MS, graceMs = DEFAULT_GRACE_MS } = options; with module-scope DEFAULT_TTL_MS / DEFAULT_GRACE_MS). Its tests never tripped only because they always pass explicit options, so the defaults never execute.

Suggested fix direction

In ComprehensiveUsageCollector::visit_mut_var_declarator, instead of skipping the entire var_decl.name, traverse the default-value initializer expressions within destructuring patterns while still not marking the binding names as used. (i.e. walk Pat::Object / Pat::Array defaults — ObjectPatProp::KeyValue values, ObjectPatProp::Assign.value, AssignPat.right, array element defaults — visiting only the RHS initializer expressions.) The same blind spot likely applies to function-parameter destructuring defaults visited via this collector, so a fix should cover parameter patterns too.

Where a regression test should live

The fixture harness at packages/swc-plugin-workflow/transform/tests/fixture/**/{input.js|input.ts} with output-step.js / output-workflow.js snapshots (run via cargo test -p swc_workflow --test fixture, update with UPDATE=1). A new fixture (e.g. destructuring-default-references-module-const/) asserting the const survives in both modes would lock this. A complementary runtime regression could go alongside the existing pattern tests in workbench/vitest/test/.

🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions