diff --git a/.strata.yml b/.strata.yml index c3e58ce7a3..be022c8771 100644 --- a/.strata.yml +++ b/.strata.yml @@ -1,44 +1,42 @@ global: - email-reply-to: raptorteam@salesforce.com - preprocessor-tag: jenkins-sfci-sfci-managed-preprocessor-PR-6322-40-itest - + email-reply-to: raptorteam@salesforce.com + preprocessor-tag: jenkins-sfci-sfci-managed-preprocessor-PR-6322-40-itest stages: - build: - - npm-setup: - yarn-modern: true - - step: - name: yarn-build - image: docker.repo.local.sfdc.net/sfci/docker-images/sfdc_rhel9_nodejs20/sfdc_rhel9_nodejs20_build:latest - commands: - - yarn install - - yarn build - - yarn test - integration-test: - - downstream-dependency: - downstream-repos: - - repo-url: https://github.com/salesforce-experience-platform-emu/lwc-platform - branches: - - master - packages-to-test: - npm: - - "@lwc/aria-reflection" - - "@lwc/babel-plugin-component" - - "@lwc/compiler" - - "@lwc/engine-core" - - "@lwc/engine-dom" - - "@lwc/errors" - - "@lwc/features" - - "@lwc/module-resolver" - - "@lwc/rollup-plugin" - - "@lwc/shared" - - "@lwc/signals" - - "@lwc/ssr-client-utils" - - "@lwc/ssr-compiler" - - "@lwc/ssr-runtime" - - "@lwc/style-compiler" - - "@lwc/synthetic-shadow" - - "@lwc/template-compiler" - - "@lwc/types" - - "@lwc/wire-service" - + build: + - npm-setup: + yarn-modern: true + - step: + name: yarn-build + image: docker.repo.local.sfdc.net/sfci/docker-images/sfdc_rhel9_nodejs20/sfdc_rhel9_nodejs20_build:latest + commands: + - yarn install + - yarn build + - yarn test + integration-test: + - downstream-dependency: + downstream-repos: + - repo-url: https://github.com/salesforce-experience-platform-emu/lwc-platform + branches: + - master + packages-to-test: + npm: + - '@lwc/aria-reflection' + - '@lwc/babel-plugin-component' + - '@lwc/compiler' + - '@lwc/engine-core' + - '@lwc/engine-dom' + - '@lwc/errors' + - '@lwc/features' + - '@lwc/module-resolver' + - '@lwc/rollup-plugin' + - '@lwc/shared' + - '@lwc/signals' + - '@lwc/ssr-client-utils' + - '@lwc/ssr-compiler' + - '@lwc/ssr-runtime' + - '@lwc/style-compiler' + - '@lwc/synthetic-shadow' + - '@lwc/template-compiler' + - '@lwc/types' + - '@lwc/wire-service' diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/.gitkeep b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/private-methods/.gitkeep 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..5749655b91 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.skip('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 new file mode 100644 index 0000000000..b46c0c7722 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/private-method-transform.spec.ts @@ -0,0 +1,344 @@ +/* + * 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 + */ + +// 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'; + +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: [ + LwcPrivateMethodTransform, + [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', () => { + 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( + /cannot start with reserved prefix `__lwc_`\. 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: [ + LwcPrivateMethodTransform, + [plugin, { ...BASE_OPTS }], + methodRemoverPlugin, + LwcReversePrivateMethodTransform, + ], + }) + ).toThrowError(/Private method transform count mismatch/); + }); + + 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('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: [ + LwcPrivateMethodTransform, + [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: [ + LwcPrivateMethodTransform, + [plugin, { ...BASE_OPTS }], + prefixedMethodInjectorPlugin, + LwcReversePrivateMethodTransform, + ], + }) + ).toThrowError(/cannot start with reserved prefix `__lwc_`/); + }); + + 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('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'); + }); + + 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); + }); +}); diff --git a/packages/@lwc/babel-plugin-component/src/constants.ts b/packages/@lwc/babel-plugin-component/src/constants.ts index 6773cc67ea..4306f82f59 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.ts +++ b/packages/@lwc/babel-plugin-component/src/constants.ts @@ -34,6 +34,8 @@ const TEMPLATE_KEY = 'tmpl'; const COMPONENT_NAME_KEY = 'sel'; const API_VERSION_KEY = 'apiVersion'; const COMPONENT_CLASS_ID = '__lwc_component_class_internal'; +const PRIVATE_METHOD_PREFIX = '__lwc_component_class_internal_private_'; +const PRIVATE_METHOD_METADATA_KEY = '__lwcTransformedPrivateMethods'; const SYNTHETIC_ELEMENT_INTERNALS_KEY = 'enableSyntheticElementInternals'; const COMPONENT_FEATURE_FLAG_KEY = 'componentFeatureFlag'; @@ -48,6 +50,8 @@ export { COMPONENT_NAME_KEY, API_VERSION_KEY, COMPONENT_CLASS_ID, + PRIVATE_METHOD_PREFIX, + PRIVATE_METHOD_METADATA_KEY, SYNTHETIC_ELEMENT_INTERNALS_KEY, COMPONENT_FEATURE_FLAG_KEY, }; diff --git a/packages/@lwc/babel-plugin-component/src/index.ts b/packages/@lwc/babel-plugin-component/src/index.ts index dfcb059335..903b5e3720 100644 --- a/packages/@lwc/babel-plugin-component/src/index.ts +++ b/packages/@lwc/babel-plugin-component/src/index.ts @@ -21,6 +21,9 @@ import type { PluginObj } from '@babel/core'; // This is useful for consumers of this package to define their options export type { LwcBabelPluginOptions } from './types'; +export { default as LwcPrivateMethodTransform } from './private-method-transform'; +export { default as LwcReversePrivateMethodTransform } from './reverse-private-method-transform'; + /** * The transform is done in 2 passes: * - First, apply in a single AST traversal the decorators and the component transformation. diff --git a/packages/@lwc/babel-plugin-component/src/private-method-transform.ts b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts new file mode 100644 index 0000000000..c864f4dd42 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/private-method-transform.ts @@ -0,0 +1,149 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { DecoratorErrors } from '@lwc/errors'; +import { PRIVATE_METHOD_PREFIX, PRIVATE_METHOD_METADATA_KEY } from './constants'; +import { copyMethodMetadata, handleError } from './utils'; +import type { BabelAPI, LwcBabelPluginPass } from './types'; +import type { NodePath, PluginObj } 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'; + +/** + * Standalone Babel plugin that transforms private method identifiers from + * `#privateMethod` to `__lwc_component_class_internal_private_privateMethod`. + * + * This must be registered BEFORE the main LWC class transform plugin so that + * private methods are converted to regular methods before decorator and class + * property processing. + * + * Uses Program > 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): PluginObj { + return { + visitor: { + 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( + methodPath: NodePath, + methodState: LwcBabelPluginPass + ) { + const key = methodPath.get('key'); + if (!key.isPrivateName()) { + return; + } + + if (methodPath.node.kind !== METHOD_KIND) { + handleError( + methodPath, + { + errorInfo: DecoratorErrors.UNSUPPORTED_PRIVATE_MEMBER, + messageArgs: ['accessor methods'], + }, + methodState + ); + return; + } + + 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( + METHOD_KIND, + keyReplacement, + node.params, + node.body, + node.computed, + node.static, + node.generator, + node.async + ) as types.ClassMethod; + + 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); + }, + + 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 + ) { + handleError( + propPath, + { + errorInfo: DecoratorErrors.UNSUPPORTED_PRIVATE_MEMBER, + messageArgs: ['fields'], + }, + propState + ); + }, + }, + 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 new file mode 100644 index 0000000000..898a3f26f7 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/reverse-private-method-transform.ts @@ -0,0 +1,135 @@ +/* + * Copyright (c) 2025, salesforce.com, inc. + * All rights reserved. + * SPDX-License-Identifier: MIT + * For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT + */ +import { DecoratorErrors } from '@lwc/errors'; +import { PRIVATE_METHOD_PREFIX, PRIVATE_METHOD_METADATA_KEY } from './constants'; +import { copyMethodMetadata } from './utils'; +import type { BabelAPI, LwcBabelPluginPass } from './types'; +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, + * 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. + * + * 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): 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 { + visitor: { + ClassMethod(path: NodePath, state: LwcBabelPluginPass) { + const key = path.get('key'); + + // kind: 'method' | 'get' | 'set' - only 'method' is in scope. + if (key.isIdentifier() && path.node.kind === 'method') { + const methodName = key.node.name; + + 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. + if (!forwardTransformedNames || !forwardTransformedNames.has(methodName)) { + const message = + DecoratorErrors.PRIVATE_METHOD_NAME_COLLISION.message.replace( + '{0}', + methodName + ); + throw path.buildCodeFrameError(message); + } + + const originalPrivateName = methodName.replace(PRIVATE_METHOD_PREFIX, ''); + + 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 + classPrivateMethod.async = node.async; + classPrivateMethod.generator = node.generator; + classPrivateMethod.computed = node.computed; + + copyMethodMetadata(node, classPrivateMethod); + + path.replaceWith(classPrivateMethod); + reverseTransformedNames.add(methodName); + } + } + }, + + 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 + // 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; + } + + 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/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, }; 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. 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 b3733f7135..0e25cecb9b 100755 --- a/packages/@lwc/compiler/src/transformers/javascript.ts +++ b/packages/@lwc/compiler/src/transformers/javascript.ts @@ -10,7 +10,11 @@ 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, { + LwcPrivateMethodTransform, + LwcReversePrivateMethodTransform, + type LwcBabelPluginOptions, +} from '@lwc/babel-plugin-component'; import { CompilerAggregateError, CompilerError, @@ -44,6 +48,7 @@ export default function scriptTransform( dynamicImports, outputConfig: { sourcemap }, enableLightningWebSecurityTransforms, + enablePrivateMethods, namespace, name, instrumentation, @@ -64,8 +69,10 @@ export default function scriptTransform( }; const plugins: babel.PluginItem[] = [ + ...(enablePrivateMethods ? [LwcPrivateMethodTransform as babel.PluginItem] : []), [lwcClassTransformPlugin, lwcBabelPluginOptions], [babelClassPropertiesPlugin, { loose: true }], + ...(enablePrivateMethods ? [LwcReversePrivateMethodTransform as babel.PluginItem] : []), ]; if (!isAPIFeatureEnabled(APIFeature.DISABLE_OBJECT_REST_SPREAD_TRANSFORMATION, apiVersion)) { diff --git a/packages/@lwc/errors/src/compiler/error-info/index.ts b/packages/@lwc/errors/src/compiler/error-info/index.ts index c2f596bcf8..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: 1212 + * 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 f435ef84ec..75ae09c6f8 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,27 @@ export const DecoratorErrors = { level: DiagnosticLevel.Error, url: 'https://lwc.dev/guide/error_codes#lwc1200', }, + + DECORATOR_ON_PRIVATE_METHOD: { + code: 1212, + message: + 'Decorators cannot be applied to private methods. Private methods are not part of the component API.', + level: DiagnosticLevel.Error, + url: 'https://lwc.dev/guide/error_codes#lwc1212', + }, + + PRIVATE_METHOD_NAME_COLLISION: { + code: 1213, + message: + "Method '{0}' cannot start with reserved prefix `__lwc_`. Please rename this function to avoid conflict.", + 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; 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`. 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) {