Skip to content

Commit f18b317

Browse files
committed
feat: transform private method invocations in forward and reverse passes
Extend the private method transform to also handle MemberExpression nodes with PrivateName properties (e.g. this.#foo()), not just ClassPrivateMethod definitions. This ensures the intermediate AST is fully consistent—both definitions and call sites use the prefixed public name—so intermediate plugins can process the code correctly. Made-with: Cursor
1 parent 380c24d commit f18b317

File tree

3 files changed

+175
-1
lines changed

3 files changed

+175
-1
lines changed

packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -660,4 +660,132 @@ describe('private method transform validation', () => {
660660
/Private fields are not currently supported/
661661
);
662662
});
663+
664+
test('forward-only output transforms call sites to prefixed names', () => {
665+
const source = `
666+
import { LightningElement } from 'lwc';
667+
export default class Test extends LightningElement {
668+
#doWork(x) { return x * 2; }
669+
connectedCallback() {
670+
const result = this.#doWork(21);
671+
console.log(result);
672+
}
673+
}
674+
`;
675+
676+
const result = transformForwardOnly(source);
677+
const code = result.code!;
678+
expect(code).toContain('this.__lwc_component_class_internal_private_doWork(21)');
679+
expect(code).not.toContain('this.#doWork');
680+
});
681+
682+
test('forward references in call sites are transformed', () => {
683+
const source = `
684+
import { LightningElement } from 'lwc';
685+
export default class Test extends LightningElement {
686+
connectedCallback() {
687+
return this.#helper();
688+
}
689+
#helper() { return 42; }
690+
}
691+
`;
692+
693+
const result = transformForwardOnly(source);
694+
const code = result.code!;
695+
expect(code).toContain('this.__lwc_component_class_internal_private_helper()');
696+
expect(code).not.toContain('this.#helper');
697+
698+
const roundTrip = transformWithFullPipeline(source);
699+
expect(roundTrip.code).toContain('this.#helper()');
700+
expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_');
701+
});
702+
703+
test('multiple invocations of the same private method are all transformed', () => {
704+
const source = `
705+
import { LightningElement } from 'lwc';
706+
export default class Test extends LightningElement {
707+
#compute(x) { return x * 2; }
708+
run() {
709+
const a = this.#compute(1);
710+
const b = this.#compute(2);
711+
const c = this.#compute(3);
712+
return a + b + c;
713+
}
714+
}
715+
`;
716+
717+
const result = transformForwardOnly(source);
718+
const code = result.code!;
719+
const matches = code.match(/__lwc_component_class_internal_private_compute/g);
720+
// 1 definition + 3 call sites = 4 occurrences
721+
expect(matches).toHaveLength(4);
722+
723+
const roundTrip = transformWithFullPipeline(source);
724+
expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_');
725+
const privateMatches = roundTrip.code!.match(/#compute/g);
726+
expect(privateMatches).toHaveLength(4);
727+
});
728+
729+
test('self-referencing private method round-trips', () => {
730+
const source = `
731+
import { LightningElement } from 'lwc';
732+
export default class Test extends LightningElement {
733+
#recursive(n) {
734+
if (n <= 0) return 0;
735+
return n + this.#recursive(n - 1);
736+
}
737+
}
738+
`;
739+
740+
const result = transformForwardOnly(source);
741+
const code = result.code!;
742+
expect(code).toContain('this.__lwc_component_class_internal_private_recursive(n - 1)');
743+
744+
const roundTrip = transformWithFullPipeline(source);
745+
expect(roundTrip.code).toContain('this.#recursive(n - 1)');
746+
expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_');
747+
});
748+
749+
test('private method reference without call round-trips', () => {
750+
const source = `
751+
import { LightningElement } from 'lwc';
752+
export default class Test extends LightningElement {
753+
#handler() { return 42; }
754+
connectedCallback() {
755+
const fn = this.#handler;
756+
setTimeout(this.#handler, 100);
757+
}
758+
}
759+
`;
760+
761+
const result = transformForwardOnly(source);
762+
const code = result.code!;
763+
expect(code).toContain('this.__lwc_component_class_internal_private_handler;');
764+
expect(code).toContain('this.__lwc_component_class_internal_private_handler, 100');
765+
expect(code).not.toContain('this.#handler');
766+
767+
const roundTrip = transformWithFullPipeline(source);
768+
expect(roundTrip.code).toContain('this.#handler;');
769+
expect(roundTrip.code).toContain('this.#handler, 100');
770+
expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_');
771+
});
772+
773+
test('cross-method private call sites in forward-only output', () => {
774+
const source = `
775+
import { LightningElement } from 'lwc';
776+
export default class Test extends LightningElement {
777+
#helper() { return 42; }
778+
#caller() {
779+
return this.#helper() + 1;
780+
}
781+
}
782+
`;
783+
784+
const result = transformForwardOnly(source);
785+
const code = result.code!;
786+
expect(code).toContain('this.__lwc_component_class_internal_private_helper()');
787+
expect(code).toContain('__lwc_component_class_internal_private_caller');
788+
expect(code).not.toContain('#helper');
789+
expect(code).not.toContain('#caller');
790+
});
663791
});

packages/@lwc/babel-plugin-component/src/private-method-transform.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,20 @@ export default function privateMethodTransform({
3636
Program(path: NodePath<types.Program>, state: LwcBabelPluginPass) {
3737
const transformedNames = new Set<string>();
3838

39+
// Phase 1: Collect base names of all private methods (kind: 'method')
40+
// so that Phase 2 can transform invocations even for forward references
41+
// (call site visited before the method definition).
42+
const privateMethodBaseNames = new Set<string>();
43+
path.traverse({
44+
ClassPrivateMethod(methodPath: NodePath<types.ClassPrivateMethod>) {
45+
const key = methodPath.get('key');
46+
if (key.isPrivateName() && methodPath.node.kind === METHOD_KIND) {
47+
privateMethodBaseNames.add(key.node.id.name);
48+
}
49+
},
50+
});
51+
52+
// Phase 2: Transform definitions and invocations
3953
path.traverse(
4054
{
4155
ClassPrivateMethod(
@@ -98,6 +112,19 @@ export default function privateMethodTransform({
98112
transformedNames.add(transformedName);
99113
},
100114

115+
MemberExpression(memberPath: NodePath<types.MemberExpression>) {
116+
const property = memberPath.node.property;
117+
if (t.isPrivateName(property)) {
118+
const baseName = (property as types.PrivateName).id.name;
119+
if (privateMethodBaseNames.has(baseName)) {
120+
const prefixedName = `${PRIVATE_METHOD_PREFIX}${baseName}`;
121+
memberPath
122+
.get('property')
123+
.replaceWith(t.identifier(prefixedName));
124+
}
125+
}
126+
},
127+
101128
ClassPrivateProperty(
102129
propPath: NodePath<types.ClassPrivateProperty>,
103130
propState: LwcBabelPluginPass

packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ import type { types, NodePath, PluginObj } from '@babel/core';
1212

1313
/**
1414
* Standalone Babel plugin that reverses the private method transformation by converting
15-
* methods with prefix {@link PRIVATE_METHOD_PREFIX} back to ClassPrivateMethod nodes.
15+
* methods with prefix {@link PRIVATE_METHOD_PREFIX} back to ClassPrivateMethod nodes,
16+
* and restoring prefixed MemberExpression properties back to PrivateName nodes.
1617
*
1718
* This must be registered AFTER @babel/plugin-transform-class-properties so that
1819
* class properties are fully transformed before private methods are restored.
@@ -80,6 +81,24 @@ export default function reversePrivateMethodTransform({
8081
}
8182
},
8283

84+
MemberExpression(path: NodePath<types.MemberExpression>, state: LwcBabelPluginPass) {
85+
const property = path.node.property;
86+
if (!t.isIdentifier(property) || !property.name.startsWith(PRIVATE_METHOD_PREFIX)) {
87+
return;
88+
}
89+
90+
const forwardTransformedNames: Set<string> | undefined = (
91+
state.file.metadata as any
92+
)[PRIVATE_METHOD_METADATA_KEY];
93+
94+
if (!forwardTransformedNames || !forwardTransformedNames.has(property.name)) {
95+
return;
96+
}
97+
98+
const originalName = property.name.replace(PRIVATE_METHOD_PREFIX, '');
99+
path.get('property').replaceWith(t.privateName(t.identifier(originalName)));
100+
},
101+
83102
// After all nodes have been visited, verify that every method the forward transform
84103
// renamed was also restored by the reverse transform. A mismatch here means an
85104
// intermediate plugin (e.g. @babel/plugin-transform-class-properties) removed or

0 commit comments

Comments
 (0)