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

Eliminate unreferenced private class members in workflow mode after `"use step"` stripping
54 changes: 54 additions & 0 deletions packages/swc-plugin-workflow/spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -1164,6 +1164,60 @@ const obj = {

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

### Private member dead code elimination

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:

- **JS native private members**: `#field`, `#method()` (`ClassMember::PrivateMethod`, `ClassMember::PrivateProp`)
- **TypeScript `private` members**: `private field`, `private method()` (`ClassMethod`/`ClassProp` with `accessibility: Private`)

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.

Input:
```typescript
export class Run {
static [WORKFLOW_SERIALIZE](instance) { return { id: instance.id }; }
static [WORKFLOW_DESERIALIZE](data) { return new Run(data.id); }

id: string;
private encryptionKeyPromise: Promise<any> | null = null;

private async getEncryptionKey() {
if (!this.encryptionKeyPromise) {
this.encryptionKeyPromise = importKey(this.id);
}
return this.encryptionKeyPromise;
}

constructor(id: string) { this.id = id; }

get value(): Promise<any> {
'use step';
return this.getEncryptionKey().then(() => getWorld().get(this.id));
}
}
```

Workflow output:
```javascript
export class Run {
static [WORKFLOW_SERIALIZE](instance) { return { id: instance.id }; }
static [WORKFLOW_DESERIALIZE](data) { return new Run(data.id); }
id;
// private encryptionKeyPromise — ELIMINATED (only referenced by getEncryptionKey)
// private getEncryptionKey() — ELIMINATED (only referenced by stripped getter)
constructor(id) { this.id = id; }
}
// getter replaced with step proxy
var __step_Run$value = globalThis[Symbol.for("WORKFLOW_USE_STEP")]("step_id");
Object.defineProperty(Run.prototype, "value", {
get() { return __step_Run$value.call(this); },
configurable: true, enumerable: false
});
```

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.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Nit

This section explains the algorithm well. If you want to reduce future issue reports, a short explicit note on unsupported patterns (for example computed this[...] access to private fields) could help set expectations.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good suggestion. Computed access like this[name] where name happens to be a private member name won't be tracked, which could lead to incorrect elimination. In practice this pattern is very rare for private members (especially in SDK code like Run), but worth noting if we want to document limitations.


---

## Parameter Handling
Expand Down
207 changes: 206 additions & 1 deletion packages/swc-plugin-workflow/transform/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use swc_core::{
common::{errors::HANDLER, SyntaxContext, DUMMY_SP},
ecma::{
ast::*,
visit::{noop_visit_mut_type, VisitMut, VisitMutWith},
visit::{noop_visit_mut_type, noop_visit_type, Visit, VisitMut, VisitMutWith, VisitWith},
},
};

Expand Down Expand Up @@ -491,6 +491,198 @@ impl TryFrom<&Expr> for Name {
}
}

/// Collects all member names referenced within an AST subtree via
/// `this.foo`, `this.#foo`, or `obj.foo` (when `foo` is a known
/// TS-private name) patterns. Used after stripping `"use step"` methods
/// in workflow mode to determine which private class members are still
/// referenced by the remaining body, so unreferenced ones can be
/// dead-code-eliminated.
///
/// Handles both:
/// - JS native private members (`#field`, `#method()`) — stored with `#`
/// prefix to avoid collisions with TS private members of the same name
/// - TypeScript `private` members — stored without prefix; detected via
/// `this.foo` and also `obj.foo` when `foo` is a known TS-private name
/// (to handle same-class access patterns like `static compare(a, b) {
/// return a.x - b.x }`)
struct ClassMemberRefCollector {
/// All member names referenced. JS native private names are prefixed
/// with `#` (e.g. `"#foo"`), TS private names are unprefixed (`"foo"`).
referenced: HashSet<String>,
/// Known TS-private member names in the current class, so that `a.foo`
/// accesses (not just `this.foo`) are recognized as references.
ts_private_names: HashSet<String>,
}

impl ClassMemberRefCollector {
fn new(ts_private_names: HashSet<String>) -> Self {
Self {
referenced: HashSet::new(),
ts_private_names,
}
}

/// Collects all member names transitively referenced by non-private
/// (public) members of the class. Private members that are only
/// referenced by other private members (which are themselves
/// unreferenced) are NOT included, enabling cascading elimination.
///
/// Algorithm: seed the referenced set from public members, then
/// iteratively expand by adding references from surviving private
/// members until the set stabilizes.
fn collect_from_class_body(body: &[ClassMember]) -> HashSet<String> {
// Build the set of known TS-private names for the collector
let ts_private_names: HashSet<String> = body
.iter()
.filter_map(|m| match m {
ClassMember::Method(m) if m.accessibility == Some(Accessibility::Private) => {
match &m.key {
PropName::Ident(i) => Some(i.sym.to_string()),
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
_ => None,
}
}
ClassMember::ClassProp(p) if p.accessibility == Some(Accessibility::Private) => {
match &p.key {
PropName::Ident(i) => Some(i.sym.to_string()),
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
_ => None,
}
}
_ => None,
})
.collect();

// Phase 1: collect references from all non-private members
let mut collector = Self::new(ts_private_names);
for member in body {
if !Self::is_private_member(member) {
member.visit_with(&mut collector);
}
}

// Phase 2: iteratively expand — if a private member is referenced,
// its body may reference other private members
loop {
let prev_len = collector.referenced.len();
for member in body {
if let Some(name) = Self::private_member_name(member) {
if collector.referenced.contains(&name) {
// This private member survived; scan its body for
// references to other private members
Self::visit_member_body(member, &mut collector);
}
}
}
if collector.referenced.len() == prev_len {
break; // fixed point reached
}
}

collector.referenced
}

/// Visit the body/initializer of a class member for reference collection.
fn visit_member_body(member: &ClassMember, collector: &mut Self) {
match member {
ClassMember::PrivateMethod(m) => {
if let Some(body) = &m.function.body {
body.visit_with(collector);
}
}
ClassMember::PrivateProp(p) => {
if let Some(value) = &p.value {
value.visit_with(collector);
}
}
ClassMember::Method(m) => {
if let Some(body) = &m.function.body {
body.visit_with(collector);
}
}
ClassMember::ClassProp(p) => {
if let Some(value) = &p.value {
value.visit_with(collector);
}
}
_ => {}
}
}

/// Returns true if the member is a private member (JS native or TS).
fn is_private_member(member: &ClassMember) -> bool {
matches!(
member,
ClassMember::PrivateMethod(_) | ClassMember::PrivateProp(_)
) || matches!(member, ClassMember::Method(m) if m.accessibility == Some(Accessibility::Private))
|| matches!(member, ClassMember::ClassProp(p) if p.accessibility == Some(Accessibility::Private))
}

/// Returns the canonical name of a private member. JS native private
/// names are prefixed with `#` to avoid collisions with TS private
/// members of the same name.
fn private_member_name(member: &ClassMember) -> Option<String> {
match member {
ClassMember::PrivateMethod(m) => Some(format!("#{}", m.key.name)),
ClassMember::PrivateProp(p) => Some(format!("#{}", p.key.name)),
ClassMember::Method(m) if m.accessibility == Some(Accessibility::Private) => {
match &m.key {
PropName::Ident(i) => Some(i.sym.to_string()),
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
_ => None,
}
}
ClassMember::ClassProp(p) if p.accessibility == Some(Accessibility::Private) => {
match &p.key {
PropName::Ident(i) => Some(i.sym.to_string()),
PropName::Str(s) => Some(s.value.to_string_lossy().to_string()),
_ => None,
}
}
_ => None,
}
}

/// Removes unreferenced private class members from a class body.
/// Call after stripping `"use step"` methods in workflow mode.
fn retain_referenced_private_members(body: &mut Vec<ClassMember>) {
let referenced = Self::collect_from_class_body(body);
body.retain(|member| {
if let Some(name) = Self::private_member_name(member) {
referenced.contains(&name)
} else {
true
}
});
}
}

impl Visit for ClassMemberRefCollector {
noop_visit_type!();

fn visit_member_expr(&mut self, expr: &MemberExpr) {
match &expr.prop {
// Native JS private: `this.#foo` — stored as `#foo`
MemberProp::PrivateName(name) => {
self.referenced.insert(format!("#{}", name.name));
}
// TS private or any ident member access. Track `this.foo` as
// before, and also track `obj.foo` when `foo` is a known
// TS-private member of the current class so same-class
// accesses like `a.x` / `b.x` are not missed.
MemberProp::Ident(ident) => {
let name = ident.sym.to_string();
if matches!(&*expr.obj, Expr::This(_)) || self.ts_private_names.contains(&name) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI Review: Note

For MemberProp::Ident, any this.foo access records foo in referenced, not only TS-private members. Retain still keys off private_member_name, so this is mostly extra churn in the set rather than incorrect retention, but it is slightly less precise than filtering to private names only.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct - the collector is conservative: it records all this.foo accesses, not just those matching known private names. This is intentional for simplicity. The extra entries in the referenced set are harmless since retain only removes members that private_member_name() identifies as private. Public members are always kept regardless of what's in the set.

self.referenced.insert(name);
}
Comment on lines +664 to +677
Copy link

Copilot AI Apr 9, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Native private #foo and public/TS member foo are both stored as the same string ("foo"). If a class contains both foo and #foo, a this.foo access will incorrectly mark #foo as referenced, preventing intended elimination. Consider encoding JS-private names distinctly (e.g. prefix with # or store an enum) so #foo and foo don’t collide.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. JS native private names are now stored with a # prefix (e.g. "#foo") while TS private names remain unprefixed ("foo"). private_member_name() returns the prefixed form for PrivateMethod/PrivateProp, and visit_member_expr inserts format!("#{}", name) for MemberProp::PrivateName.

}
_ => {}
}
// Continue visiting children, including computed property expressions
expr.visit_children_with(self);
}
}

// Visitor to collect closure variables from a nested step function
struct ClosureVariableCollector {
closure_vars: HashSet<String>,
Expand Down Expand Up @@ -8194,6 +8386,14 @@ impl VisitMut for StepTransform {
}
true
});

// After stripping "use step" methods, eliminate private class
// members (both JS native `#field`/`#method()` and TypeScript
// `private field`/`private method()`) that are no longer
// referenced by any remaining member.
ClassMemberRefCollector::retain_referenced_private_members(
&mut class_decl.class.body,
);
}
}

Expand Down Expand Up @@ -8312,6 +8512,11 @@ impl VisitMut for StepTransform {
}
true
});

// Dead-code-eliminate unreferenced private members
ClassMemberRefCollector::retain_referenced_private_members(
&mut class_expr.class.body,
);
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import { WORKFLOW_SERIALIZE, WORKFLOW_DESERIALIZE } from '@workflow/serde';
import { getWorld } from './world.js';
import { importKey } from './encryption.js';

export class Run {
static [WORKFLOW_SERIALIZE](instance) {
return { id: instance.id };
}

static [WORKFLOW_DESERIALIZE](data) {
return new Run(data.id);
}

// Public field — should be kept
id;

// Native private field — only referenced by #getEncryptionKey
#encryptionKeyPromise = null;

// Native private field — referenced by toString (public), should survive
#label = 'run';

// Native private method — only called by stripped step methods
async #getEncryptionKey() {
if (!this.#encryptionKeyPromise) {
this.#encryptionKeyPromise = importKey(this.id);
}
return this.#encryptionKeyPromise;
}

constructor(id) {
this.id = id;
}

get value() {
'use step';
return this.#getEncryptionKey().then(() => getWorld().get(this.id));
}

async cancel() {
'use step';
const key = await this.#getEncryptionKey();
await getWorld().cancel(this.id, key);
}

toString() {
return `Run(${this.id}, ${this.#label})`;
}
}
Loading
Loading