Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
bea64c5
feat: first attempt
a-chabot Feb 13, 2026
f443394
fix: linter fix
a-chabot Feb 13, 2026
2d9ad36
fix: update note
a-chabot Feb 13, 2026
999c686
fix: cleaning up lint errors to be able to run tests
a-chabot Feb 23, 2026
6b1f599
fix: bump "Next error code" comment to 1213
a-chabot Feb 23, 2026
4b1e630
feat: add round-trip validation for private method transforms
a-chabot Feb 26, 2026
8dcb515
test: add full-cycle and intermediate disruption tests for private me…
a-chabot Feb 26, 2026
9b1e109
refactor: make PRIVATE_METHOD_NAME_COLLISION error message mention re…
a-chabot Mar 2, 2026
61ad8b5
chore: revert playground counter.js to master
a-chabot Mar 2, 2026
76da7ac
refactor: hoist methodKind constant to module scope
a-chabot Mar 2, 2026
fcf9c25
refactor: extract shared copyMethodMetadata helper for private method…
a-chabot Mar 2, 2026
c409872
refactor: make forward and reverse private method transforms independ…
a-chabot Mar 2, 2026
591bc67
refactor: move non-null assertion to transformSync helpers in tests
a-chabot Mar 2, 2026
a2d3e4b
refactor: consolidate fragmented toContain assertions in tests
a-chabot Mar 2, 2026
7e51945
test: add test cases for private fields passing through unchanged
a-chabot Mar 2, 2026
d4f93e4
test: add test cases for rogue #privateNames not sneaking through
a-chabot Mar 2, 2026
f284247
feat: add unsupported private member errors and fixture e2e tests
a-chabot Mar 2, 2026
380c24d
chore: retrigger CI
a-chabot Mar 2, 2026
f18b317
feat: transform private method invocations in forward and reverse passes
a-chabot Mar 4, 2026
3461010
feat: gate private method transform behind enablePrivateMethods compi…
a-chabot Mar 4, 2026
2ba8959
feat: thread enablePrivateMethods through rollup plugin and enable in…
a-chabot Mar 4, 2026
d38ae20
chore: revert playground counter and rollup config changes
a-chabot Mar 4, 2026
c59f08a
Merge pull request #1 from a-chabot/a-chabot/private-methods-compiler…
a-chabot Mar 4, 2026
446b34a
test: move private-methods fixture tests to dedicated branch
a-chabot Mar 4, 2026
197d2ff
test: remove inline tests now covered by fixture tests
a-chabot Mar 4, 2026
f024518
test: remove inline tests now covered by fixture tests
a-chabot Mar 4, 2026
4d60549
test: replace per-test comments with top-of-file blurb
a-chabot Mar 4, 2026
500c3b3
test: add cross-class private method access inline tests
a-chabot Mar 4, 2026
e49f1b3
docs: add enablePrivateMethods to rollup plugin README
a-chabot Mar 4, 2026
2820b37
docs: add enablePrivateMethods to compiler README
a-chabot Mar 5, 2026
cb1870d
test: skip empty private-methods fixtures suite until tests are merged
a-chabot Mar 5, 2026
c2a8c07
Merge remote-tracking branch 'origin/master' into a-chabot/private-me…
a-chabot Mar 6, 2026
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

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/@lwc/babel-plugin-component/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ const TEMPLATE_KEY = 'tmpl';
const COMPONENT_NAME_KEY = 'sel';
const API_VERSION_KEY = 'apiVersion';
const COMPONENT_CLASS_ID = '__lwc_component_class_internal';
const PRIVATE_METHOD_PREFIX = '__lwc_component_class_internal_private_';
const PRIVATE_METHOD_METADATA_KEY = '__lwcTransformedPrivateMethods';
const SYNTHETIC_ELEMENT_INTERNALS_KEY = 'enableSyntheticElementInternals';
const COMPONENT_FEATURE_FLAG_KEY = 'componentFeatureFlag';

Expand All @@ -48,6 +50,8 @@ export {
COMPONENT_NAME_KEY,
API_VERSION_KEY,
COMPONENT_CLASS_ID,
PRIVATE_METHOD_PREFIX,
PRIVATE_METHOD_METADATA_KEY,
SYNTHETIC_ELEMENT_INTERNALS_KEY,
COMPONENT_FEATURE_FLAG_KEY,
};
23 changes: 23 additions & 0 deletions packages/@lwc/babel-plugin-component/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,26 @@ import {
import dynamicImports from './dynamic-imports';
import scopeCssImports from './scope-css-imports';
import compilerVersionNumber from './compiler-version-number';
import privateMethodTransform from './private-method-transform';
import reversePrivateMethodTransform from './reverse-private-method-transform';
import { getEngineImportSpecifiers } from './utils';
import type { BabelAPI, LwcBabelPluginPass } from './types';
import type { PluginObj } from '@babel/core';

// This is useful for consumers of this package to define their options
export type { LwcBabelPluginOptions } from './types';

/**
* Standalone Babel plugin that reverses the private method transformation.
* This must be registered AFTER @babel/plugin-transform-class-properties so that
* class properties are fully transformed before private methods are restored.
*/
export function LwcReversePrivateMethodTransform(api: BabelAPI): PluginObj<LwcBabelPluginPass> {
return {
visitor: reversePrivateMethodTransform(api),
};
}

/**
* The transform is done in 2 passes:
* - First, apply in a single AST traversal the decorators and the component transformation.
Expand All @@ -32,6 +45,7 @@ export default function LwcClassTransform(api: BabelAPI): PluginObj<LwcBabelPlug
const { Class: transformDecorators } = decorators(api);
const { Import: transformDynamicImports } = dynamicImports();
const { ClassBody: addCompilerVersionNumber } = compilerVersionNumber(api);
const { Program: transformPrivateMethods } = privateMethodTransform(api);

return {
manipulateOptions(opts, parserOpts) {
Expand All @@ -53,6 +67,15 @@ export default function LwcClassTransform(api: BabelAPI): PluginObj<LwcBabelPlug

// Add ?scoped=true to *.scoped.css imports
scopeCssImports(api, path);

// Transform private methods BEFORE any other plugin processes them
if (
transformPrivateMethods &&
typeof transformPrivateMethods === 'object' &&
'enter' in transformPrivateMethods
) {
(transformPrivateMethods as any).enter(path, state);
}
},
exit(path) {
const engineImportSpecifiers = getEngineImportSpecifiers(path);
Expand Down
110 changes: 110 additions & 0 deletions packages/@lwc/babel-plugin-component/src/private-method-transform.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { DecoratorErrors } from '@lwc/errors';
import { PRIVATE_METHOD_PREFIX, PRIVATE_METHOD_METADATA_KEY } from './constants';
import { handleError } from './utils';
import type { BabelAPI, LwcBabelPluginPass } from './types';
import type { NodePath, Visitor } from '@babel/core';
import type { types } from '@babel/core';

/**
* Transforms private method identifiers from #privateMethod to __lwc_component_class_internal_private_privateMethod
* This function returns a Program visitor that transforms private methods before other plugins process them
*/
export default function privateMethodTransform({
types: t,
}: BabelAPI): Visitor<LwcBabelPluginPass> {
return {
Program: {
enter(path: NodePath<types.Program>, state: LwcBabelPluginPass) {
const transformedNames = new Set<string>();

// Transform private methods BEFORE any other plugin processes them
path.traverse(
{
ClassPrivateMethod(
Copy link
Contributor

Choose a reason for hiding this comment

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

Why do we have a Program visitor that traverses with a ClassPrivateMethod visitor instead of just a top-level ClassPrivateMethod visitor?

Modifying nodes can trigger re-evaluation of visitors. If I understand correctly, this means that the current implementation will do a full traversal looking for ClassPrivateMethod whenever a Program is re-evaluated, even if it's not a changed ClassPrivateMethod that triggered re-evaluation. That seems like not what we want.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you switch the top-level ClassPrivateMethod visitor and it causes an infinite loop. The cause of this is that the forward and reverse transforms are both active during the same Babel traversal (Babel merges all plugin visitors into a single pass).

When a top-level ClassPrivateMethod visitor replaces a node with ClassMethod, the reverse transform's ClassMethod visitor immediately fires on the replacement and converts it back to ClassPrivateMethod, which triggers the forward visitor again

Loop: ClassPrivateMethod --> ClassMethod --> ClassPrivateMethod --> ClassMethod --> ....

The Program + path.traverse() pattern avoids this because it's a manual one-shot traversal that completes all forward replacements before Babel's main traversal reaches any class member nodes. By the time the reverse transform's ClassMethod visitor is active, there are no ClassPrivateMethod nodes left to fight over.

You're right that Babel can re-evaluate a Program visitor when descendant nodes are modified. I split out the forward transform to be its own separate plugin, whose only visitor is Program. Since the forward transform itself doesn't modify any Program-level nodes (it only replaces ClassPrivateMethod nodes deep inside the tree), it won't trigger its own re-evaluation.

methodPath: NodePath<types.ClassPrivateMethod>,
methodState: LwcBabelPluginPass
) {
const key = methodPath.get('key');

// We only want kind: 'method'.
// Other options not included are 'get', 'set', and 'constructor'.
const methodKind = 'method';

if (key.isPrivateName() && methodPath.node.kind === methodKind) {
const node = methodPath.node;

// Reject private methods with decorators (e.g. @api, @track, @wire)
if (node.decorators && node.decorators.length > 0) {
handleError(
methodPath,
{ errorInfo: DecoratorErrors.DECORATOR_ON_PRIVATE_METHOD },
methodState
);
return;
}

const privateName = key.node.id.name;
const transformedName = `${PRIVATE_METHOD_PREFIX}${privateName}`;
const keyReplacement = t.identifier(transformedName);

// Create a new ClassMethod node to replace the ClassPrivateMethod
// https://babeljs.io/docs/babel-types#classmethod
const classMethod = t.classMethod(
methodKind,
keyReplacement,
node.params,
node.body,
node.computed,
node.static,
node.generator,
node.async
) as types.ClassMethod;

// Preserve TypeScript annotations and source location when present
if (node.returnType != null) {
classMethod.returnType = node.returnType;
}
if (node.typeParameters != null) {
classMethod.typeParameters = node.typeParameters;
}
if (node.loc != null) {
classMethod.loc = node.loc;
}
// Preserve TypeScript/ECMAScript modifier flags (excluded from t.classMethod() builder)
if (node.abstract != null) {
classMethod.abstract = node.abstract;
}
if (node.access != null) {
classMethod.access = node.access;
}
if (node.accessibility != null) {
classMethod.accessibility = node.accessibility;
}
if (node.optional != null) {
classMethod.optional = node.optional;
}
if (node.override != null) {
classMethod.override = node.override;
}
Copy link
Contributor

Choose a reason for hiding this comment

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

Is all of this information still required at this point?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

These aren't required by the builder but I do think should be preserved when switching between node types (between ClassMethod and ClassPrivateMethod nodes)
https://babeljs.io/docs/babel-types?utm_source=chatgpt.com#classmethod


// Replace the entire ClassPrivateMethod node with the new ClassMethod node
// (we can't just replace the key of type PrivateName with type Identifier)
methodPath.replaceWith(classMethod);
transformedNames.add(transformedName);
}
},
},
state
);

(state.file.metadata as any)[PRIVATE_METHOD_METADATA_KEY] = transformedNames;
},
},
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright (c) 2025, salesforce.com, inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { DecoratorErrors } from '@lwc/errors';
import { PRIVATE_METHOD_PREFIX, PRIVATE_METHOD_METADATA_KEY } from './constants';
import type { BabelAPI, LwcBabelPluginPass } from './types';
import type { types, NodePath, Visitor } from '@babel/core';

/**
* Reverses the private method transformation by converting methods with prefix {@link PRIVATE_METHOD_PREFIX}
* back to ClassPrivateMethod nodes. This runs after babelClassPropertiesPlugin to restore private methods.
*
* Round-trip parity: to match {@link ./private-method-transform.ts}, this transform must copy the same
* properties from ClassMethod onto ClassPrivateMethod when present: returnType, typeParameters, loc,
* abstract, access, accessibility, optional, override (plus async, generator, computed from the builder).
*
* @see {@link ./private-method-transform.ts} for original transformation
*/
export default function reversePrivateMethodTransform({
types: t,
}: BabelAPI): Visitor<LwcBabelPluginPass> {
// Scoped to this plugin instance's closure. Safe as long as each Babel run creates a
// fresh plugin via LwcReversePrivateMethodTransform(); would accumulate across files if
// the same instance were ever reused.
const reverseTransformedNames = new Set<string>();

return {
ClassMethod(path: NodePath<types.ClassMethod>, state: LwcBabelPluginPass) {
const key = path.get('key');

// Check if the key is an identifier with our special prefix
// kind: 'method' | 'get' | 'set' - only 'method' is in scope.
if (key.isIdentifier() && path.node.kind === 'method') {
const methodName = key.node.name;

// Check if this method has our special prefix
if (methodName.startsWith(PRIVATE_METHOD_PREFIX)) {
const forwardTransformedNames: Set<string> | undefined = (
state.file.metadata as any
)[PRIVATE_METHOD_METADATA_KEY];

// If the method was not transformed by the forward pass, it is a
// user-defined method that collides with the reserved prefix.
// This will throw an error to tell the user to rename their function.
if (!forwardTransformedNames || !forwardTransformedNames.has(methodName)) {
const message =
DecoratorErrors.PRIVATE_METHOD_NAME_COLLISION.message.replace(
'{0}',
methodName
);
throw path.buildCodeFrameError(message);
}

// Extract the original private method name
const originalPrivateName = methodName.replace(PRIVATE_METHOD_PREFIX, '');

// Create a new ClassPrivateMethod node to replace the ClassMethod
const node = path.node;
const classPrivateMethod = t.classPrivateMethod(
'method',
t.privateName(t.identifier(originalPrivateName)), // key
node.params,
node.body,
node.static
);

// Properties the t.classPrivateMethod() builder doesn't accept (same as forward transform)
classPrivateMethod.async = node.async;
classPrivateMethod.generator = node.generator;
classPrivateMethod.computed = node.computed;

// Round-trip parity with private-method-transform: preserve TS annotations and modifier flags
if (node.returnType != null) classPrivateMethod.returnType = node.returnType;
if (node.typeParameters != null)
classPrivateMethod.typeParameters = node.typeParameters;
if (node.loc != null) classPrivateMethod.loc = node.loc;
if (node.abstract != null) classPrivateMethod.abstract = node.abstract;
if (node.access != null) classPrivateMethod.access = node.access;
if (node.accessibility != null)
classPrivateMethod.accessibility = node.accessibility;
if (node.optional != null) classPrivateMethod.optional = node.optional;
if (node.override != null) classPrivateMethod.override = node.override;

// Replace the entire ClassMethod with the new ClassPrivateMethod
path.replaceWith(classPrivateMethod);
reverseTransformedNames.add(methodName);
}
}
},

// After all nodes have been visited, verify that every method the forward transform
// renamed was also restored by the reverse transform. A mismatch here means an
// intermediate plugin (e.g. @babel/plugin-transform-class-properties) removed or
// renamed a prefixed method, leaving a mangled name in the final output.
Program: {
exit(_path: NodePath<types.Program>, state: LwcBabelPluginPass) {
const forwardTransformedNames: Set<string> | undefined = (
state.file.metadata as any
)[PRIVATE_METHOD_METADATA_KEY];

if (!forwardTransformedNames) {
return;
}

// Identify methods that were forward-transformed but never reverse-transformed
const missingFromReverse: string[] = [];
for (const name of forwardTransformedNames) {
if (!reverseTransformedNames.has(name)) {
missingFromReverse.push(name);
}
}

if (missingFromReverse.length > 0) {
throw new Error(
`Private method transform count mismatch: ` +
`forward transformed ${forwardTransformedNames.size} method(s), ` +
`but reverse transformed ${reverseTransformedNames.size}. ` +
`Missing reverse transforms for: ${missingFromReverse.join(', ')}`
);
}
},
},
};
}
6 changes: 5 additions & 1 deletion packages/@lwc/compiler/src/transformers/javascript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ import babelAsyncToGenPlugin from '@babel/plugin-transform-async-to-generator';
import babelClassPropertiesPlugin from '@babel/plugin-transform-class-properties';
import babelObjectRestSpreadPlugin from '@babel/plugin-transform-object-rest-spread';
import lockerBabelPluginTransformUnforgeables from '@locker/babel-plugin-transform-unforgeables';
import lwcClassTransformPlugin, { type LwcBabelPluginOptions } from '@lwc/babel-plugin-component';
import lwcClassTransformPlugin, {
LwcReversePrivateMethodTransform,
type LwcBabelPluginOptions,
} from '@lwc/babel-plugin-component';
import {
CompilerAggregateError,
CompilerError,
Expand Down Expand Up @@ -66,6 +69,7 @@ export default function scriptTransform(
const plugins: babel.PluginItem[] = [
[lwcClassTransformPlugin, lwcBabelPluginOptions],
[babelClassPropertiesPlugin, { loose: true }],
LwcReversePrivateMethodTransform,
];

if (!isAPIFeatureEnabled(APIFeature.DISABLE_OBJECT_REST_SPREAD_TRANSFORMATION, apiVersion)) {
Expand Down
2 changes: 1 addition & 1 deletion packages/@lwc/errors/src/compiler/error-info/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
/**
* Next error code: 1212
* Next error code: 1214
*/

export * from './compiler';
Expand Down
16 changes: 16 additions & 0 deletions packages/@lwc/errors/src/compiler/error-info/lwc-class.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,4 +218,20 @@ export const DecoratorErrors = {
level: DiagnosticLevel.Error,
url: 'https://lwc.dev/guide/error_codes#lwc1200',
},

DECORATOR_ON_PRIVATE_METHOD: {
code: 1212,
message:
'Decorators cannot be applied to private methods. Private methods are not part of the component API.',
level: DiagnosticLevel.Error,
url: 'https://lwc.dev/guide/error_codes#lwc1212',
},

PRIVATE_METHOD_NAME_COLLISION: {
code: 1213,
message:
"Method '{0}' conflicts with internal naming conventions. Please rename this function to avoid conflict.",
level: DiagnosticLevel.Error,
url: 'https://lwc.dev/guide/error_codes#lwc1213',
},
} as const satisfies Record<string, LWCErrorInfo>;
6 changes: 6 additions & 0 deletions playground/src/modules/x/counter/counter.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,11 @@ export default class extends LightningElement {
}
decrement() {
this.counter--;

this.#privateMethod();
}

#privateMethod() {
this.counter += 0;
}
}