Skip to content

Commit 66585fd

Browse files
authored
feat(swc): dead code eliminate unreferenced private class members in workflow mode (#1671)
* feat(swc): dead code eliminate unreferenced private class members in workflow mode After stripping 'use step' methods from a class body in workflow mode, eliminate private members (both JS native #field/#method and TypeScript private field/private method) that are no longer referenced by any remaining public member. The algorithm is iterative: references are seeded from public members, then expanded through surviving private members until a fixed point, enabling cascading elimination (e.g. a private field only referenced by a private method that is itself unreferenced). * test(swc): add fixture for JS native private member DCE (#field, #method) * fix(swc): address review feedback on private member DCE - Namespace JS native private names with # prefix to avoid collisions with TS private members of the same name - Track TS private member accesses on non-this receivers (e.g. a.x in static methods) by maintaining a set of known TS-private names - Use visit_children_with for full traversal including computed member expressions - Extract retain logic into shared retain_referenced_private_members() helper used by both visit_mut_class_decl and visit_mut_class_expr
1 parent 32a17b4 commit 66585fd

11 files changed

Lines changed: 677 additions & 1 deletion

File tree

.changeset/private-member-dce.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@workflow/swc-plugin": patch
3+
---
4+
5+
Eliminate unreferenced private class members in workflow mode after `"use step"` stripping

packages/swc-plugin-workflow/spec.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1164,6 +1164,60 @@ const obj = {
11641164

11651165
**Client mode**: Same as step mode — the getter body is hoisted for `stepId` assignment, original getter preserved.
11661166

1167+
### Private member dead code elimination
1168+
1169+
In workflow mode, after stripping `"use step"` methods and getters from a class body, the plugin eliminates private class members that are no longer referenced by any remaining (non-private) member. This applies to both:
1170+
1171+
- **JS native private members**: `#field`, `#method()` (`ClassMember::PrivateMethod`, `ClassMember::PrivateProp`)
1172+
- **TypeScript `private` members**: `private field`, `private method()` (`ClassMethod`/`ClassProp` with `accessibility: Private`)
1173+
1174+
The algorithm is iterative: references are first collected from all public members, then the referenced set is expanded by scanning surviving private members' bodies for cross-references, repeating until the set stabilizes. This enables cascading elimination — a private field only referenced by a private method that is itself unreferenced will also be removed.
1175+
1176+
Input:
1177+
```typescript
1178+
export class Run {
1179+
static [WORKFLOW_SERIALIZE](instance) { return { id: instance.id }; }
1180+
static [WORKFLOW_DESERIALIZE](data) { return new Run(data.id); }
1181+
1182+
id: string;
1183+
private encryptionKeyPromise: Promise<any> | null = null;
1184+
1185+
private async getEncryptionKey() {
1186+
if (!this.encryptionKeyPromise) {
1187+
this.encryptionKeyPromise = importKey(this.id);
1188+
}
1189+
return this.encryptionKeyPromise;
1190+
}
1191+
1192+
constructor(id: string) { this.id = id; }
1193+
1194+
get value(): Promise<any> {
1195+
'use step';
1196+
return this.getEncryptionKey().then(() => getWorld().get(this.id));
1197+
}
1198+
}
1199+
```
1200+
1201+
Workflow output:
1202+
```javascript
1203+
export class Run {
1204+
static [WORKFLOW_SERIALIZE](instance) { return { id: instance.id }; }
1205+
static [WORKFLOW_DESERIALIZE](data) { return new Run(data.id); }
1206+
id;
1207+
// private encryptionKeyPromise — ELIMINATED (only referenced by getEncryptionKey)
1208+
// private getEncryptionKey() — ELIMINATED (only referenced by stripped getter)
1209+
constructor(id) { this.id = id; }
1210+
}
1211+
// getter replaced with step proxy
1212+
var __step_Run$value = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step_id");
1213+
Object.defineProperty(Run.prototype, "value", {
1214+
get() { return __step_Run$value.call(this); },
1215+
configurable: true, enumerable: false
1216+
});
1217+
```
1218+
1219+
This optimization is critical for SDK classes like `Run` where private helper methods reference Node.js-only imports (encryption, world access, etc.) — eliminating them allows the downstream module-level DCE to also remove those imports from the workflow bundle.
1220+
11671221
---
11681222

11691223
## Parameter Handling

packages/swc-plugin-workflow/transform/src/lib.rs

Lines changed: 206 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ use swc_core::{
66
common::{errors::HANDLER, SyntaxContext, DUMMY_SP},
77
ecma::{
88
ast::*,
9-
visit::{noop_visit_mut_type, VisitMut, VisitMutWith},
9+
visit::{noop_visit_mut_type, noop_visit_type, Visit, VisitMut, VisitMutWith, VisitWith},
1010
},
1111
};
1212

@@ -491,6 +491,198 @@ impl TryFrom<&Expr> for Name {
491491
}
492492
}
493493

494+
/// Collects all member names referenced within an AST subtree via
495+
/// `this.foo`, `this.#foo`, or `obj.foo` (when `foo` is a known
496+
/// TS-private name) patterns. Used after stripping `"use step"` methods
497+
/// in workflow mode to determine which private class members are still
498+
/// referenced by the remaining body, so unreferenced ones can be
499+
/// dead-code-eliminated.
500+
///
501+
/// Handles both:
502+
/// - JS native private members (`#field`, `#method()`) — stored with `#`
503+
/// prefix to avoid collisions with TS private members of the same name
504+
/// - TypeScript `private` members — stored without prefix; detected via
505+
/// `this.foo` and also `obj.foo` when `foo` is a known TS-private name
506+
/// (to handle same-class access patterns like `static compare(a, b) {
507+
/// return a.x - b.x }`)
508+
struct ClassMemberRefCollector {
509+
/// All member names referenced. JS native private names are prefixed
510+
/// with `#` (e.g. `"#foo"`), TS private names are unprefixed (`"foo"`).
511+
referenced: HashSet<String>,
512+
/// Known TS-private member names in the current class, so that `a.foo`
513+
/// accesses (not just `this.foo`) are recognized as references.
514+
ts_private_names: HashSet<String>,
515+
}
516+
517+
impl ClassMemberRefCollector {
518+
fn new(ts_private_names: HashSet<String>) -> Self {
519+
Self {
520+
referenced: HashSet::new(),
521+
ts_private_names,
522+
}
523+
}
524+
525+
/// Collects all member names transitively referenced by non-private
526+
/// (public) members of the class. Private members that are only
527+
/// referenced by other private members (which are themselves
528+
/// unreferenced) are NOT included, enabling cascading elimination.
529+
///
530+
/// Algorithm: seed the referenced set from public members, then
531+
/// iteratively expand by adding references from surviving private
532+
/// members until the set stabilizes.
533+
fn collect_from_class_body(body: &[ClassMember]) -> HashSet<String> {
534+
// Build the set of known TS-private names for the collector
535+
let ts_private_names: HashSet<String> = body
536+
.iter()
537+
.filter_map(|m| match m {
538+
ClassMember::Method(m) if m.accessibility == Some(Accessibility::Private) => {
539+
match &m.key {
540+
PropName::Ident(i) => Some(i.sym.to_string()),
541+
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
542+
_ => None,
543+
}
544+
}
545+
ClassMember::ClassProp(p) if p.accessibility == Some(Accessibility::Private) => {
546+
match &p.key {
547+
PropName::Ident(i) => Some(i.sym.to_string()),
548+
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
549+
_ => None,
550+
}
551+
}
552+
_ => None,
553+
})
554+
.collect();
555+
556+
// Phase 1: collect references from all non-private members
557+
let mut collector = Self::new(ts_private_names);
558+
for member in body {
559+
if !Self::is_private_member(member) {
560+
member.visit_with(&mut collector);
561+
}
562+
}
563+
564+
// Phase 2: iteratively expand — if a private member is referenced,
565+
// its body may reference other private members
566+
loop {
567+
let prev_len = collector.referenced.len();
568+
for member in body {
569+
if let Some(name) = Self::private_member_name(member) {
570+
if collector.referenced.contains(&name) {
571+
// This private member survived; scan its body for
572+
// references to other private members
573+
Self::visit_member_body(member, &mut collector);
574+
}
575+
}
576+
}
577+
if collector.referenced.len() == prev_len {
578+
break; // fixed point reached
579+
}
580+
}
581+
582+
collector.referenced
583+
}
584+
585+
/// Visit the body/initializer of a class member for reference collection.
586+
fn visit_member_body(member: &ClassMember, collector: &mut Self) {
587+
match member {
588+
ClassMember::PrivateMethod(m) => {
589+
if let Some(body) = &m.function.body {
590+
body.visit_with(collector);
591+
}
592+
}
593+
ClassMember::PrivateProp(p) => {
594+
if let Some(value) = &p.value {
595+
value.visit_with(collector);
596+
}
597+
}
598+
ClassMember::Method(m) => {
599+
if let Some(body) = &m.function.body {
600+
body.visit_with(collector);
601+
}
602+
}
603+
ClassMember::ClassProp(p) => {
604+
if let Some(value) = &p.value {
605+
value.visit_with(collector);
606+
}
607+
}
608+
_ => {}
609+
}
610+
}
611+
612+
/// Returns true if the member is a private member (JS native or TS).
613+
fn is_private_member(member: &ClassMember) -> bool {
614+
matches!(
615+
member,
616+
ClassMember::PrivateMethod(_) | ClassMember::PrivateProp(_)
617+
) || matches!(member, ClassMember::Method(m) if m.accessibility == Some(Accessibility::Private))
618+
|| matches!(member, ClassMember::ClassProp(p) if p.accessibility == Some(Accessibility::Private))
619+
}
620+
621+
/// Returns the canonical name of a private member. JS native private
622+
/// names are prefixed with `#` to avoid collisions with TS private
623+
/// members of the same name.
624+
fn private_member_name(member: &ClassMember) -> Option<String> {
625+
match member {
626+
ClassMember::PrivateMethod(m) => Some(format!("#{}", m.key.name)),
627+
ClassMember::PrivateProp(p) => Some(format!("#{}", p.key.name)),
628+
ClassMember::Method(m) if m.accessibility == Some(Accessibility::Private) => {
629+
match &m.key {
630+
PropName::Ident(i) => Some(i.sym.to_string()),
631+
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
632+
_ => None,
633+
}
634+
}
635+
ClassMember::ClassProp(p) if p.accessibility == Some(Accessibility::Private) => {
636+
match &p.key {
637+
PropName::Ident(i) => Some(i.sym.to_string()),
638+
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
639+
_ => None,
640+
}
641+
}
642+
_ => None,
643+
}
644+
}
645+
646+
/// Removes unreferenced private class members from a class body.
647+
/// Call after stripping `"use step"` methods in workflow mode.
648+
fn retain_referenced_private_members(body: &mut Vec<ClassMember>) {
649+
let referenced = Self::collect_from_class_body(body);
650+
body.retain(|member| {
651+
if let Some(name) = Self::private_member_name(member) {
652+
referenced.contains(&name)
653+
} else {
654+
true
655+
}
656+
});
657+
}
658+
}
659+
660+
impl Visit for ClassMemberRefCollector {
661+
noop_visit_type!();
662+
663+
fn visit_member_expr(&mut self, expr: &MemberExpr) {
664+
match &expr.prop {
665+
// Native JS private: `this.#foo` — stored as `#foo`
666+
MemberProp::PrivateName(name) => {
667+
self.referenced.insert(format!("#{}", name.name));
668+
}
669+
// TS private or any ident member access. Track `this.foo` as
670+
// before, and also track `obj.foo` when `foo` is a known
671+
// TS-private member of the current class so same-class
672+
// accesses like `a.x` / `b.x` are not missed.
673+
MemberProp::Ident(ident) => {
674+
let name = ident.sym.to_string();
675+
if matches!(&*expr.obj, Expr::This(_)) || self.ts_private_names.contains(&name) {
676+
self.referenced.insert(name);
677+
}
678+
}
679+
_ => {}
680+
}
681+
// Continue visiting children, including computed property expressions
682+
expr.visit_children_with(self);
683+
}
684+
}
685+
494686
// Visitor to collect closure variables from a nested step function
495687
struct ClosureVariableCollector {
496688
closure_vars: HashSet<String>,
@@ -8194,6 +8386,14 @@ impl VisitMut for StepTransform {
81948386
}
81958387
true
81968388
});
8389+
8390+
// After stripping "use step" methods, eliminate private class
8391+
// members (both JS native `#field`/`#method()` and TypeScript
8392+
// `private field`/`private method()`) that are no longer
8393+
// referenced by any remaining member.
8394+
ClassMemberRefCollector::retain_referenced_private_members(
8395+
&mut class_decl.class.body,
8396+
);
81978397
}
81988398
}
81998399

@@ -8312,6 +8512,11 @@ impl VisitMut for StepTransform {
83128512
}
83138513
true
83148514
});
8515+
8516+
// Dead-code-eliminate unreferenced private members
8517+
ClassMemberRefCollector::retain_referenced_private_members(
8518+
&mut class_expr.class.body,
8519+
);
83158520
}
83168521
}
83178522

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
2+
import { getWorld } from './world.js';
3+
import { importKey } from './encryption.js';
4+
5+
export class Run {
6+
static [WORKFLOW_SERIALIZE](instance) {
7+
return { id: instance.id };
8+
}
9+
10+
static [WORKFLOW_DESERIALIZE](data) {
11+
return new Run(data.id);
12+
}
13+
14+
// Public field — should be kept
15+
id;
16+
17+
// Native private field — only referenced by #getEncryptionKey
18+
#encryptionKeyPromise = null;
19+
20+
// Native private field — referenced by toString (public), should survive
21+
#label = 'run';
22+
23+
// Native private method — only called by stripped step methods
24+
async #getEncryptionKey() {
25+
if (!this.#encryptionKeyPromise) {
26+
this.#encryptionKeyPromise = importKey(this.id);
27+
}
28+
return this.#encryptionKeyPromise;
29+
}
30+
31+
constructor(id) {
32+
this.id = id;
33+
}
34+
35+
get value() {
36+
'use step';
37+
return this.#getEncryptionKey().then(() => getWorld().get(this.id));
38+
}
39+
40+
async cancel() {
41+
'use step';
42+
const key = await this.#getEncryptionKey();
43+
await getWorld().cancel(this.id, key);
44+
}
45+
46+
toString() {
47+
return `Run(${this.id}, ${this.#label})`;
48+
}
49+
}

0 commit comments

Comments
 (0)