From bea64c5a45a4e878371e0bbd7be8c971eeb3a567 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Fri, 13 Feb 2026 16:23:35 -0500 Subject: [PATCH 01/30] feat: first attempt Signed-off-by: a.chabot --- .../babel-plugin-component/src/constants.ts | 5 + .../@lwc/babel-plugin-component/src/index.ts | 27 +++++ .../src/private-method-transform.ts | 105 ++++++++++++++++++ .../src/reverse-private-method-transform.ts | 71 ++++++++++++ .../compiler/src/transformers/javascript.ts | 6 +- .../src/compiler/error-info/lwc-class.ts | 7 ++ playground/src/modules/x/counter/counter.js | 6 + 7 files changed, 226 insertions(+), 1 deletion(-) create mode 100644 packages/@lwc/babel-plugin-component/src/private-method-transform.ts create mode 100644 packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts diff --git a/packages/@lwc/babel-plugin-component/src/constants.ts b/packages/@lwc/babel-plugin-component/src/constants.ts index 6773cc67ea..d71b59021d 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.ts +++ b/packages/@lwc/babel-plugin-component/src/constants.ts @@ -34,9 +34,13 @@ 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 SYNTHETIC_ELEMENT_INTERNALS_KEY = 'enableSyntheticElementInternals'; const COMPONENT_FEATURE_FLAG_KEY = 'componentFeatureFlag'; +// TODO: Implement hash into prefix +// TODO: Do I need to do anything else with the 1212? + export { DECORATOR_TYPES, LWC_PACKAGE_ALIAS, @@ -48,6 +52,7 @@ export { COMPONENT_NAME_KEY, API_VERSION_KEY, COMPONENT_CLASS_ID, + PRIVATE_METHOD_PREFIX, SYNTHETIC_ELEMENT_INTERNALS_KEY, COMPONENT_FEATURE_FLAG_KEY, }; diff --git a/packages/@lwc/babel-plugin-component/src/index.ts b/packages/@lwc/babel-plugin-component/src/index.ts index dfcb059335..d2b87ecde3 100644 --- a/packages/@lwc/babel-plugin-component/src/index.ts +++ b/packages/@lwc/babel-plugin-component/src/index.ts @@ -14,6 +14,8 @@ 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'; @@ -21,6 +23,21 @@ 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 { + const { ClassMethod: reverseTransformPrivateMethods } = reversePrivateMethodTransform(api); + + return { + visitor: { + ClassMethod: reverseTransformPrivateMethods, + }, + }; +} + /** * The transform is done in 2 passes: * - First, apply in a single AST traversal the decorators and the component transformation. @@ -32,6 +49,7 @@ export default function LwcClassTransform(api: BabelAPI): PluginObj { + return { + Program: { + enter(path: NodePath, state: LwcBabelPluginPass) { + // Transform private methods BEFORE any other plugin processes them + path.traverse( + { + ClassPrivateMethod( + methodPath: NodePath, + 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; + } + + // 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); + } + }, + }, + state + ); + }, + }, + }; +} \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts new file mode 100644 index 0000000000..0867903e76 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts @@ -0,0 +1,71 @@ +/* + * 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 { PRIVATE_METHOD_PREFIX } 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 { + return { + ClassMethod(path: NodePath) { + 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)) { + // 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); + } + } + }, + }; +} \ No newline at end of file diff --git a/packages/@lwc/compiler/src/transformers/javascript.ts b/packages/@lwc/compiler/src/transformers/javascript.ts index b3733f7135..824d4eb411 100755 --- a/packages/@lwc/compiler/src/transformers/javascript.ts +++ b/packages/@lwc/compiler/src/transformers/javascript.ts @@ -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, @@ -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)) { diff --git a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts index f435ef84ec..2e486c80b8 100644 --- a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts +++ b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts @@ -218,4 +218,11 @@ 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', + }, } as const satisfies Record; diff --git a/playground/src/modules/x/counter/counter.js b/playground/src/modules/x/counter/counter.js index 058d61167c..a19036f673 100644 --- a/playground/src/modules/x/counter/counter.js +++ b/playground/src/modules/x/counter/counter.js @@ -10,5 +10,11 @@ export default class extends LightningElement { } decrement() { this.counter--; + + this.#privateMethod(); + } + + #privateMethod() { + console.log('private method called'); } } From f4433946eab82157ad3b3db91ae3f48919c7874a Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Fri, 13 Feb 2026 16:34:31 -0500 Subject: [PATCH 02/30] fix: linter fix Co-authored-by: Cursor --- .../babel-plugin-component/src/private-method-transform.ts | 6 +++--- .../src/reverse-private-method-transform.ts | 2 +- packages/@lwc/errors/src/compiler/error-info/lwc-class.ts | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts index 63577632ba..582e700b67 100644 --- a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -29,8 +29,8 @@ export default function privateMethodTransform({ methodState: LwcBabelPluginPass ) { const key = methodPath.get('key'); - - // We only want kind: 'method'. + + // We only want kind: 'method'. // Other options not included are 'get', 'set', and 'constructor'. const methodKind = 'method'; @@ -102,4 +102,4 @@ export default function privateMethodTransform({ }, }, }; -} \ No newline at end of file +} diff --git a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts index 0867903e76..8e8ece3f3a 100644 --- a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts @@ -68,4 +68,4 @@ export default function reversePrivateMethodTransform({ } }, }; -} \ No newline at end of file +} diff --git a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts index 2e486c80b8..dc05090d59 100644 --- a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts +++ b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts @@ -221,7 +221,8 @@ export const DecoratorErrors = { DECORATOR_ON_PRIVATE_METHOD: { code: 1212, - message: 'Decorators cannot be applied to private methods. Private methods are not part of the component API.', + 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', }, From 2d9ad36d9e3277c47060dae6066766589da7e10a Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Fri, 13 Feb 2026 17:12:53 -0500 Subject: [PATCH 03/30] fix: update note Signed-off-by: a.chabot --- packages/@lwc/babel-plugin-component/src/constants.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/@lwc/babel-plugin-component/src/constants.ts b/packages/@lwc/babel-plugin-component/src/constants.ts index d71b59021d..d0375fa616 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.ts +++ b/packages/@lwc/babel-plugin-component/src/constants.ts @@ -38,7 +38,8 @@ const PRIVATE_METHOD_PREFIX = '__lwc_component_class_internal_private_'; const SYNTHETIC_ELEMENT_INTERNALS_KEY = 'enableSyntheticElementInternals'; const COMPONENT_FEATURE_FLAG_KEY = 'componentFeatureFlag'; -// TODO: Implement hash into prefix +// TODO: Implement a counter so we know how many functions are getting transformed +// before and after (instead of hash) // TODO: Do I need to do anything else with the 1212? export { From 999c68642baec28e6fd785156fdc401688d9cb03 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 23 Feb 2026 14:51:39 -0500 Subject: [PATCH 04/30] fix: cleaning up lint errors to be able to run tests Co-authored-by: Cursor --- packages/@lwc/babel-plugin-component/src/constants.ts | 4 ---- .../babel-plugin-component/src/private-method-transform.ts | 4 ++-- playground/src/modules/x/counter/counter.js | 4 ++-- 3 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/constants.ts b/packages/@lwc/babel-plugin-component/src/constants.ts index d0375fa616..84027fe99e 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.ts +++ b/packages/@lwc/babel-plugin-component/src/constants.ts @@ -38,10 +38,6 @@ const PRIVATE_METHOD_PREFIX = '__lwc_component_class_internal_private_'; const SYNTHETIC_ELEMENT_INTERNALS_KEY = 'enableSyntheticElementInternals'; const COMPONENT_FEATURE_FLAG_KEY = 'componentFeatureFlag'; -// TODO: Implement a counter so we know how many functions are getting transformed -// before and after (instead of hash) -// TODO: Do I need to do anything else with the 1212? - export { DECORATOR_TYPES, LWC_PACKAGE_ALIAS, diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts index 582e700b67..d76a0abd49 100644 --- a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -4,12 +4,12 @@ * 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 } 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'; -import { handleError } from './utils'; -import { DecoratorErrors } from '@lwc/errors'; /** * Transforms private method identifiers from #privateMethod to __lwc_component_class_internal_private_privateMethod diff --git a/playground/src/modules/x/counter/counter.js b/playground/src/modules/x/counter/counter.js index a19036f673..d975ff2dd9 100644 --- a/playground/src/modules/x/counter/counter.js +++ b/playground/src/modules/x/counter/counter.js @@ -10,11 +10,11 @@ export default class extends LightningElement { } decrement() { this.counter--; - + this.#privateMethod(); } #privateMethod() { - console.log('private method called'); + this.counter += 0; } } From 6b1f599b7c5c1d4a3852f10f572d71d25bb0a572 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 23 Feb 2026 15:03:31 -0500 Subject: [PATCH 05/30] fix: bump "Next error code" comment to 1213 Co-authored-by: Cursor --- packages/@lwc/errors/src/compiler/error-info/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@lwc/errors/src/compiler/error-info/index.ts b/packages/@lwc/errors/src/compiler/error-info/index.ts index c2f596bcf8..cbd4480390 100644 --- a/packages/@lwc/errors/src/compiler/error-info/index.ts +++ b/packages/@lwc/errors/src/compiler/error-info/index.ts @@ -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: 1213 */ export * from './compiler'; From 4b1e6301fd299c121436e29de27d049e16d65a93 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Thu, 26 Feb 2026 11:50:14 -0500 Subject: [PATCH 06/30] feat: add round-trip validation for private method transforms Detect when a user-defined method collides with the reserved __lwc_component_class_internal_private_ prefix by tracking which methods the forward transform renames and verifying the reverse transform only restores those same methods. Throws a descriptive error on mismatch. Made-with: Cursor --- .../private-method-transform.spec.ts | 108 ++++++++++++++++++ .../babel-plugin-component/src/constants.ts | 2 + .../@lwc/babel-plugin-component/src/index.ts | 6 +- .../src/private-method-transform.ts | 7 +- .../src/reverse-private-method-transform.ts | 60 +++++++++- .../errors/src/compiler/error-info/index.ts | 2 +- .../src/compiler/error-info/lwc-class.ts | 8 ++ 7 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts new file mode 100644 index 0000000000..ec2d21c2b1 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -0,0 +1,108 @@ +/* + * 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 { describe, expect, test } from 'vitest'; +import { transformSync } from '@babel/core'; +import plugin, { LwcReversePrivateMethodTransform } from '../index'; + +const BASE_OPTS = { + namespace: 'lwc', + name: 'test', +}; + +const BASE_CONFIG = { + babelrc: false, + configFile: false, + filename: 'test.js', + compact: false, +}; + +function transformWithFullPipeline(source: string, opts = {}) { + return transformSync(source, { + ...BASE_CONFIG, + plugins: [[plugin, { ...BASE_OPTS, ...opts }], LwcReversePrivateMethodTransform], + }); +} + +describe('private method transform validation', () => { + test('normal private methods round-trip successfully', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #privateMethod() { + return 42; + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#privateMethod'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('multiple private methods round-trip successfully', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #methodA() { return 1; } + #methodB() { return 2; } + #methodC() { return 3; } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#methodA'); + expect(result!.code).toContain('#methodB'); + expect(result!.code).toContain('#methodC'); + }); + + test('throws error when user-defined method collides with reserved prefix', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + __lwc_component_class_internal_private_sneakyMethod() { + return 'collision'; + } + } + `; + + expect(() => transformWithFullPipeline(source)).toThrowError( + /conflicts with internal naming conventions\. Please rename this function to avoid conflict/ + ); + }); + + test('throws error when collision exists alongside real private methods', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #realPrivate() { return 1; } + __lwc_component_class_internal_private_fakePrivate() { + return 'collision'; + } + } + `; + + expect(() => transformWithFullPipeline(source)).toThrowError( + /conflicts with internal naming conventions\. Please rename this function to avoid conflict/ + ); + }); + + test('does not flag methods that do not use the reserved prefix', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #privateMethod() { return 1; } + normalPublicMethod() { return 2; } + _underscoreMethod() { return 3; } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#privateMethod'); + expect(result!.code).toContain('normalPublicMethod'); + expect(result!.code).toContain('_underscoreMethod'); + }); +}); diff --git a/packages/@lwc/babel-plugin-component/src/constants.ts b/packages/@lwc/babel-plugin-component/src/constants.ts index 84027fe99e..4306f82f59 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.ts +++ b/packages/@lwc/babel-plugin-component/src/constants.ts @@ -35,6 +35,7 @@ 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'; @@ -50,6 +51,7 @@ export { API_VERSION_KEY, COMPONENT_CLASS_ID, PRIVATE_METHOD_PREFIX, + PRIVATE_METHOD_METADATA_KEY, SYNTHETIC_ELEMENT_INTERNALS_KEY, COMPONENT_FEATURE_FLAG_KEY, }; diff --git a/packages/@lwc/babel-plugin-component/src/index.ts b/packages/@lwc/babel-plugin-component/src/index.ts index d2b87ecde3..278521a4c5 100644 --- a/packages/@lwc/babel-plugin-component/src/index.ts +++ b/packages/@lwc/babel-plugin-component/src/index.ts @@ -29,12 +29,8 @@ export type { LwcBabelPluginOptions } from './types'; * class properties are fully transformed before private methods are restored. */ export function LwcReversePrivateMethodTransform(api: BabelAPI): PluginObj { - const { ClassMethod: reverseTransformPrivateMethods } = reversePrivateMethodTransform(api); - return { - visitor: { - ClassMethod: reverseTransformPrivateMethods, - }, + visitor: reversePrivateMethodTransform(api), }; } diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts index d76a0abd49..d29e8bcd33 100644 --- a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -5,7 +5,7 @@ * 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 } from './constants'; +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'; @@ -21,6 +21,8 @@ export default function privateMethodTransform({ return { Program: { enter(path: NodePath, state: LwcBabelPluginPass) { + const transformedNames = new Set(); + // Transform private methods BEFORE any other plugin processes them path.traverse( { @@ -94,11 +96,14 @@ export default function privateMethodTransform({ // 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; }, }, }; diff --git a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts index 8e8ece3f3a..831d819623 100644 --- a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts @@ -4,7 +4,8 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ -import { PRIVATE_METHOD_PREFIX } from './constants'; +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'; @@ -21,8 +22,13 @@ import type { types, NodePath, Visitor } from '@babel/core'; export default function reversePrivateMethodTransform({ types: t, }: BabelAPI): Visitor { + // 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(); + return { - ClassMethod(path: NodePath) { + ClassMethod(path: NodePath, state: LwcBabelPluginPass) { const key = path.get('key'); // Check if the key is an identifier with our special prefix @@ -32,6 +38,22 @@ export default function reversePrivateMethodTransform({ // Check if this method has our special prefix if (methodName.startsWith(PRIVATE_METHOD_PREFIX)) { + const forwardTransformedNames: Set | 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, ''); @@ -64,8 +86,42 @@ export default function reversePrivateMethodTransform({ // 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, state: LwcBabelPluginPass) { + const forwardTransformedNames: Set | 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(', ')}` + ); + } + }, + }, }; } diff --git a/packages/@lwc/errors/src/compiler/error-info/index.ts b/packages/@lwc/errors/src/compiler/error-info/index.ts index cbd4480390..0c7fa0d366 100644 --- a/packages/@lwc/errors/src/compiler/error-info/index.ts +++ b/packages/@lwc/errors/src/compiler/error-info/index.ts @@ -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: 1213 + * Next error code: 1214 */ export * from './compiler'; diff --git a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts index dc05090d59..7255ac6347 100644 --- a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts +++ b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts @@ -226,4 +226,12 @@ export const DecoratorErrors = { 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; From 8dcb5159d48eba3805d7d3d40efa1143c453a6e7 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Thu, 26 Feb 2026 13:33:21 -0500 Subject: [PATCH 07/30] test: add full-cycle and intermediate disruption tests for private method transforms Add 10 new tests covering forward-only output verification, combined flags, method ordering, default/destructuring params, empty bodies, same-name coexistence, and intermediate plugin scenarios (body mutation, method injection, near-miss prefix matching). Made-with: Cursor --- .../private-method-transform.spec.ts | 488 ++++++++++++++++++ 1 file changed, 488 insertions(+) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index ec2d21c2b1..35d717a731 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -27,6 +27,20 @@ function transformWithFullPipeline(source: string, opts = {}) { }); } +function transformReverseOnly(source: string) { + return transformSync(source, { + ...BASE_CONFIG, + plugins: [LwcReversePrivateMethodTransform], + }); +} + +function transformForwardOnly(source: string, opts = {}) { + return transformSync(source, { + ...BASE_CONFIG, + plugins: [[plugin, { ...BASE_OPTS, ...opts }]], + }); +} + describe('private method transform validation', () => { test('normal private methods round-trip successfully', () => { const source = ` @@ -105,4 +119,478 @@ describe('private method transform validation', () => { expect(result!.code).toContain('normalPublicMethod'); expect(result!.code).toContain('_underscoreMethod'); }); + + test('async private method round-trips successfully', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + async #fetchData() { + return await Promise.resolve(42); + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#fetchData'); + expect(result!.code).toContain('async'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('static private method round-trips successfully', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + static #helper() { + return 'static'; + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#helper'); + expect(result!.code).toContain('static'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('private method with parameters round-trips successfully', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #compute(a, b, ...rest) { + return a + b + rest.length; + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#compute'); + expect(result!.code).toContain('a'); + expect(result!.code).toContain('b'); + expect(result!.code).toContain('...rest'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('private getters and setters are not transformed', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + get #value() { + return this._val; + } + set #value(v) { + this._val = v; + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('get #value'); + expect(result!.code).toContain('set #value'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('decorated private method throws', () => { + const source = ` + import { LightningElement, api } from 'lwc'; + export default class Test extends LightningElement { + @api #decorated() { + return 1; + } + } + `; + + // The @api decorator validation (LWC1103) fires before the private method + // transform, so the error comes from decorator validation rather than the + // private method decorator check (LWC1212). + expect(() => transformWithFullPipeline(source)).toThrowError( + /"@api" can only be applied on class properties/ + ); + }); + + test('class with zero private methods succeeds', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + publicMethod() { return 1; } + anotherPublic() { return 2; } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('publicMethod'); + expect(result!.code).toContain('anotherPublic'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('error message includes the specific offending method name', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + __lwc_component_class_internal_private_mySpecificName() { + return 'collision'; + } + } + `; + + expect(() => transformWithFullPipeline(source)).toThrowError( + /__lwc_component_class_internal_private_mySpecificName/ + ); + }); + + test('multiple collision methods throws on first encountered', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + __lwc_component_class_internal_private_collisionA() { return 1; } + __lwc_component_class_internal_private_collisionB() { return 2; } + } + `; + + expect(() => transformWithFullPipeline(source)).toThrowError( + /__lwc_component_class_internal_private_collision[AB]/ + ); + }); + + test('generator private method round-trips successfully', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + *#generate() { + yield 1; + yield 2; + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#generate'); + expect(result!.code).toContain('yield'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('reverse standalone on clean code succeeds without forward metadata', () => { + const source = ` + class Test { + publicMethod() { return 1; } + } + `; + + const result = transformReverseOnly(source); + expect(result!.code).toContain('publicMethod'); + }); + + test('reverse standalone with prefixed method throws collision when metadata is missing', () => { + const source = ` + class Test { + __lwc_component_class_internal_private_foo() { return 1; } + } + `; + + expect(() => transformReverseOnly(source)).toThrowError( + /conflicts with internal naming conventions\. Please rename this function to avoid conflict/ + ); + }); + + test('Program.exit count mismatch throws when forward-transformed method is removed', () => { + const PREFIX = '__lwc_component_class_internal_private_'; + + // Custom Babel plugin that removes methods with the private prefix, + // simulating an intermediate plugin that drops a method between + // the forward and reverse transforms. + function methodRemoverPlugin(): { visitor: { ClassMethod: (path: any) => void } } { + return { + visitor: { + ClassMethod(path: any) { + const key = path.get('key'); + if (key.isIdentifier() && key.node.name.startsWith(PREFIX)) { + path.remove(); + } + }, + }, + }; + } + + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #disappearingMethod() { return 1; } + } + `; + + expect(() => + transformSync(source, { + ...BASE_CONFIG, + plugins: [ + [plugin, { ...BASE_OPTS }], + methodRemoverPlugin, + LwcReversePrivateMethodTransform, + ], + }) + ).toThrowError(/Private method transform count mismatch/); + }); + + test('multiple classes in the same file round-trip private methods', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class First extends LightningElement { + #shared() { return 'first'; } + } + class Second extends LightningElement { + #shared() { return 'second'; } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + const matches = result!.code!.match(/#shared/g); + expect(matches).toHaveLength(2); + }); + + test('private method body with call sites round-trips', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #helper() { return 42; } + #caller() { + return this.#helper() + 1; + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#helper'); + expect(result!.code).toContain('#caller'); + expect(result!.code).toContain('this.#helper()'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('forward-only output contains correct prefixed names', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #foo() { return 1; } + #bar() { return 2; } + } + `; + + const result = transformForwardOnly(source); + expect(result!.code).toContain('__lwc_component_class_internal_private_foo'); + expect(result!.code).toContain('__lwc_component_class_internal_private_bar'); + expect(result!.code).not.toContain('#foo'); + expect(result!.code).not.toContain('#bar'); + }); + + test('combined flags (static, async, default param) survive round-trip', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + static async #fetch(url, opts = {}) { + return await fetch(url, opts); + } + } + `; + + const result = transformWithFullPipeline(source); + const code = result!.code!; + expect(code).toContain('static'); + expect(code).toContain('async'); + expect(code).toContain('#fetch'); + expect(code).toContain('url'); + expect(code).toContain('opts'); + expect(code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('method ordering is preserved through round-trip', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #alpha() { return 'a'; } + publicBeta() { return 'b'; } + #gamma() { return 'c'; } + publicDelta() { return 'd'; } + } + `; + + const result = transformWithFullPipeline(source); + const code = result!.code!; + const alphaIdx = code.indexOf('#alpha'); + const betaIdx = code.indexOf('publicBeta'); + const gammaIdx = code.indexOf('#gamma'); + const deltaIdx = code.indexOf('publicDelta'); + expect(alphaIdx).toBeGreaterThan(-1); + expect(betaIdx).toBeGreaterThan(-1); + expect(gammaIdx).toBeGreaterThan(-1); + expect(deltaIdx).toBeGreaterThan(-1); + expect(alphaIdx).toBeLessThan(betaIdx); + expect(betaIdx).toBeLessThan(gammaIdx); + expect(gammaIdx).toBeLessThan(deltaIdx); + }); + + test('default parameter values survive round-trip', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #greet(name = 'world', times = 3) { + return name.repeat(times); + } + } + `; + + const result = transformWithFullPipeline(source); + const code = result!.code!; + expect(code).toContain('#greet'); + expect(code).toContain("'world'"); + expect(code).toContain('3'); + expect(code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('destructuring parameters survive round-trip', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #process({ x, y }, [a, b]) { + return x + y + a + b; + } + } + `; + + const result = transformWithFullPipeline(source); + const code = result!.code!; + expect(code).toContain('#process'); + expect(code).toMatch(/\{\s*x,\s*y\s*\}/); + expect(code).toMatch(/\[\s*a,\s*b\s*\]/); + expect(code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('empty method body round-trips', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #noop() {} + } + `; + + const result = transformWithFullPipeline(source); + expect(result!.code).toContain('#noop'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('private and public method with same name coexist', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #foo() { return 'private'; } + foo() { return 'public'; } + } + `; + + const result = transformWithFullPipeline(source); + const code = result!.code!; + expect(code).toContain('#foo'); + expect(code).toContain("return 'private'"); + expect(code).toContain("return 'public'"); + expect(code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('intermediate plugin that modifies method body does not break reverse transform', () => { + function bodyModifierPlugin({ types: t }: any) { + return { + visitor: { + ClassMethod(path: any) { + const key = path.get('key'); + if ( + key.isIdentifier() && + key.node.name.startsWith('__lwc_component_class_internal_private_') + ) { + const logStatement = t.expressionStatement( + t.callExpression( + t.memberExpression( + t.identifier('console'), + t.identifier('log') + ), + [t.stringLiteral('injected')] + ) + ); + path.node.body.body.unshift(logStatement); + } + }, + }, + }; + } + + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #myMethod() { return 42; } + } + `; + + const result = transformSync(source, { + ...BASE_CONFIG, + plugins: [ + [plugin, { ...BASE_OPTS }], + bodyModifierPlugin, + LwcReversePrivateMethodTransform, + ], + }); + expect(result!.code).toContain('#myMethod'); + expect(result!.code).toContain('console.log("injected")'); + expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('intermediate plugin that adds a prefixed method triggers collision', () => { + function prefixedMethodInjectorPlugin({ types: t }: any) { + let injected = false; + return { + visitor: { + ClassMethod(path: any) { + if (injected) return; + injected = true; + path.insertAfter( + t.classMethod( + 'method', + t.identifier('__lwc_component_class_internal_private_injected'), + [], + t.blockStatement([t.returnStatement(t.stringLiteral('injected'))]) + ) + ); + }, + }, + }; + } + + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #realMethod() { return 1; } + } + `; + + expect(() => + transformSync(source, { + ...BASE_CONFIG, + plugins: [ + [plugin, { ...BASE_OPTS }], + prefixedMethodInjectorPlugin, + LwcReversePrivateMethodTransform, + ], + }) + ).toThrowError(/conflicts with internal naming conventions/); + }); + + test('methods with similar but non-matching prefixes are not reverse-transformed', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + __lwc_component_class_internal_foo() { return 1; } + __lwc_component_class_internal_privatefoo() { return 2; } + } + `; + + const result = transformWithFullPipeline(source); + const code = result!.code!; + expect(code).toContain('__lwc_component_class_internal_foo'); + expect(code).toContain('__lwc_component_class_internal_privatefoo'); + expect(code).not.toContain('#foo'); + }); }); From 9b1e10974001831bd50c787add641d65f823d690 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 12:36:45 -0500 Subject: [PATCH 08/30] refactor: make PRIVATE_METHOD_NAME_COLLISION error message mention reserved prefix More precise guidance: "cannot start with reserved prefix `__lwc_`" instead of "conflicts with internal naming conventions". Made-with: Cursor --- .../src/__tests__/private-method-transform.spec.ts | 8 ++++---- packages/@lwc/errors/src/compiler/error-info/lwc-class.ts | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index 35d717a731..78193debda 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -84,7 +84,7 @@ describe('private method transform validation', () => { `; expect(() => transformWithFullPipeline(source)).toThrowError( - /conflicts with internal naming conventions\. Please rename this function to avoid conflict/ + /cannot start with reserved prefix `__lwc_`\. Please rename this function to avoid conflict/ ); }); @@ -100,7 +100,7 @@ describe('private method transform validation', () => { `; expect(() => transformWithFullPipeline(source)).toThrowError( - /conflicts with internal naming conventions\. Please rename this function to avoid conflict/ + /cannot start with reserved prefix `__lwc_`\. Please rename this function to avoid conflict/ ); }); @@ -287,7 +287,7 @@ describe('private method transform validation', () => { `; expect(() => transformReverseOnly(source)).toThrowError( - /conflicts with internal naming conventions\. Please rename this function to avoid conflict/ + /cannot start with reserved prefix `__lwc_`\. Please rename this function to avoid conflict/ ); }); @@ -575,7 +575,7 @@ describe('private method transform validation', () => { LwcReversePrivateMethodTransform, ], }) - ).toThrowError(/conflicts with internal naming conventions/); + ).toThrowError(/cannot start with reserved prefix `__lwc_`/); }); test('methods with similar but non-matching prefixes are not reverse-transformed', () => { diff --git a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts index 7255ac6347..7703601947 100644 --- a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts +++ b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts @@ -230,7 +230,7 @@ export const DecoratorErrors = { PRIVATE_METHOD_NAME_COLLISION: { code: 1213, message: - "Method '{0}' conflicts with internal naming conventions. Please rename this function to avoid conflict.", + "Method '{0}' cannot start with reserved prefix `__lwc_`. Please rename this function to avoid conflict.", level: DiagnosticLevel.Error, url: 'https://lwc.dev/guide/error_codes#lwc1213', }, From 61ad8b5672ca6ed3e884941a195789e4652345ef Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 12:38:07 -0500 Subject: [PATCH 09/30] chore: revert playground counter.js to master Remove private method example added for testing; not needed in the playground. Made-with: Cursor --- playground/src/modules/x/counter/counter.js | 6 ------ 1 file changed, 6 deletions(-) diff --git a/playground/src/modules/x/counter/counter.js b/playground/src/modules/x/counter/counter.js index d975ff2dd9..058d61167c 100644 --- a/playground/src/modules/x/counter/counter.js +++ b/playground/src/modules/x/counter/counter.js @@ -10,11 +10,5 @@ export default class extends LightningElement { } decrement() { this.counter--; - - this.#privateMethod(); - } - - #privateMethod() { - this.counter += 0; } } From 76da7ac05809cfac80a6d9462e3181a5b84264ed Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 12:39:12 -0500 Subject: [PATCH 10/30] refactor: hoist methodKind constant to module scope Move the constant literal out of the visitor body into a module-level METHOD_KIND constant. Made-with: Cursor --- .../src/private-method-transform.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts index d29e8bcd33..4a79d533c1 100644 --- a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -11,6 +11,9 @@ import type { BabelAPI, LwcBabelPluginPass } from './types'; import type { NodePath, Visitor } from '@babel/core'; import type { types } from '@babel/core'; +// We only transform kind: 'method'. Other kinds ('get', 'set', 'constructor') are left alone. +const METHOD_KIND = 'method'; + /** * 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 @@ -32,11 +35,7 @@ export default function privateMethodTransform({ ) { 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) { + if (key.isPrivateName() && methodPath.node.kind === METHOD_KIND) { const node = methodPath.node; // Reject private methods with decorators (e.g. @api, @track, @wire) @@ -56,7 +55,7 @@ export default function privateMethodTransform({ // Create a new ClassMethod node to replace the ClassPrivateMethod // https://babeljs.io/docs/babel-types#classmethod const classMethod = t.classMethod( - methodKind, + METHOD_KIND, keyReplacement, node.params, node.body, From fcf9c2593c03456015f6088acf9ffdfafd2f02fa Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 13:11:50 -0500 Subject: [PATCH 11/30] refactor: extract shared copyMethodMetadata helper for private method transforms Deduplicate the repeated if-node.X-!= null property copying into a single helper in utils.ts, used by both the forward and reverse transforms. Made-with: Cursor --- .../src/private-method-transform.ts | 29 ++----------------- .../src/reverse-private-method-transform.ts | 13 ++------- .../@lwc/babel-plugin-component/src/utils.ts | 21 ++++++++++++++ 3 files changed, 25 insertions(+), 38 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts index 4a79d533c1..f81bdd2457 100644 --- a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -6,7 +6,7 @@ */ import { DecoratorErrors } from '@lwc/errors'; import { PRIVATE_METHOD_PREFIX, PRIVATE_METHOD_METADATA_KEY } from './constants'; -import { handleError } from './utils'; +import { copyMethodMetadata, handleError } from './utils'; import type { BabelAPI, LwcBabelPluginPass } from './types'; import type { NodePath, Visitor } from '@babel/core'; import type { types } from '@babel/core'; @@ -65,32 +65,7 @@ export default function privateMethodTransform({ 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; - } + copyMethodMetadata(node, classMethod); // Replace the entire ClassPrivateMethod node with the new ClassMethod node // (we can't just replace the key of type PrivateName with type Identifier) diff --git a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts index 831d819623..1505f80072 100644 --- a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts @@ -6,6 +6,7 @@ */ import { DecoratorErrors } from '@lwc/errors'; import { PRIVATE_METHOD_PREFIX, PRIVATE_METHOD_METADATA_KEY } from './constants'; +import { copyMethodMetadata } from './utils'; import type { BabelAPI, LwcBabelPluginPass } from './types'; import type { types, NodePath, Visitor } from '@babel/core'; @@ -72,17 +73,7 @@ export default function reversePrivateMethodTransform({ 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; + copyMethodMetadata(node, classPrivateMethod); // Replace the entire ClassMethod with the new ClassPrivateMethod path.replaceWith(classPrivateMethod); diff --git a/packages/@lwc/babel-plugin-component/src/utils.ts b/packages/@lwc/babel-plugin-component/src/utils.ts index 290a158169..c00609be73 100644 --- a/packages/@lwc/babel-plugin-component/src/utils.ts +++ b/packages/@lwc/babel-plugin-component/src/utils.ts @@ -159,6 +159,26 @@ function isErrorRecoveryMode(state: LwcBabelPluginPass): boolean { return state.file.opts?.parserOpts?.errorRecovery ?? false; } +/** + * Copies optional metadata properties between ClassMethod and ClassPrivateMethod nodes. + * These properties are not accepted by the t.classMethod() / t.classPrivateMethod() builders, + * so they must be transferred manually after node creation. Both the forward and reverse + * private-method transforms use this to maintain round-trip parity. + */ +function copyMethodMetadata( + source: types.ClassMethod | types.ClassPrivateMethod, + target: types.ClassMethod | types.ClassPrivateMethod +): void { + if (source.returnType != null) target.returnType = source.returnType; + if (source.typeParameters != null) target.typeParameters = source.typeParameters; + if (source.loc != null) target.loc = source.loc; + if (source.abstract != null) target.abstract = source.abstract; + if (source.access != null) target.access = source.access; + if (source.accessibility != null) target.accessibility = source.accessibility; + if (source.optional != null) target.optional = source.optional; + if (source.override != null) target.override = source.override; +} + export { isClassMethod, isGetterClassMethod, @@ -167,4 +187,5 @@ export { handleError, incrementMetricCounter, isErrorRecoveryMode, + copyMethodMetadata, }; From c409872a8c5fd15d2c7009bba52eec35a6777815 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 13:21:15 -0500 Subject: [PATCH 12/30] refactor: make forward and reverse private method transforms independent plugins Both transforms are now standalone Babel plugins returning PluginObj directly, re-exported from index.ts. The forward transform is no longer bundled inside the main LwcClassTransform Program.enter; instead it runs as a separate plugin before the main plugin in the pipeline. Made-with: Cursor --- .../private-method-transform.spec.ts | 20 ++- .../@lwc/babel-plugin-component/src/index.ts | 24 +-- .../src/private-method-transform.ts | 27 +++- .../src/reverse-private-method-transform.ts | 144 +++++++++--------- .../compiler/src/transformers/javascript.ts | 2 + 5 files changed, 107 insertions(+), 110 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index 78193debda..f72fc915b0 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, test } from 'vitest'; import { transformSync } from '@babel/core'; -import plugin, { LwcReversePrivateMethodTransform } from '../index'; +import plugin, { LwcPrivateMethodTransform, LwcReversePrivateMethodTransform } from '../index'; const BASE_OPTS = { namespace: 'lwc', @@ -23,7 +23,11 @@ const BASE_CONFIG = { function transformWithFullPipeline(source: string, opts = {}) { return transformSync(source, { ...BASE_CONFIG, - plugins: [[plugin, { ...BASE_OPTS, ...opts }], LwcReversePrivateMethodTransform], + plugins: [ + LwcPrivateMethodTransform, + [plugin, { ...BASE_OPTS, ...opts }], + LwcReversePrivateMethodTransform, + ], }); } @@ -37,7 +41,7 @@ function transformReverseOnly(source: string) { function transformForwardOnly(source: string, opts = {}) { return transformSync(source, { ...BASE_CONFIG, - plugins: [[plugin, { ...BASE_OPTS, ...opts }]], + plugins: [LwcPrivateMethodTransform, [plugin, { ...BASE_OPTS, ...opts }]], }); } @@ -199,11 +203,10 @@ describe('private method transform validation', () => { } `; - // The @api decorator validation (LWC1103) fires before the private method - // transform, so the error comes from decorator validation rather than the - // private method decorator check (LWC1212). + // The forward private method transform runs as a separate plugin before the + // main LWC plugin, so LWC1212 fires before the @api decorator validation. expect(() => transformWithFullPipeline(source)).toThrowError( - /"@api" can only be applied on class properties/ + /Decorators cannot be applied to private methods/ ); }); @@ -321,6 +324,7 @@ describe('private method transform validation', () => { transformSync(source, { ...BASE_CONFIG, plugins: [ + LwcPrivateMethodTransform, [plugin, { ...BASE_OPTS }], methodRemoverPlugin, LwcReversePrivateMethodTransform, @@ -528,6 +532,7 @@ describe('private method transform validation', () => { const result = transformSync(source, { ...BASE_CONFIG, plugins: [ + LwcPrivateMethodTransform, [plugin, { ...BASE_OPTS }], bodyModifierPlugin, LwcReversePrivateMethodTransform, @@ -570,6 +575,7 @@ describe('private method transform validation', () => { transformSync(source, { ...BASE_CONFIG, plugins: [ + LwcPrivateMethodTransform, [plugin, { ...BASE_OPTS }], prefixedMethodInjectorPlugin, LwcReversePrivateMethodTransform, diff --git a/packages/@lwc/babel-plugin-component/src/index.ts b/packages/@lwc/babel-plugin-component/src/index.ts index 278521a4c5..903b5e3720 100644 --- a/packages/@lwc/babel-plugin-component/src/index.ts +++ b/packages/@lwc/babel-plugin-component/src/index.ts @@ -14,8 +14,6 @@ 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'; @@ -23,16 +21,8 @@ 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 { - return { - visitor: reversePrivateMethodTransform(api), - }; -} +export { default as LwcPrivateMethodTransform } from './private-method-transform'; +export { default as LwcReversePrivateMethodTransform } from './reverse-private-method-transform'; /** * The transform is done in 2 passes: @@ -45,7 +35,6 @@ export default function LwcClassTransform(api: BabelAPI): PluginObj path.traverse() rather than a top-level ClassPrivateMethod visitor + * because the reverse transform has a ClassMethod visitor in the same Babel pass. + * A direct ClassPrivateMethod visitor would replace nodes that the reverse transform + * immediately converts back, creating an infinite loop. The manual traverse ensures + * all forward replacements complete before the reverse visitor sees any ClassMethod. */ export default function privateMethodTransform({ types: t, -}: BabelAPI): Visitor { +}: BabelAPI): PluginObj { return { - Program: { - enter(path: NodePath, state: LwcBabelPluginPass) { + visitor: { + Program(path: NodePath, state: LwcBabelPluginPass) { const transformedNames = new Set(); - // Transform private methods BEFORE any other plugin processes them path.traverse( { ClassPrivateMethod( @@ -42,7 +51,9 @@ export default function privateMethodTransform({ if (node.decorators && node.decorators.length > 0) { handleError( methodPath, - { errorInfo: DecoratorErrors.DECORATOR_ON_PRIVATE_METHOD }, + { + errorInfo: DecoratorErrors.DECORATOR_ON_PRIVATE_METHOD, + }, methodState ); return; diff --git a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts index 1505f80072..9cbfee4dae 100644 --- a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts @@ -8,11 +8,14 @@ import { DecoratorErrors } from '@lwc/errors'; import { PRIVATE_METHOD_PREFIX, PRIVATE_METHOD_METADATA_KEY } from './constants'; import { copyMethodMetadata } from './utils'; import type { BabelAPI, LwcBabelPluginPass } from './types'; -import type { types, NodePath, Visitor } from '@babel/core'; +import type { types, NodePath, PluginObj } 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. + * Standalone Babel plugin that reverses the private method transformation by converting + * methods with prefix {@link PRIVATE_METHOD_PREFIX} back to ClassPrivateMethod nodes. + * + * This must be registered AFTER @babel/plugin-transform-class-properties so that + * class properties are fully transformed before private methods are restored. * * 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, @@ -22,96 +25,91 @@ import type { types, NodePath, Visitor } from '@babel/core'; */ export default function reversePrivateMethodTransform({ types: t, -}: BabelAPI): Visitor { +}: BabelAPI): PluginObj { // 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(); return { - ClassMethod(path: NodePath, state: LwcBabelPluginPass) { - const key = path.get('key'); + visitor: { + ClassMethod(path: NodePath, 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; + // 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 | undefined = ( - state.file.metadata as any - )[PRIVATE_METHOD_METADATA_KEY]; + if (methodName.startsWith(PRIVATE_METHOD_PREFIX)) { + const forwardTransformedNames: Set | 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); - } + // If the method was not transformed by the forward pass, it is a + // user-defined method that collides with the reserved prefix. + 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, ''); + 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 - ); + const node = path.node; + const classPrivateMethod = t.classPrivateMethod( + 'method', + t.privateName(t.identifier(originalPrivateName)), + 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; + // Properties the t.classPrivateMethod() builder doesn't accept + classPrivateMethod.async = node.async; + classPrivateMethod.generator = node.generator; + classPrivateMethod.computed = node.computed; - copyMethodMetadata(node, classPrivateMethod); + copyMethodMetadata(node, classPrivateMethod); - // Replace the entire ClassMethod with the new ClassPrivateMethod - path.replaceWith(classPrivateMethod); - reverseTransformedNames.add(methodName); + 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, state: LwcBabelPluginPass) { - const forwardTransformedNames: Set | undefined = ( - state.file.metadata as any - )[PRIVATE_METHOD_METADATA_KEY]; + // 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, state: LwcBabelPluginPass) { + const forwardTransformedNames: Set | undefined = ( + state.file.metadata as any + )[PRIVATE_METHOD_METADATA_KEY]; - if (!forwardTransformedNames) { - return; - } + 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); + 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(', ')}` - ); - } + 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(', ')}` + ); + } + }, }, }, }; diff --git a/packages/@lwc/compiler/src/transformers/javascript.ts b/packages/@lwc/compiler/src/transformers/javascript.ts index 824d4eb411..aaf3eaf583 100755 --- a/packages/@lwc/compiler/src/transformers/javascript.ts +++ b/packages/@lwc/compiler/src/transformers/javascript.ts @@ -11,6 +11,7 @@ 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, { + LwcPrivateMethodTransform, LwcReversePrivateMethodTransform, type LwcBabelPluginOptions, } from '@lwc/babel-plugin-component'; @@ -67,6 +68,7 @@ export default function scriptTransform( }; const plugins: babel.PluginItem[] = [ + LwcPrivateMethodTransform, [lwcClassTransformPlugin, lwcBabelPluginOptions], [babelClassPropertiesPlugin, { loose: true }], LwcReversePrivateMethodTransform, From 591bc67fcaf0cf03a36ae7f72154b73de3949088 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 13:23:20 -0500 Subject: [PATCH 13/30] refactor: move non-null assertion to transformSync helpers in tests Add ! to the transformSync() return in each helper function so callers can use result.code instead of result!.code everywhere. Made-with: Cursor --- .../private-method-transform.spec.ts | 106 +++++++++--------- 1 file changed, 53 insertions(+), 53 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index f72fc915b0..dd75095828 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -28,21 +28,21 @@ function transformWithFullPipeline(source: string, opts = {}) { [plugin, { ...BASE_OPTS, ...opts }], LwcReversePrivateMethodTransform, ], - }); + })!; } function transformReverseOnly(source: string) { return transformSync(source, { ...BASE_CONFIG, plugins: [LwcReversePrivateMethodTransform], - }); + })!; } function transformForwardOnly(source: string, opts = {}) { return transformSync(source, { ...BASE_CONFIG, plugins: [LwcPrivateMethodTransform, [plugin, { ...BASE_OPTS, ...opts }]], - }); + })!; } describe('private method transform validation', () => { @@ -57,8 +57,8 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#privateMethod'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#privateMethod'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('multiple private methods round-trip successfully', () => { @@ -72,9 +72,9 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#methodA'); - expect(result!.code).toContain('#methodB'); - expect(result!.code).toContain('#methodC'); + expect(result.code).toContain('#methodA'); + expect(result.code).toContain('#methodB'); + expect(result.code).toContain('#methodC'); }); test('throws error when user-defined method collides with reserved prefix', () => { @@ -119,9 +119,9 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#privateMethod'); - expect(result!.code).toContain('normalPublicMethod'); - expect(result!.code).toContain('_underscoreMethod'); + expect(result.code).toContain('#privateMethod'); + expect(result.code).toContain('normalPublicMethod'); + expect(result.code).toContain('_underscoreMethod'); }); test('async private method round-trips successfully', () => { @@ -135,9 +135,9 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#fetchData'); - expect(result!.code).toContain('async'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#fetchData'); + expect(result.code).toContain('async'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('static private method round-trips successfully', () => { @@ -151,9 +151,9 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#helper'); - expect(result!.code).toContain('static'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#helper'); + expect(result.code).toContain('static'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('private method with parameters round-trips successfully', () => { @@ -167,11 +167,11 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#compute'); - expect(result!.code).toContain('a'); - expect(result!.code).toContain('b'); - expect(result!.code).toContain('...rest'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#compute'); + expect(result.code).toContain('a'); + expect(result.code).toContain('b'); + expect(result.code).toContain('...rest'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('private getters and setters are not transformed', () => { @@ -188,9 +188,9 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('get #value'); - expect(result!.code).toContain('set #value'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('get #value'); + expect(result.code).toContain('set #value'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('decorated private method throws', () => { @@ -220,9 +220,9 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('publicMethod'); - expect(result!.code).toContain('anotherPublic'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('publicMethod'); + expect(result.code).toContain('anotherPublic'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('error message includes the specific offending method name', () => { @@ -266,9 +266,9 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#generate'); - expect(result!.code).toContain('yield'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#generate'); + expect(result.code).toContain('yield'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('reverse standalone on clean code succeeds without forward metadata', () => { @@ -279,7 +279,7 @@ describe('private method transform validation', () => { `; const result = transformReverseOnly(source); - expect(result!.code).toContain('publicMethod'); + expect(result.code).toContain('publicMethod'); }); test('reverse standalone with prefixed method throws collision when metadata is missing', () => { @@ -345,8 +345,8 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); - const matches = result!.code!.match(/#shared/g); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); + const matches = result.code!.match(/#shared/g); expect(matches).toHaveLength(2); }); @@ -362,10 +362,10 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#helper'); - expect(result!.code).toContain('#caller'); - expect(result!.code).toContain('this.#helper()'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#helper'); + expect(result.code).toContain('#caller'); + expect(result.code).toContain('this.#helper()'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('forward-only output contains correct prefixed names', () => { @@ -378,10 +378,10 @@ describe('private method transform validation', () => { `; const result = transformForwardOnly(source); - expect(result!.code).toContain('__lwc_component_class_internal_private_foo'); - expect(result!.code).toContain('__lwc_component_class_internal_private_bar'); - expect(result!.code).not.toContain('#foo'); - expect(result!.code).not.toContain('#bar'); + expect(result.code).toContain('__lwc_component_class_internal_private_foo'); + expect(result.code).toContain('__lwc_component_class_internal_private_bar'); + expect(result.code).not.toContain('#foo'); + expect(result.code).not.toContain('#bar'); }); test('combined flags (static, async, default param) survive round-trip', () => { @@ -395,7 +395,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - const code = result!.code!; + const code = result.code!; expect(code).toContain('static'); expect(code).toContain('async'); expect(code).toContain('#fetch'); @@ -416,7 +416,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - const code = result!.code!; + const code = result.code!; const alphaIdx = code.indexOf('#alpha'); const betaIdx = code.indexOf('publicBeta'); const gammaIdx = code.indexOf('#gamma'); @@ -441,7 +441,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - const code = result!.code!; + const code = result.code!; expect(code).toContain('#greet'); expect(code).toContain("'world'"); expect(code).toContain('3'); @@ -459,7 +459,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - const code = result!.code!; + const code = result.code!; expect(code).toContain('#process'); expect(code).toMatch(/\{\s*x,\s*y\s*\}/); expect(code).toMatch(/\[\s*a,\s*b\s*\]/); @@ -475,8 +475,8 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result!.code).toContain('#noop'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#noop'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('private and public method with same name coexist', () => { @@ -489,7 +489,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - const code = result!.code!; + const code = result.code!; expect(code).toContain('#foo'); expect(code).toContain("return 'private'"); expect(code).toContain("return 'public'"); @@ -538,9 +538,9 @@ describe('private method transform validation', () => { LwcReversePrivateMethodTransform, ], }); - expect(result!.code).toContain('#myMethod'); - expect(result!.code).toContain('console.log("injected")'); - expect(result!.code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('#myMethod'); + expect(result.code).toContain('console.log("injected")'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('intermediate plugin that adds a prefixed method triggers collision', () => { @@ -594,7 +594,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - const code = result!.code!; + const code = result.code!; expect(code).toContain('__lwc_component_class_internal_foo'); expect(code).toContain('__lwc_component_class_internal_privatefoo'); expect(code).not.toContain('#foo'); From a2d3e4b277e2d15c03092a3930acd481724b37ee Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 13:24:48 -0500 Subject: [PATCH 14/30] refactor: consolidate fragmented toContain assertions in tests Combine split expect(code).toContain() calls into single assertions that match the full substring (e.g. 'static async #fetch(url, opts = {})'). Made-with: Cursor --- .../private-method-transform.spec.ts | 20 +++++-------------- 1 file changed, 5 insertions(+), 15 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index dd75095828..6dcb024d14 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -135,8 +135,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result.code).toContain('#fetchData'); - expect(result.code).toContain('async'); + expect(result.code).toContain('async #fetchData'); expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); @@ -151,8 +150,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result.code).toContain('#helper'); - expect(result.code).toContain('static'); + expect(result.code).toContain('static #helper'); expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); @@ -167,10 +165,7 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - expect(result.code).toContain('#compute'); - expect(result.code).toContain('a'); - expect(result.code).toContain('b'); - expect(result.code).toContain('...rest'); + expect(result.code).toContain('#compute(a, b, ...rest)'); expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); @@ -395,13 +390,8 @@ describe('private method transform validation', () => { `; const result = transformWithFullPipeline(source); - const code = result.code!; - expect(code).toContain('static'); - expect(code).toContain('async'); - expect(code).toContain('#fetch'); - expect(code).toContain('url'); - expect(code).toContain('opts'); - expect(code).not.toContain('__lwc_component_class_internal_private_'); + expect(result.code).toContain('static async #fetch(url, opts = {})'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); test('method ordering is preserved through round-trip', () => { From 7e51945d84ace0f9803b5f78a9c7e60e7c5b12bc Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 13:26:04 -0500 Subject: [PATCH 15/30] test: add test cases for private fields passing through unchanged Verify that private fields (#count, #name) are not affected by the private method transform, both alone and alongside private methods. Made-with: Cursor --- .../private-method-transform.spec.ts | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index 6dcb024d14..804e0f724e 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -589,4 +589,33 @@ describe('private method transform validation', () => { expect(code).toContain('__lwc_component_class_internal_privatefoo'); expect(code).not.toContain('#foo'); }); + + test('private fields are not transformed', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #count = 0; + #name = 'test'; + } + `; + + const result = transformWithFullPipeline(source); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('private fields alongside private methods are handled correctly', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #count = 0; + #increment() { + this.#count++; + } + } + `; + + const result = transformWithFullPipeline(source); + expect(result.code).toContain('#increment'); + expect(result.code).not.toContain('__lwc_component_class_internal_private_'); + }); }); From d4f93e4c4f213f1ef6a058de0fa928b23d826a45 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 13:28:18 -0500 Subject: [PATCH 16/30] test: add test cases for rogue #privateNames not sneaking through Validate that private method call sites, private field references in method bodies, and mixed field/method classes all behave correctly through the transform pipeline without leaking prefixed names. Made-with: Cursor --- .../private-method-transform.spec.ts | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index 804e0f724e..f81c653604 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -618,4 +618,55 @@ describe('private method transform validation', () => { expect(result.code).toContain('#increment'); expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); + + test('private method call sites do not leak prefixed names after round-trip', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #doWork(x) { return x * 2; } + connectedCallback() { + const result = this.#doWork(21); + console.log(result); + } + } + `; + + const result = transformWithFullPipeline(source); + const code = result.code!; + expect(code).toContain('this.#doWork(21)'); + expect(code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('private field reference in method body survives round-trip', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #state = { ready: false }; + #init() { + this.#state.ready = true; + } + } + `; + + const result = transformWithFullPipeline(source); + const code = result.code!; + expect(code).toContain('#init'); + expect(code).toContain('this.#state.ready = true'); + expect(code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('forward transform only renames method declarations, not field declarations', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #secret = 42; + #getSecret() { return this.#secret; } + } + `; + + const result = transformForwardOnly(source); + const code = result.code!; + expect(code).toContain('__lwc_component_class_internal_private_getSecret'); + expect(code).toContain('#secret'); + }); }); From f28424788fd6cddbf69b69f799b533e6168346ef Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 14:43:12 -0500 Subject: [PATCH 17/30] feat: add unsupported private member errors and fixture e2e tests Add LWC1214 error for unsupported private member types (fields and accessor methods). Update the forward transform to throw informative errors instead of silently passing through. Replace affected unit tests with error-expectation tests. Add fixture-based end-to-end tests that run the full pipeline (forward transform, LWC plugin, class-properties plugin, reverse transform) to verify private methods survive round-trip. Made-with: Cursor --- .../multiple-private-methods/actual.js | 18 ++++ .../multiple-private-methods/error.json | 0 .../multiple-private-methods/expected.js | 26 +++++ .../actual.js | 13 +++ .../error.json | 0 .../expected.js | 31 ++++++ .../simple-private-method/actual.js | 10 ++ .../simple-private-method/error.json | 0 .../simple-private-method/expected.js | 18 ++++ .../static-async-private-method/actual.js | 11 +++ .../static-async-private-method/error.json | 0 .../static-async-private-method/expected.js | 19 ++++ .../throws-on-private-field/actual.js | 4 + .../throws-on-private-field/error.json | 10 ++ .../throws-on-private-field/expected.js | 0 .../throws-on-private-getter/actual.js | 6 ++ .../throws-on-private-getter/error.json | 10 ++ .../throws-on-private-getter/expected.js | 0 .../src/__tests__/index.spec.ts | 61 +++++++++++- .../private-method-transform.spec.ts | 65 ++++++------- .../src/private-method-transform.ts | 95 ++++++++++++------- .../errors/src/compiler/error-info/index.ts | 2 +- .../src/compiler/error-info/lwc-class.ts | 7 ++ 23 files changed, 332 insertions(+), 74 deletions(-) create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/error.json create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/error.json create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/error.json create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/error.json create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/expected.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json create mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/expected.js diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js new file mode 100644 index 0000000000..5b8160cc55 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js @@ -0,0 +1,18 @@ +import { LightningElement } from 'lwc'; +export default class Test extends LightningElement { + #validate(input) { + return input != null; + } + #transform(input) { + return String(input).trim(); + } + #process(input) { + if (this.#validate(input)) { + return this.#transform(input); + } + return null; + } + connectedCallback() { + this.#process('hello'); + } +} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/error.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js new file mode 100644 index 0000000000..342f8f09a2 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js @@ -0,0 +1,26 @@ +import _tmpl from "./test.html"; +import { LightningElement, registerComponent as _registerComponent } from 'lwc'; +class Test extends LightningElement { + #validate(input) { + return input != null; + } + #transform(input) { + return String(input).trim(); + } + #process(input) { + if (this.#validate(input)) { + return this.#transform(input); + } + return null; + } + connectedCallback() { + this.#process('hello'); + } + /*LWC compiler vX.X.X*/ +} +const __lwc_component_class_internal = _registerComponent(Test, { + tmpl: _tmpl, + sel: "lwc-test", + apiVersion: 9999999 +}); +export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js new file mode 100644 index 0000000000..0a5b314d2f --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js @@ -0,0 +1,13 @@ +import { LightningElement, api } from 'lwc'; +export default class Test extends LightningElement { + @api label = 'default'; + count = 0; + + #increment() { + this.count++; + } + + handleClick() { + this.#increment(); + } +} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/error.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js new file mode 100644 index 0000000000..39189e9ed6 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js @@ -0,0 +1,31 @@ +import { registerDecorators as _registerDecorators } from "lwc"; +import _tmpl from "./test.html"; +import { LightningElement, registerComponent as _registerComponent } from 'lwc'; +class Test extends LightningElement { + constructor(...args) { + super(...args); + this.label = 'default'; + this.count = 0; + } + #increment() { + this.count++; + } + handleClick() { + this.#increment(); + } + /*LWC compiler vX.X.X*/ +} +_registerDecorators(Test, { + publicProps: { + label: { + config: 0 + } + }, + fields: ["count"] +}); +const __lwc_component_class_internal = _registerComponent(Test, { + tmpl: _tmpl, + sel: "lwc-test", + apiVersion: 9999999 +}); +export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js new file mode 100644 index 0000000000..798cfb0570 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js @@ -0,0 +1,10 @@ +import { LightningElement } from 'lwc'; +export default class Test extends LightningElement { + #privateHelper() { + return 42; + } + connectedCallback() { + const val = this.#privateHelper(); + console.log(val); + } +} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/error.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js new file mode 100644 index 0000000000..a476373283 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js @@ -0,0 +1,18 @@ +import _tmpl from "./test.html"; +import { LightningElement, registerComponent as _registerComponent } from 'lwc'; +class Test extends LightningElement { + #privateHelper() { + return 42; + } + connectedCallback() { + const val = this.#privateHelper(); + console.log(val); + } + /*LWC compiler vX.X.X*/ +} +const __lwc_component_class_internal = _registerComponent(Test, { + tmpl: _tmpl, + sel: "lwc-test", + apiVersion: 9999999 +}); +export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js new file mode 100644 index 0000000000..97d542dd57 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js @@ -0,0 +1,11 @@ +import { LightningElement } from 'lwc'; +export default class Test extends LightningElement { + static async #fetchData(url) { + const response = await fetch(url); + return response.json(); + } + async connectedCallback() { + const data = await Test.#fetchData('/api/data'); + console.log(data); + } +} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/error.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js new file mode 100644 index 0000000000..3857bcb685 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js @@ -0,0 +1,19 @@ +import _tmpl from "./test.html"; +import { LightningElement, registerComponent as _registerComponent } from 'lwc'; +class Test extends LightningElement { + static async #fetchData(url) { + const response = await fetch(url); + return response.json(); + } + async connectedCallback() { + const data = await Test.#fetchData('/api/data'); + console.log(data); + } + /*LWC compiler vX.X.X*/ +} +const __lwc_component_class_internal = _registerComponent(Test, { + tmpl: _tmpl, + sel: "lwc-test", + apiVersion: 9999999 +}); +export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js new file mode 100644 index 0000000000..6d89da0959 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js @@ -0,0 +1,4 @@ +import { LightningElement } from 'lwc'; +export default class Test extends LightningElement { + #count = 0; +} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json new file mode 100644 index 0000000000..f587efa9fa --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json @@ -0,0 +1,10 @@ +{ + "message": "LWC1214: Private fields are not currently supported. Only private methods are supported.", + "loc": { + "line": 3, + "column": 4, + "start": 97, + "length": 11 + }, + "filename": "test.js" +} \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/expected.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js new file mode 100644 index 0000000000..89aaae1cd4 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js @@ -0,0 +1,6 @@ +import { LightningElement } from 'lwc'; +export default class Test extends LightningElement { + get #value() { + return this._val; + } +} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json new file mode 100644 index 0000000000..20d15cf784 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json @@ -0,0 +1,10 @@ +{ + "message": "LWC1214: Private accessor methods are not currently supported. Only private methods are supported.", + "loc": { + "line": 3, + "column": 4, + "start": 97, + "length": 46 + }, + "filename": "test.js" +} \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/expected.js new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts index 64c84f61af..07b7e6b8a8 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts @@ -7,9 +7,14 @@ import path from 'node:path'; import { describe } from 'vitest'; import { transformSync } from '@babel/core'; +import babelClassPropertiesPlugin from '@babel/plugin-transform-class-properties'; import { LWC_VERSION, HIGHEST_API_VERSION } from '@lwc/shared'; import { testFixtureDir } from '@lwc/test-utils-lwc-internals'; -import plugin, { type LwcBabelPluginOptions } from '../index'; +import plugin, { + LwcPrivateMethodTransform, + LwcReversePrivateMethodTransform, + type LwcBabelPluginOptions, +} from '../index'; interface TestConfig extends LwcBabelPluginOptions { experimentalErrorRecoveryMode?: boolean; @@ -82,7 +87,7 @@ describe('fixtures', () => { testFixtureDir( { root: path.resolve(import.meta.dirname, 'fixtures'), - pattern: '**/actual.js', + pattern: '!(private-methods)/**/actual.js', ssrVersion: 2, }, ({ src, config }) => { @@ -110,3 +115,55 @@ describe('fixtures', () => { } ); }); + +function transformWithPrivateMethodPipeline(source: string, opts = {}) { + const testConfig = { + ...BASE_CONFIG, + parserOpts: (opts as any).parserOpts ?? {}, + plugins: [ + LwcPrivateMethodTransform, + [plugin, { ...BASE_OPTS, ...opts }], + [babelClassPropertiesPlugin, { loose: true }], + LwcReversePrivateMethodTransform, + ], + }; + + const result = transformSync(source, testConfig)!; + + let { code } = result; + + code = code!.replace(new RegExp(LWC_VERSION.replace(/\./g, '\\.'), 'g'), 'X.X.X'); + + code = code.replace( + new RegExp(`apiVersion: ${HIGHEST_API_VERSION}`, 'g'), + `apiVersion: 9999999` + ); + + return { code }; +} + +describe('private-methods fixtures', () => { + testFixtureDir( + { + root: path.resolve(import.meta.dirname, 'fixtures', 'private-methods'), + pattern: '**/actual.js', + ssrVersion: 2, + }, + ({ src, config }) => { + let code; + let error; + + try { + const result = transformWithPrivateMethodPipeline(src, config); + code = result.code; + } catch (err) { + error = JSON.stringify(normalizeError(err), null, 4); + } + + return { + 'expected.js': code, + 'error.json': error, + }; + } + ); +}); diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index f81c653604..df65dff763 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -169,23 +169,34 @@ describe('private method transform validation', () => { expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); - test('private getters and setters are not transformed', () => { + test('private getter throws unsupported error', () => { const source = ` import { LightningElement } from 'lwc'; export default class Test extends LightningElement { get #value() { return this._val; } + } + `; + + expect(() => transformWithFullPipeline(source)).toThrowError( + /Private accessor methods are not currently supported\. Only private methods are supported\./ + ); + }); + + test('private setter throws unsupported error', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { set #value(v) { this._val = v; } } `; - const result = transformWithFullPipeline(source); - expect(result.code).toContain('get #value'); - expect(result.code).toContain('set #value'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); + expect(() => transformWithFullPipeline(source)).toThrowError( + /Private accessor methods are not currently supported\. Only private methods are supported\./ + ); }); test('decorated private method throws', () => { @@ -590,20 +601,20 @@ describe('private method transform validation', () => { expect(code).not.toContain('#foo'); }); - test('private fields are not transformed', () => { + test('private field throws unsupported error', () => { const source = ` import { LightningElement } from 'lwc'; export default class Test extends LightningElement { #count = 0; - #name = 'test'; } `; - const result = transformWithFullPipeline(source); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); + expect(() => transformWithFullPipeline(source)).toThrowError( + /Private fields are not currently supported\. Only private methods are supported\./ + ); }); - test('private fields alongside private methods are handled correctly', () => { + test('private field alongside private method throws unsupported error', () => { const source = ` import { LightningElement } from 'lwc'; export default class Test extends LightningElement { @@ -614,9 +625,9 @@ describe('private method transform validation', () => { } `; - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#increment'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); + expect(() => transformWithFullPipeline(source)).toThrowError( + /Private fields are not currently supported/ + ); }); test('private method call sites do not leak prefixed names after round-trip', () => { @@ -637,36 +648,16 @@ describe('private method transform validation', () => { expect(code).not.toContain('__lwc_component_class_internal_private_'); }); - test('private field reference in method body survives round-trip', () => { + test('private field with initializer throws unsupported error', () => { const source = ` import { LightningElement } from 'lwc'; export default class Test extends LightningElement { #state = { ready: false }; - #init() { - this.#state.ready = true; - } } `; - const result = transformWithFullPipeline(source); - const code = result.code!; - expect(code).toContain('#init'); - expect(code).toContain('this.#state.ready = true'); - expect(code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('forward transform only renames method declarations, not field declarations', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #secret = 42; - #getSecret() { return this.#secret; } - } - `; - - const result = transformForwardOnly(source); - const code = result.code!; - expect(code).toContain('__lwc_component_class_internal_private_getSecret'); - expect(code).toContain('#secret'); + expect(() => transformWithFullPipeline(source)).toThrowError( + /Private fields are not currently supported/ + ); }); }); diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts index 48d1691d70..84a5d16dfd 100644 --- a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -43,46 +43,73 @@ export default function privateMethodTransform({ methodState: LwcBabelPluginPass ) { const key = methodPath.get('key'); + if (!key.isPrivateName()) { + return; + } - if (key.isPrivateName() && methodPath.node.kind === METHOD_KIND) { - const node = methodPath.node; + if (methodPath.node.kind !== METHOD_KIND) { + handleError( + methodPath, + { + errorInfo: DecoratorErrors.UNSUPPORTED_PRIVATE_MEMBER, + messageArgs: ['accessor methods'], + }, + methodState + ); + return; + } - // 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 node = methodPath.node; - const privateName = key.node.id.name; - const transformedName = `${PRIVATE_METHOD_PREFIX}${privateName}`; - const keyReplacement = t.identifier(transformedName); + // 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; + } - // Create a new ClassMethod node to replace the ClassPrivateMethod - // https://babeljs.io/docs/babel-types#classmethod - const classMethod = t.classMethod( - METHOD_KIND, - keyReplacement, - node.params, - node.body, - node.computed, - node.static, - node.generator, - node.async - ) as types.ClassMethod; + const privateName = key.node.id.name; + const transformedName = `${PRIVATE_METHOD_PREFIX}${privateName}`; + const keyReplacement = t.identifier(transformedName); - copyMethodMetadata(node, classMethod); + // Create a new ClassMethod node to replace the ClassPrivateMethod + // https://babeljs.io/docs/babel-types#classmethod + const classMethod = t.classMethod( + METHOD_KIND, + keyReplacement, + node.params, + node.body, + node.computed, + node.static, + node.generator, + node.async + ) as types.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); - } + copyMethodMetadata(node, 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); + }, + + ClassPrivateProperty( + propPath: NodePath, + propState: LwcBabelPluginPass + ) { + handleError( + propPath, + { + errorInfo: DecoratorErrors.UNSUPPORTED_PRIVATE_MEMBER, + messageArgs: ['fields'], + }, + propState + ); }, }, state diff --git a/packages/@lwc/errors/src/compiler/error-info/index.ts b/packages/@lwc/errors/src/compiler/error-info/index.ts index 0c7fa0d366..b03279eff4 100644 --- a/packages/@lwc/errors/src/compiler/error-info/index.ts +++ b/packages/@lwc/errors/src/compiler/error-info/index.ts @@ -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: 1214 + * Next error code: 1215 */ export * from './compiler'; diff --git a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts index 7703601947..75ae09c6f8 100644 --- a/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts +++ b/packages/@lwc/errors/src/compiler/error-info/lwc-class.ts @@ -234,4 +234,11 @@ export const DecoratorErrors = { level: DiagnosticLevel.Error, url: 'https://lwc.dev/guide/error_codes#lwc1213', }, + + UNSUPPORTED_PRIVATE_MEMBER: { + code: 1214, + message: 'Private {0} are not currently supported. Only private methods are supported.', + level: DiagnosticLevel.Error, + url: 'https://lwc.dev/guide/error_codes#lwc1214', + }, } as const satisfies Record; From 380c24df1674bcab47d6685237ca532caa55e8b4 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Mon, 2 Mar 2026 15:38:10 -0500 Subject: [PATCH 18/30] chore: retrigger CI Made-with: Cursor From f18b31715d02037f390e4b6c1194f781d2517b92 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 10:50:48 -0500 Subject: [PATCH 19/30] feat: transform private method invocations in forward and reverse passes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../private-method-transform.spec.ts | 128 ++++++++++++++++++ .../src/private-method-transform.ts | 27 ++++ .../src/reverse-private-method-transform.ts | 21 ++- 3 files changed, 175 insertions(+), 1 deletion(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index df65dff763..c00b92c538 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -660,4 +660,132 @@ describe('private method transform validation', () => { /Private fields are not currently supported/ ); }); + + test('forward-only output transforms call sites to prefixed names', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #doWork(x) { return x * 2; } + connectedCallback() { + const result = this.#doWork(21); + console.log(result); + } + } + `; + + const result = transformForwardOnly(source); + const code = result.code!; + expect(code).toContain('this.__lwc_component_class_internal_private_doWork(21)'); + expect(code).not.toContain('this.#doWork'); + }); + + test('forward references in call sites are transformed', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + connectedCallback() { + return this.#helper(); + } + #helper() { return 42; } + } + `; + + const result = transformForwardOnly(source); + const code = result.code!; + expect(code).toContain('this.__lwc_component_class_internal_private_helper()'); + expect(code).not.toContain('this.#helper'); + + const roundTrip = transformWithFullPipeline(source); + expect(roundTrip.code).toContain('this.#helper()'); + expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('multiple invocations of the same private method are all transformed', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #compute(x) { return x * 2; } + run() { + const a = this.#compute(1); + const b = this.#compute(2); + const c = this.#compute(3); + return a + b + c; + } + } + `; + + const result = transformForwardOnly(source); + const code = result.code!; + const matches = code.match(/__lwc_component_class_internal_private_compute/g); + // 1 definition + 3 call sites = 4 occurrences + expect(matches).toHaveLength(4); + + const roundTrip = transformWithFullPipeline(source); + expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); + const privateMatches = roundTrip.code!.match(/#compute/g); + expect(privateMatches).toHaveLength(4); + }); + + test('self-referencing private method round-trips', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #recursive(n) { + if (n <= 0) return 0; + return n + this.#recursive(n - 1); + } + } + `; + + const result = transformForwardOnly(source); + const code = result.code!; + expect(code).toContain('this.__lwc_component_class_internal_private_recursive(n - 1)'); + + const roundTrip = transformWithFullPipeline(source); + expect(roundTrip.code).toContain('this.#recursive(n - 1)'); + expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('private method reference without call round-trips', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #handler() { return 42; } + connectedCallback() { + const fn = this.#handler; + setTimeout(this.#handler, 100); + } + } + `; + + const result = transformForwardOnly(source); + const code = result.code!; + expect(code).toContain('this.__lwc_component_class_internal_private_handler;'); + expect(code).toContain('this.__lwc_component_class_internal_private_handler, 100'); + expect(code).not.toContain('this.#handler'); + + const roundTrip = transformWithFullPipeline(source); + expect(roundTrip.code).toContain('this.#handler;'); + expect(roundTrip.code).toContain('this.#handler, 100'); + expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); + }); + + test('cross-method private call sites in forward-only output', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Test extends LightningElement { + #helper() { return 42; } + #caller() { + return this.#helper() + 1; + } + } + `; + + const result = transformForwardOnly(source); + const code = result.code!; + expect(code).toContain('this.__lwc_component_class_internal_private_helper()'); + expect(code).toContain('__lwc_component_class_internal_private_caller'); + expect(code).not.toContain('#helper'); + expect(code).not.toContain('#caller'); + }); }); diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts index 84a5d16dfd..c864f4dd42 100644 --- a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -36,6 +36,20 @@ export default function privateMethodTransform({ Program(path: NodePath, state: LwcBabelPluginPass) { const transformedNames = new Set(); + // Phase 1: Collect base names of all private methods (kind: 'method') + // so that Phase 2 can transform invocations even for forward references + // (call site visited before the method definition). + const privateMethodBaseNames = new Set(); + path.traverse({ + ClassPrivateMethod(methodPath: NodePath) { + const key = methodPath.get('key'); + if (key.isPrivateName() && methodPath.node.kind === METHOD_KIND) { + privateMethodBaseNames.add(key.node.id.name); + } + }, + }); + + // Phase 2: Transform definitions and invocations path.traverse( { ClassPrivateMethod( @@ -98,6 +112,19 @@ export default function privateMethodTransform({ transformedNames.add(transformedName); }, + MemberExpression(memberPath: NodePath) { + const property = memberPath.node.property; + if (t.isPrivateName(property)) { + const baseName = (property as types.PrivateName).id.name; + if (privateMethodBaseNames.has(baseName)) { + const prefixedName = `${PRIVATE_METHOD_PREFIX}${baseName}`; + memberPath + .get('property') + .replaceWith(t.identifier(prefixedName)); + } + } + }, + ClassPrivateProperty( propPath: NodePath, propState: LwcBabelPluginPass diff --git a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts index 9cbfee4dae..898a3f26f7 100644 --- a/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts +++ b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts @@ -12,7 +12,8 @@ import type { types, NodePath, PluginObj } from '@babel/core'; /** * Standalone Babel plugin that reverses the private method transformation by converting - * methods with prefix {@link PRIVATE_METHOD_PREFIX} back to ClassPrivateMethod nodes. + * methods with prefix {@link PRIVATE_METHOD_PREFIX} back to ClassPrivateMethod nodes, + * and restoring prefixed MemberExpression properties back to PrivateName nodes. * * This must be registered AFTER @babel/plugin-transform-class-properties so that * class properties are fully transformed before private methods are restored. @@ -80,6 +81,24 @@ export default function reversePrivateMethodTransform({ } }, + MemberExpression(path: NodePath, state: LwcBabelPluginPass) { + const property = path.node.property; + if (!t.isIdentifier(property) || !property.name.startsWith(PRIVATE_METHOD_PREFIX)) { + return; + } + + const forwardTransformedNames: Set | undefined = ( + state.file.metadata as any + )[PRIVATE_METHOD_METADATA_KEY]; + + if (!forwardTransformedNames || !forwardTransformedNames.has(property.name)) { + return; + } + + const originalName = property.name.replace(PRIVATE_METHOD_PREFIX, ''); + path.get('property').replaceWith(t.privateName(t.identifier(originalName))); + }, + // 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 From 34610100bfba04462f3fe86ba13b84c6835230c1 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 11:02:47 -0500 Subject: [PATCH 20/30] feat: gate private method transform behind enablePrivateMethods compiler flag The private method round-trip transform is now only applied when enablePrivateMethods is explicitly set to true in TransformOptions, allowing the feature to be restricted to internal components. Made-with: Cursor --- packages/@lwc/compiler/src/options.ts | 3 ++ .../__tests__/transform-javascript.spec.ts | 32 +++++++++++++++++++ .../compiler/src/transformers/javascript.ts | 5 +-- 3 files changed, 38 insertions(+), 2 deletions(-) diff --git a/packages/@lwc/compiler/src/options.ts b/packages/@lwc/compiler/src/options.ts index ea8eba1ee6..5ac1238ef9 100755 --- a/packages/@lwc/compiler/src/options.ts +++ b/packages/@lwc/compiler/src/options.ts @@ -149,6 +149,8 @@ export interface TransformOptions { experimentalErrorRecoveryMode?: boolean; /** Full module path for a feature flag to import and enforce at runtime (e.g., '@salesforce/featureFlag/name'). */ componentFeatureFlagModulePath?: string; + /** Flag to enable the private method round-trip transform. When false or omitted, private methods pass through to standard Babel handling. */ + enablePrivateMethods?: boolean; } type OptionalTransformKeys = @@ -164,6 +166,7 @@ type OptionalTransformKeys = | 'experimentalDynamicDirective' | 'dynamicImports' | 'componentFeatureFlagModulePath' + | 'enablePrivateMethods' | 'instrumentation'; type RequiredTransformOptions = RecursiveRequired>; diff --git a/packages/@lwc/compiler/src/transformers/__tests__/transform-javascript.spec.ts b/packages/@lwc/compiler/src/transformers/__tests__/transform-javascript.spec.ts index 62b2dba1b2..61435ade51 100644 --- a/packages/@lwc/compiler/src/transformers/__tests__/transform-javascript.spec.ts +++ b/packages/@lwc/compiler/src/transformers/__tests__/transform-javascript.spec.ts @@ -224,6 +224,38 @@ describe('unnecessary registerDecorators', () => { }); }); +describe('enablePrivateMethods', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class Foo extends LightningElement { + #secret() { return 42; } + connectedCallback() { this.#secret(); } + } + `; + + it('should transform and restore private methods when enabled', async () => { + const { code } = await transform(source, 'foo.js', { + ...BASE_TRANSFORM_OPTIONS, + enablePrivateMethods: true, + }); + expect(code).toContain('#secret'); + expect(code).not.toContain('__lwc_component_class_internal_private_'); + }); + + it('should reject private methods when disabled', async () => { + await expect( + transform(source, 'foo.js', { + ...BASE_TRANSFORM_OPTIONS, + enablePrivateMethods: false, + }) + ).rejects.toThrow(); + }); + + it('should reject private methods when omitted', async () => { + await expect(transform(source, 'foo.js', BASE_TRANSFORM_OPTIONS)).rejects.toThrow(); + }); +}); + describe('sourcemaps', () => { it("should generate inline sourcemaps when the output config includes the 'inline' option for sourcemaps", () => { const source = ` diff --git a/packages/@lwc/compiler/src/transformers/javascript.ts b/packages/@lwc/compiler/src/transformers/javascript.ts index aaf3eaf583..0e25cecb9b 100755 --- a/packages/@lwc/compiler/src/transformers/javascript.ts +++ b/packages/@lwc/compiler/src/transformers/javascript.ts @@ -48,6 +48,7 @@ export default function scriptTransform( dynamicImports, outputConfig: { sourcemap }, enableLightningWebSecurityTransforms, + enablePrivateMethods, namespace, name, instrumentation, @@ -68,10 +69,10 @@ export default function scriptTransform( }; const plugins: babel.PluginItem[] = [ - LwcPrivateMethodTransform, + ...(enablePrivateMethods ? [LwcPrivateMethodTransform as babel.PluginItem] : []), [lwcClassTransformPlugin, lwcBabelPluginOptions], [babelClassPropertiesPlugin, { loose: true }], - LwcReversePrivateMethodTransform, + ...(enablePrivateMethods ? [LwcReversePrivateMethodTransform as babel.PluginItem] : []), ]; if (!isAPIFeatureEnabled(APIFeature.DISABLE_OBJECT_REST_SPREAD_TRANSFORMATION, apiVersion)) { From 2ba8959800b6a8b1b5685d6e967d50ec21e7582b Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 12:24:15 -0500 Subject: [PATCH 21/30] feat: thread enablePrivateMethods through rollup plugin and enable in playground Add enablePrivateMethods to RollupLwcOptions and forward it to the compiler's transformSync call. Enable it in the playground rollup config and add a private method to the counter component for testing. Made-with: Cursor --- packages/@lwc/rollup-plugin/src/index.ts | 3 +++ playground/rollup.config.js | 2 +- playground/src/modules/x/counter/counter.js | 5 +++++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/@lwc/rollup-plugin/src/index.ts b/packages/@lwc/rollup-plugin/src/index.ts index 4124e0d43f..938867cd13 100644 --- a/packages/@lwc/rollup-plugin/src/index.ts +++ b/packages/@lwc/rollup-plugin/src/index.ts @@ -74,6 +74,7 @@ export interface RollupLwcOptions { * @example '@salesforce/featureFlag/name' */ componentFeatureFlagModulePath?: string; + enablePrivateMethods?: boolean; } const PLUGIN_NAME = 'rollup-plugin-lwc-compiler'; @@ -198,6 +199,7 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { apiVersion, defaultModules = DEFAULT_MODULES, componentFeatureFlagModulePath, + enablePrivateMethods, } = pluginOptions; return { @@ -395,6 +397,7 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { targetSSR, ssrMode, componentFeatureFlagModulePath, + enablePrivateMethods, }); if (warnings) { diff --git a/playground/rollup.config.js b/playground/rollup.config.js index ed0dba638d..887d4ea815 100644 --- a/playground/rollup.config.js +++ b/playground/rollup.config.js @@ -21,7 +21,7 @@ export default (args) => { 'process.env.NODE_ENV': JSON.stringify(__ENV__), preventAssignment: true, }), - lwc(), + lwc({ enablePrivateMethods: true }), args.watch && serve({ open: false, diff --git a/playground/src/modules/x/counter/counter.js b/playground/src/modules/x/counter/counter.js index 058d61167c..1a6d955200 100644 --- a/playground/src/modules/x/counter/counter.js +++ b/playground/src/modules/x/counter/counter.js @@ -7,8 +7,13 @@ export default class extends LightningElement { increment() { this.counter++; + this.#privateMethod(); } decrement() { this.counter--; } + + #privateMethod() { + this.counter++; + } } From d38ae207bcc722c7ef9dec86ef0832bc782bcc72 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 13:27:16 -0500 Subject: [PATCH 22/30] chore: revert playground counter and rollup config changes Remove test private method from counter component and revert playground rollup config back to default lwc() options. Made-with: Cursor --- playground/rollup.config.js | 2 +- playground/src/modules/x/counter/counter.js | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/playground/rollup.config.js b/playground/rollup.config.js index 887d4ea815..ed0dba638d 100644 --- a/playground/rollup.config.js +++ b/playground/rollup.config.js @@ -21,7 +21,7 @@ export default (args) => { 'process.env.NODE_ENV': JSON.stringify(__ENV__), preventAssignment: true, }), - lwc({ enablePrivateMethods: true }), + lwc(), args.watch && serve({ open: false, diff --git a/playground/src/modules/x/counter/counter.js b/playground/src/modules/x/counter/counter.js index 1a6d955200..058d61167c 100644 --- a/playground/src/modules/x/counter/counter.js +++ b/playground/src/modules/x/counter/counter.js @@ -7,13 +7,8 @@ export default class extends LightningElement { increment() { this.counter++; - this.#privateMethod(); } decrement() { this.counter--; } - - #privateMethod() { - this.counter++; - } } From 446b34a7066625dc4cb5d6ee392456cd8d752476 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 15:28:42 -0500 Subject: [PATCH 23/30] test: move private-methods fixture tests to dedicated branch Remove fixture test files from this branch; they now live on a-chabot/private-methods-fixture-tests. Made-with: Cursor --- .../error.json => .gitkeep} | 0 .../multiple-private-methods/actual.js | 18 ----------- .../multiple-private-methods/expected.js | 26 ---------------- .../actual.js | 13 -------- .../error.json | 0 .../expected.js | 31 ------------------- .../simple-private-method/actual.js | 10 ------ .../simple-private-method/error.json | 0 .../simple-private-method/expected.js | 18 ----------- .../static-async-private-method/actual.js | 11 ------- .../static-async-private-method/error.json | 0 .../static-async-private-method/expected.js | 19 ------------ .../throws-on-private-field/actual.js | 4 --- .../throws-on-private-field/error.json | 10 ------ .../throws-on-private-field/expected.js | 0 .../throws-on-private-getter/actual.js | 6 ---- .../throws-on-private-getter/error.json | 10 ------ .../throws-on-private-getter/expected.js | 0 18 files changed, 176 deletions(-) rename packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/{multiple-private-methods/error.json => .gitkeep} (100%) delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/error.json delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/error.json delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/error.json delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/expected.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json delete mode 100644 packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/expected.js diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/.gitkeep similarity index 100% rename from packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/error.json rename to packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/.gitkeep diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js deleted file mode 100644 index 5b8160cc55..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/actual.js +++ /dev/null @@ -1,18 +0,0 @@ -import { LightningElement } from 'lwc'; -export default class Test extends LightningElement { - #validate(input) { - return input != null; - } - #transform(input) { - return String(input).trim(); - } - #process(input) { - if (this.#validate(input)) { - return this.#transform(input); - } - return null; - } - connectedCallback() { - this.#process('hello'); - } -} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js deleted file mode 100644 index 342f8f09a2..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/multiple-private-methods/expected.js +++ /dev/null @@ -1,26 +0,0 @@ -import _tmpl from "./test.html"; -import { LightningElement, registerComponent as _registerComponent } from 'lwc'; -class Test extends LightningElement { - #validate(input) { - return input != null; - } - #transform(input) { - return String(input).trim(); - } - #process(input) { - if (this.#validate(input)) { - return this.#transform(input); - } - return null; - } - connectedCallback() { - this.#process('hello'); - } - /*LWC compiler vX.X.X*/ -} -const __lwc_component_class_internal = _registerComponent(Test, { - tmpl: _tmpl, - sel: "lwc-test", - apiVersion: 9999999 -}); -export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js deleted file mode 100644 index 0a5b314d2f..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/actual.js +++ /dev/null @@ -1,13 +0,0 @@ -import { LightningElement, api } from 'lwc'; -export default class Test extends LightningElement { - @api label = 'default'; - count = 0; - - #increment() { - this.count++; - } - - handleClick() { - this.#increment(); - } -} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/error.json deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js deleted file mode 100644 index 39189e9ed6..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/private-method-with-public-members/expected.js +++ /dev/null @@ -1,31 +0,0 @@ -import { registerDecorators as _registerDecorators } from "lwc"; -import _tmpl from "./test.html"; -import { LightningElement, registerComponent as _registerComponent } from 'lwc'; -class Test extends LightningElement { - constructor(...args) { - super(...args); - this.label = 'default'; - this.count = 0; - } - #increment() { - this.count++; - } - handleClick() { - this.#increment(); - } - /*LWC compiler vX.X.X*/ -} -_registerDecorators(Test, { - publicProps: { - label: { - config: 0 - } - }, - fields: ["count"] -}); -const __lwc_component_class_internal = _registerComponent(Test, { - tmpl: _tmpl, - sel: "lwc-test", - apiVersion: 9999999 -}); -export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js deleted file mode 100644 index 798cfb0570..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/actual.js +++ /dev/null @@ -1,10 +0,0 @@ -import { LightningElement } from 'lwc'; -export default class Test extends LightningElement { - #privateHelper() { - return 42; - } - connectedCallback() { - const val = this.#privateHelper(); - console.log(val); - } -} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/error.json deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js deleted file mode 100644 index a476373283..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/simple-private-method/expected.js +++ /dev/null @@ -1,18 +0,0 @@ -import _tmpl from "./test.html"; -import { LightningElement, registerComponent as _registerComponent } from 'lwc'; -class Test extends LightningElement { - #privateHelper() { - return 42; - } - connectedCallback() { - const val = this.#privateHelper(); - console.log(val); - } - /*LWC compiler vX.X.X*/ -} -const __lwc_component_class_internal = _registerComponent(Test, { - tmpl: _tmpl, - sel: "lwc-test", - apiVersion: 9999999 -}); -export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js deleted file mode 100644 index 97d542dd57..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/actual.js +++ /dev/null @@ -1,11 +0,0 @@ -import { LightningElement } from 'lwc'; -export default class Test extends LightningElement { - static async #fetchData(url) { - const response = await fetch(url); - return response.json(); - } - async connectedCallback() { - const data = await Test.#fetchData('/api/data'); - console.log(data); - } -} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/error.json deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js deleted file mode 100644 index 3857bcb685..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/static-async-private-method/expected.js +++ /dev/null @@ -1,19 +0,0 @@ -import _tmpl from "./test.html"; -import { LightningElement, registerComponent as _registerComponent } from 'lwc'; -class Test extends LightningElement { - static async #fetchData(url) { - const response = await fetch(url); - return response.json(); - } - async connectedCallback() { - const data = await Test.#fetchData('/api/data'); - console.log(data); - } - /*LWC compiler vX.X.X*/ -} -const __lwc_component_class_internal = _registerComponent(Test, { - tmpl: _tmpl, - sel: "lwc-test", - apiVersion: 9999999 -}); -export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js deleted file mode 100644 index 6d89da0959..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/actual.js +++ /dev/null @@ -1,4 +0,0 @@ -import { LightningElement } from 'lwc'; -export default class Test extends LightningElement { - #count = 0; -} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json deleted file mode 100644 index f587efa9fa..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/error.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "message": "LWC1214: Private fields are not currently supported. Only private methods are supported.", - "loc": { - "line": 3, - "column": 4, - "start": 97, - "length": 11 - }, - "filename": "test.js" -} \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-field/expected.js deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js deleted file mode 100644 index 89aaae1cd4..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/actual.js +++ /dev/null @@ -1,6 +0,0 @@ -import { LightningElement } from 'lwc'; -export default class Test extends LightningElement { - get #value() { - return this._val; - } -} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json deleted file mode 100644 index 20d15cf784..0000000000 --- a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/error.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "message": "LWC1214: Private accessor methods are not currently supported. Only private methods are supported.", - "loc": { - "line": 3, - "column": 4, - "start": 97, - "length": 46 - }, - "filename": "test.js" -} \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/throws-on-private-getter/expected.js deleted file mode 100644 index e69de29bb2..0000000000 From 197d2ffbfa7216921a7efad161127effcd6e61f1 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 15:30:23 -0500 Subject: [PATCH 24/30] test: remove inline tests now covered by fixture tests Remove 16 inline tests from private-method-transform.spec.ts that are now covered by fixture-based tests on the fixture branch. Made-with: Cursor --- .../private-method-transform.spec.ts | 278 ------------------ 1 file changed, 278 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index c00b92c538..027bd5b591 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -77,21 +77,6 @@ describe('private method transform validation', () => { expect(result.code).toContain('#methodC'); }); - test('throws error when user-defined method collides with reserved prefix', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - __lwc_component_class_internal_private_sneakyMethod() { - return 'collision'; - } - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /cannot start with reserved prefix `__lwc_`\. Please rename this function to avoid conflict/ - ); - }); - test('throws error when collision exists alongside real private methods', () => { const source = ` import { LightningElement } from 'lwc'; @@ -124,21 +109,6 @@ describe('private method transform validation', () => { expect(result.code).toContain('_underscoreMethod'); }); - test('async private method round-trips successfully', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - async #fetchData() { - return await Promise.resolve(42); - } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('async #fetchData'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - test('static private method round-trips successfully', () => { const source = ` import { LightningElement } from 'lwc'; @@ -154,68 +124,6 @@ describe('private method transform validation', () => { expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); - test('private method with parameters round-trips successfully', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #compute(a, b, ...rest) { - return a + b + rest.length; - } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#compute(a, b, ...rest)'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('private getter throws unsupported error', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - get #value() { - return this._val; - } - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /Private accessor methods are not currently supported\. Only private methods are supported\./ - ); - }); - - test('private setter throws unsupported error', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - set #value(v) { - this._val = v; - } - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /Private accessor methods are not currently supported\. Only private methods are supported\./ - ); - }); - - test('decorated private method throws', () => { - const source = ` - import { LightningElement, api } from 'lwc'; - export default class Test extends LightningElement { - @api #decorated() { - return 1; - } - } - `; - - // The forward private method transform runs as a separate plugin before the - // main LWC plugin, so LWC1212 fires before the @api decorator validation. - expect(() => transformWithFullPipeline(source)).toThrowError( - /Decorators cannot be applied to private methods/ - ); - }); - test('class with zero private methods succeeds', () => { const source = ` import { LightningElement } from 'lwc'; @@ -260,23 +168,6 @@ describe('private method transform validation', () => { ); }); - test('generator private method round-trips successfully', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - *#generate() { - yield 1; - yield 2; - } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#generate'); - expect(result.code).toContain('yield'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - test('reverse standalone on clean code succeeds without forward metadata', () => { const source = ` class Test { @@ -339,23 +230,6 @@ describe('private method transform validation', () => { ).toThrowError(/Private method transform count mismatch/); }); - test('multiple classes in the same file round-trip private methods', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class First extends LightningElement { - #shared() { return 'first'; } - } - class Second extends LightningElement { - #shared() { return 'second'; } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - const matches = result.code!.match(/#shared/g); - expect(matches).toHaveLength(2); - }); - test('private method body with call sites round-trips', () => { const source = ` import { LightningElement } from 'lwc'; @@ -431,72 +305,6 @@ describe('private method transform validation', () => { expect(gammaIdx).toBeLessThan(deltaIdx); }); - test('default parameter values survive round-trip', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #greet(name = 'world', times = 3) { - return name.repeat(times); - } - } - `; - - const result = transformWithFullPipeline(source); - const code = result.code!; - expect(code).toContain('#greet'); - expect(code).toContain("'world'"); - expect(code).toContain('3'); - expect(code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('destructuring parameters survive round-trip', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #process({ x, y }, [a, b]) { - return x + y + a + b; - } - } - `; - - const result = transformWithFullPipeline(source); - const code = result.code!; - expect(code).toContain('#process'); - expect(code).toMatch(/\{\s*x,\s*y\s*\}/); - expect(code).toMatch(/\[\s*a,\s*b\s*\]/); - expect(code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('empty method body round-trips', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #noop() {} - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#noop'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('private and public method with same name coexist', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #foo() { return 'private'; } - foo() { return 'public'; } - } - `; - - const result = transformWithFullPipeline(source); - const code = result.code!; - expect(code).toContain('#foo'); - expect(code).toContain("return 'private'"); - expect(code).toContain("return 'public'"); - expect(code).not.toContain('__lwc_component_class_internal_private_'); - }); - test('intermediate plugin that modifies method body does not break reverse transform', () => { function bodyModifierPlugin({ types: t }: any) { return { @@ -601,35 +409,6 @@ describe('private method transform validation', () => { expect(code).not.toContain('#foo'); }); - test('private field throws unsupported error', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #count = 0; - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /Private fields are not currently supported\. Only private methods are supported\./ - ); - }); - - test('private field alongside private method throws unsupported error', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #count = 0; - #increment() { - this.#count++; - } - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /Private fields are not currently supported/ - ); - }); - test('private method call sites do not leak prefixed names after round-trip', () => { const source = ` import { LightningElement } from 'lwc'; @@ -648,19 +427,6 @@ describe('private method transform validation', () => { expect(code).not.toContain('__lwc_component_class_internal_private_'); }); - test('private field with initializer throws unsupported error', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #state = { ready: false }; - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /Private fields are not currently supported/ - ); - }); - test('forward-only output transforms call sites to prefixed names', () => { const source = ` import { LightningElement } from 'lwc'; @@ -726,50 +492,6 @@ describe('private method transform validation', () => { expect(privateMatches).toHaveLength(4); }); - test('self-referencing private method round-trips', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #recursive(n) { - if (n <= 0) return 0; - return n + this.#recursive(n - 1); - } - } - `; - - const result = transformForwardOnly(source); - const code = result.code!; - expect(code).toContain('this.__lwc_component_class_internal_private_recursive(n - 1)'); - - const roundTrip = transformWithFullPipeline(source); - expect(roundTrip.code).toContain('this.#recursive(n - 1)'); - expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('private method reference without call round-trips', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #handler() { return 42; } - connectedCallback() { - const fn = this.#handler; - setTimeout(this.#handler, 100); - } - } - `; - - const result = transformForwardOnly(source); - const code = result.code!; - expect(code).toContain('this.__lwc_component_class_internal_private_handler;'); - expect(code).toContain('this.__lwc_component_class_internal_private_handler, 100'); - expect(code).not.toContain('this.#handler'); - - const roundTrip = transformWithFullPipeline(source); - expect(roundTrip.code).toContain('this.#handler;'); - expect(roundTrip.code).toContain('this.#handler, 100'); - expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); - }); - test('cross-method private call sites in forward-only output', () => { const source = ` import { LightningElement } from 'lwc'; From f02451828349801237948c75827d7cf9930c5cc7 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 16:33:00 -0500 Subject: [PATCH 25/30] test: remove inline tests now covered by fixture tests Remove 13 inline tests from private-method-transform.spec.ts that are now redundant with fixture tests (8 already covered, 5 converted to new fixtures). Add comments to the 10 remaining inline tests explaining why they must stay inline (custom pipelines, forward-only, reverse-only). Made-with: Cursor --- .../private-method-transform.spec.ts | 225 +----------------- 1 file changed, 10 insertions(+), 215 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index 027bd5b591..740fbf2527 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -46,128 +46,7 @@ function transformForwardOnly(source: string, opts = {}) { } describe('private method transform validation', () => { - test('normal private methods round-trip successfully', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #privateMethod() { - return 42; - } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#privateMethod'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('multiple private methods round-trip successfully', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #methodA() { return 1; } - #methodB() { return 2; } - #methodC() { return 3; } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#methodA'); - expect(result.code).toContain('#methodB'); - expect(result.code).toContain('#methodC'); - }); - - test('throws error when collision exists alongside real private methods', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #realPrivate() { return 1; } - __lwc_component_class_internal_private_fakePrivate() { - return 'collision'; - } - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /cannot start with reserved prefix `__lwc_`\. Please rename this function to avoid conflict/ - ); - }); - - test('does not flag methods that do not use the reserved prefix', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #privateMethod() { return 1; } - normalPublicMethod() { return 2; } - _underscoreMethod() { return 3; } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#privateMethod'); - expect(result.code).toContain('normalPublicMethod'); - expect(result.code).toContain('_underscoreMethod'); - }); - - test('static private method round-trips successfully', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - static #helper() { - return 'static'; - } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('static #helper'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('class with zero private methods succeeds', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - publicMethod() { return 1; } - anotherPublic() { return 2; } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('publicMethod'); - expect(result.code).toContain('anotherPublic'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('error message includes the specific offending method name', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - __lwc_component_class_internal_private_mySpecificName() { - return 'collision'; - } - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /__lwc_component_class_internal_private_mySpecificName/ - ); - }); - - test('multiple collision methods throws on first encountered', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - __lwc_component_class_internal_private_collisionA() { return 1; } - __lwc_component_class_internal_private_collisionB() { return 2; } - } - `; - - expect(() => transformWithFullPipeline(source)).toThrowError( - /__lwc_component_class_internal_private_collision[AB]/ - ); - }); - + // Kept inline: uses reverse-only pipeline (no forward transform) test('reverse standalone on clean code succeeds without forward metadata', () => { const source = ` class Test { @@ -179,6 +58,7 @@ describe('private method transform validation', () => { expect(result.code).toContain('publicMethod'); }); + // Kept inline: uses reverse-only pipeline (no forward transform) test('reverse standalone with prefixed method throws collision when metadata is missing', () => { const source = ` class Test { @@ -191,6 +71,7 @@ describe('private method transform validation', () => { ); }); + // Kept inline: uses custom intermediate Babel plugin in a 4-plugin pipeline test('Program.exit count mismatch throws when forward-transformed method is removed', () => { const PREFIX = '__lwc_component_class_internal_private_'; @@ -230,24 +111,7 @@ describe('private method transform validation', () => { ).toThrowError(/Private method transform count mismatch/); }); - test('private method body with call sites round-trips', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #helper() { return 42; } - #caller() { - return this.#helper() + 1; - } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('#helper'); - expect(result.code).toContain('#caller'); - expect(result.code).toContain('this.#helper()'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - + // Kept inline: uses forward-only pipeline (no reverse transform) test('forward-only output contains correct prefixed names', () => { const source = ` import { LightningElement } from 'lwc'; @@ -264,47 +128,7 @@ describe('private method transform validation', () => { expect(result.code).not.toContain('#bar'); }); - test('combined flags (static, async, default param) survive round-trip', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - static async #fetch(url, opts = {}) { - return await fetch(url, opts); - } - } - `; - - const result = transformWithFullPipeline(source); - expect(result.code).toContain('static async #fetch(url, opts = {})'); - expect(result.code).not.toContain('__lwc_component_class_internal_private_'); - }); - - test('method ordering is preserved through round-trip', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #alpha() { return 'a'; } - publicBeta() { return 'b'; } - #gamma() { return 'c'; } - publicDelta() { return 'd'; } - } - `; - - const result = transformWithFullPipeline(source); - const code = result.code!; - const alphaIdx = code.indexOf('#alpha'); - const betaIdx = code.indexOf('publicBeta'); - const gammaIdx = code.indexOf('#gamma'); - const deltaIdx = code.indexOf('publicDelta'); - expect(alphaIdx).toBeGreaterThan(-1); - expect(betaIdx).toBeGreaterThan(-1); - expect(gammaIdx).toBeGreaterThan(-1); - expect(deltaIdx).toBeGreaterThan(-1); - expect(alphaIdx).toBeLessThan(betaIdx); - expect(betaIdx).toBeLessThan(gammaIdx); - expect(gammaIdx).toBeLessThan(deltaIdx); - }); - + // Kept inline: uses custom intermediate Babel plugin in a 4-plugin pipeline test('intermediate plugin that modifies method body does not break reverse transform', () => { function bodyModifierPlugin({ types: t }: any) { return { @@ -352,6 +176,7 @@ describe('private method transform validation', () => { expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); + // Kept inline: uses custom intermediate Babel plugin in a 4-plugin pipeline test('intermediate plugin that adds a prefixed method triggers collision', () => { function prefixedMethodInjectorPlugin({ types: t }: any) { let injected = false; @@ -393,40 +218,7 @@ describe('private method transform validation', () => { ).toThrowError(/cannot start with reserved prefix `__lwc_`/); }); - test('methods with similar but non-matching prefixes are not reverse-transformed', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - __lwc_component_class_internal_foo() { return 1; } - __lwc_component_class_internal_privatefoo() { return 2; } - } - `; - - const result = transformWithFullPipeline(source); - const code = result.code!; - expect(code).toContain('__lwc_component_class_internal_foo'); - expect(code).toContain('__lwc_component_class_internal_privatefoo'); - expect(code).not.toContain('#foo'); - }); - - test('private method call sites do not leak prefixed names after round-trip', () => { - const source = ` - import { LightningElement } from 'lwc'; - export default class Test extends LightningElement { - #doWork(x) { return x * 2; } - connectedCallback() { - const result = this.#doWork(21); - console.log(result); - } - } - `; - - const result = transformWithFullPipeline(source); - const code = result.code!; - expect(code).toContain('this.#doWork(21)'); - expect(code).not.toContain('__lwc_component_class_internal_private_'); - }); - + // Kept inline: uses forward-only pipeline (no reverse transform) test('forward-only output transforms call sites to prefixed names', () => { const source = ` import { LightningElement } from 'lwc'; @@ -445,6 +237,7 @@ describe('private method transform validation', () => { expect(code).not.toContain('this.#doWork'); }); + // Kept inline: uses forward-only pipeline (no reverse transform) test('forward references in call sites are transformed', () => { const source = ` import { LightningElement } from 'lwc'; @@ -466,6 +259,7 @@ describe('private method transform validation', () => { expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); }); + // Kept inline: uses forward-only pipeline to verify occurrence counts test('multiple invocations of the same private method are all transformed', () => { const source = ` import { LightningElement } from 'lwc'; @@ -492,6 +286,7 @@ describe('private method transform validation', () => { expect(privateMatches).toHaveLength(4); }); + // Kept inline: uses forward-only pipeline (no reverse transform) test('cross-method private call sites in forward-only output', () => { const source = ` import { LightningElement } from 'lwc'; From 4d605494cab8fc769aba7e7e2bda73a8c68d53d0 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 16:37:42 -0500 Subject: [PATCH 26/30] test: replace per-test comments with top-of-file blurb Consolidate the individual "Kept inline" comments into a single explanatory note at the top of the file describing why these tests cannot be fixture tests. Made-with: Cursor --- .../__tests__/private-method-transform.spec.ts | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index 740fbf2527..73a2d49f04 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -4,6 +4,14 @@ * SPDX-License-Identifier: MIT * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT */ + +// These tests exercise Babel pipelines that cannot be represented as fixture tests. +// Fixture tests run the full forward → main-plugin → class-properties → reverse pipeline, +// but the tests here need one of the following non-standard setups: +// • Forward-only pipeline (transformForwardOnly) — no reverse transform +// • Reverse-only pipeline (transformReverseOnly) — no forward transform +// • Custom intermediate Babel plugin inserted between the forward and reverse transforms + import { describe, expect, test } from 'vitest'; import { transformSync } from '@babel/core'; import plugin, { LwcPrivateMethodTransform, LwcReversePrivateMethodTransform } from '../index'; @@ -46,7 +54,6 @@ function transformForwardOnly(source: string, opts = {}) { } describe('private method transform validation', () => { - // Kept inline: uses reverse-only pipeline (no forward transform) test('reverse standalone on clean code succeeds without forward metadata', () => { const source = ` class Test { @@ -58,7 +65,6 @@ describe('private method transform validation', () => { expect(result.code).toContain('publicMethod'); }); - // Kept inline: uses reverse-only pipeline (no forward transform) test('reverse standalone with prefixed method throws collision when metadata is missing', () => { const source = ` class Test { @@ -71,7 +77,6 @@ describe('private method transform validation', () => { ); }); - // Kept inline: uses custom intermediate Babel plugin in a 4-plugin pipeline test('Program.exit count mismatch throws when forward-transformed method is removed', () => { const PREFIX = '__lwc_component_class_internal_private_'; @@ -111,7 +116,6 @@ describe('private method transform validation', () => { ).toThrowError(/Private method transform count mismatch/); }); - // Kept inline: uses forward-only pipeline (no reverse transform) test('forward-only output contains correct prefixed names', () => { const source = ` import { LightningElement } from 'lwc'; @@ -128,7 +132,6 @@ describe('private method transform validation', () => { expect(result.code).not.toContain('#bar'); }); - // Kept inline: uses custom intermediate Babel plugin in a 4-plugin pipeline test('intermediate plugin that modifies method body does not break reverse transform', () => { function bodyModifierPlugin({ types: t }: any) { return { @@ -176,7 +179,6 @@ describe('private method transform validation', () => { expect(result.code).not.toContain('__lwc_component_class_internal_private_'); }); - // Kept inline: uses custom intermediate Babel plugin in a 4-plugin pipeline test('intermediate plugin that adds a prefixed method triggers collision', () => { function prefixedMethodInjectorPlugin({ types: t }: any) { let injected = false; @@ -218,7 +220,6 @@ describe('private method transform validation', () => { ).toThrowError(/cannot start with reserved prefix `__lwc_`/); }); - // Kept inline: uses forward-only pipeline (no reverse transform) test('forward-only output transforms call sites to prefixed names', () => { const source = ` import { LightningElement } from 'lwc'; @@ -237,7 +238,6 @@ describe('private method transform validation', () => { expect(code).not.toContain('this.#doWork'); }); - // Kept inline: uses forward-only pipeline (no reverse transform) test('forward references in call sites are transformed', () => { const source = ` import { LightningElement } from 'lwc'; @@ -259,7 +259,6 @@ describe('private method transform validation', () => { expect(roundTrip.code).not.toContain('__lwc_component_class_internal_private_'); }); - // Kept inline: uses forward-only pipeline to verify occurrence counts test('multiple invocations of the same private method are all transformed', () => { const source = ` import { LightningElement } from 'lwc'; @@ -286,7 +285,6 @@ describe('private method transform validation', () => { expect(privateMatches).toHaveLength(4); }); - // Kept inline: uses forward-only pipeline (no reverse transform) test('cross-method private call sites in forward-only output', () => { const source = ` import { LightningElement } from 'lwc'; From 500c3b3812980547ea330e693e9cc8c47a7b484d Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 16:52:54 -0500 Subject: [PATCH 27/30] test: add cross-class private method access inline tests Test that cross-class #privateName access is a parse error, and that a spoofed mangled name definition is harmlessly round-tripped back to a class-scoped private method. Made-with: Cursor --- .../private-method-transform.spec.ts | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts index 73a2d49f04..b46c0c7722 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -303,4 +303,42 @@ describe('private method transform validation', () => { expect(code).not.toContain('#helper'); expect(code).not.toContain('#caller'); }); + + test('cross-class #privateName access is a parse error', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class A extends LightningElement { + #privateMethod() {} + } + class B extends LightningElement { + hax() { + this.#privateMethod(); + this.__lwc_component_class_internal_private_privateMethod(); + } + } + `; + + expect(() => transformWithFullPipeline(source)).toThrowError( + /Private name #privateMethod is not defined/ + ); + }); + + test('cross-class spoofed mangled name is round-tripped back to a harmless private method', () => { + const source = ` + import { LightningElement } from 'lwc'; + export default class A extends LightningElement { + #privateMethod() {} + } + class B extends LightningElement { + __lwc_component_class_internal_private_privateMethod() { + return 'spoofed'; + } + } + `; + + const result = transformWithFullPipeline(source); + const code = result.code!; + expect(code).not.toContain('__lwc_component_class_internal_private_'); + expect((code.match(/#privateMethod/g) || []).length).toBe(2); + }); }); From e49f1b3b64ee45c476aa033869af4543d323eb36 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Wed, 4 Mar 2026 17:08:01 -0500 Subject: [PATCH 28/30] docs: add enablePrivateMethods to rollup plugin README Made-with: Cursor --- packages/@lwc/rollup-plugin/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@lwc/rollup-plugin/README.md b/packages/@lwc/rollup-plugin/README.md index a961bfdb28..d09b4cc29f 100644 --- a/packages/@lwc/rollup-plugin/README.md +++ b/packages/@lwc/rollup-plugin/README.md @@ -40,3 +40,4 @@ export default { - `enableStaticContentOptimization` (type: `boolean`, optional) - True if the static content optimization should be enabled. Passed to `@lwc/template-compiler`. True by default. - `targetSSR` (type: `boolean`) - Utilize the experimental SSR compilation mode. False by default. Do not use unless you know what you're doing. - `ssrMode` (type: `string`): The variety of SSR code that should be generated when using experimental SSR compilation mode. Should be one of `sync`, `async` or `asyncYield`. +- `enablePrivateMethods` (type: `boolean`, default: `false`) - Enable the private method round-trip transform. When false or omitted, private methods are not supported. Passed to `@lwc/compiler`. From 2820b3705c0f5724fc5d2da4949c703aae0eff7a Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Thu, 5 Mar 2026 12:53:35 -0500 Subject: [PATCH 29/30] docs: add enablePrivateMethods to compiler README Co-Authored-By: Claude Sonnet 4.5 --- packages/@lwc/compiler/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/@lwc/compiler/README.md b/packages/@lwc/compiler/README.md index 8dec078aa7..99b48d4a7a 100644 --- a/packages/@lwc/compiler/README.md +++ b/packages/@lwc/compiler/README.md @@ -57,6 +57,7 @@ const { code } = transformSync(source, filename, options); - `enableLightningWebSecurityTransforms` (type: `boolean`, default: `false`) - The configuration to enable Lighting Web Security specific transformations. - `enableLwcSpread` (boolean, optional, `true` by default) - Deprecated. Ignored by compiler. `lwc:spread` is always enabled. - `disableSyntheticShadowSupport` (type: `boolean`, default: `false`) - Set to true if synthetic shadow DOM support is not needed, which can result in smaller/faster output. + - `enablePrivateMethods` (type: `boolean`, default: `false`) - Enable the private method round-trip transform. When false or omitted, private methods pass through to standard Babel handling. - `instrumentation` (type: `InstrumentationObject`, optional) - instrumentation object to gather metrics and non-error logs for internal use. See the `@lwc/errors` package for details on the interface. - `apiVersion` (type: `number`, optional) - API version to associate with the compiled module. From cb1870d324eb1ec4e6a0794e6775624299ff74e4 Mon Sep 17 00:00:00 2001 From: "a.chabot" Date: Thu, 5 Mar 2026 13:14:47 -0500 Subject: [PATCH 30/30] test: skip empty private-methods fixtures suite until tests are merged The private-methods fixtures directory only contains .gitkeep on this branch, causing CI to fail with 'No test found in suite'. Skipping the test suite temporarily until the fixture tests from a-chabot/private-method-fixture-tests are merged. Co-Authored-By: Claude Sonnet 4.5 --- .../@lwc/babel-plugin-component/src/__tests__/index.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts b/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts index 07b7e6b8a8..5749655b91 100644 --- a/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts +++ b/packages/@lwc/babel-plugin-component/src/__tests__/index.spec.ts @@ -142,7 +142,7 @@ function transformWithPrivateMethodPipeline(source: string, opts = {}) { return { code }; } -describe('private-methods fixtures', () => { +describe.skip('private-methods fixtures', () => { testFixtureDir( { root: path.resolve(import.meta.dirname, 'fixtures', 'private-methods'),