You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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:
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
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"ifletSome(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/.
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-scopeconstis 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 runtimeReferenceError: <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
constreferenced 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: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:Actual output (step mode) —
TTLis gone,C.makestill references itThe
const TTL = 1000;declaration has been removed from the top of the module, butC.makestill readsTTLin 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:Expected vs actual
const { ttl = TTL } = options;counts as a use ofTTL.ReferenceErrorat runtime when the default value is used.What I cross-checked (so the scope is precise)
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.functiontoo (not just class methods), so it is not about class traversal.Root-cause hypothesis (confirmed location)
packages/swc-plugin-workflow/transform/src/lib.rs, inComprehensiveUsageCollector::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:For an object/array destructuring pattern, the default-value initializer expressions live inside
var_decl.name(the part being skipped), e.g. theTTLin{ ttl = TTL }. Because the wholenamepattern is skipped, those references are never added toused_identifiers, andremove_dead_codethen prunes the still-referenced module-scope declaration.The collector has no
visit_mut_class/visit_mut_class_methodoverride, andvisit_mut_identwould 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
thiscapture) 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
KillSwitch.create()usedconst { ttlMs = DEFAULT_TTL_MS, graceMs = DEFAULT_GRACE_MS } = options;against two module-scope consts; the consts were stripped from the bundle and.create()threwReferenceErrorwhen called without explicit options. Fixed by inlining the literals intocreate().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-scopeDEFAULT_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 entirevar_decl.name, traverse the default-value initializer expressions within destructuring patterns while still not marking the binding names as used. (i.e. walkPat::Object/Pat::Arraydefaults —ObjectPatProp::KeyValuevalues,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}withoutput-step.js/output-workflow.jssnapshots (run viacargo test -p swc_workflow --test fixture, update withUPDATE=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 inworkbench/vitest/test/.🤖 Generated with Claude Code