diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/actual.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/actual.js new file mode 100644 index 0000000000..d2297592e0 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/actual.js @@ -0,0 +1,3 @@ +import { LightningElement as Component } from "lwc"; + +export default class Test extends Component {} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/config.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/config.json new file mode 100644 index 0000000000..08662910cc --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/config.json @@ -0,0 +1,3 @@ +{ + "enableSyntheticElementInternals": true +} diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/error.json b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/error.json new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/expected.js b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/expected.js new file mode 100644 index 0000000000..0c8ec2a131 --- /dev/null +++ b/packages/@lwc/babel-plugin-component/src/__tests__/fixtures/component/enable-synthetic-element-internals/expected.js @@ -0,0 +1,12 @@ +import _tmpl from "./test.html"; +import { LightningElement as Component, registerComponent as _registerComponent } from "lwc"; +class Test extends Component { + /*LWC compiler vX.X.X*/ +} +const __lwc_component_class_internal = _registerComponent(Test, { + tmpl: _tmpl, + sel: "lwc-test", + apiVersion: 9999999, + enableSyntheticElementInternals: true +}); +export default __lwc_component_class_internal; \ No newline at end of file diff --git a/packages/@lwc/babel-plugin-component/src/component.ts b/packages/@lwc/babel-plugin-component/src/component.ts index 5ce17a8da1..7f3393be20 100644 --- a/packages/@lwc/babel-plugin-component/src/component.ts +++ b/packages/@lwc/babel-plugin-component/src/component.ts @@ -14,6 +14,7 @@ import { TEMPLATE_KEY, API_VERSION_KEY, COMPONENT_CLASS_ID, + SYNTHETIC_ELEMENT_INTERNALS_KEY, } from './constants'; import type { types, NodePath, Visitor } from '@babel/core'; import type { BabelAPI, BabelTypes, LwcBabelPluginPass } from './types'; @@ -81,15 +82,25 @@ export default function ({ types: t }: BabelAPI): Visitor { // sel: 'x-foo', // apiVersion: '58' // }) + const properties = [ + t.objectProperty(t.identifier(TEMPLATE_KEY), templateIdentifier), + t.objectProperty(t.identifier(COMPONENT_NAME_KEY), componentRegisteredName), + // It's important that, at this point, we have an APIVersion rather than just a number. + // The client needs to trust the server that it's providing an actual known API version + t.objectProperty(t.identifier(API_VERSION_KEY), t.numericLiteral(apiVersion)), + ]; + // Only include enableSyntheticElementInternals if set to true + if (state.opts.enableSyntheticElementInternals === true) { + properties.push( + t.objectProperty( + t.identifier(SYNTHETIC_ELEMENT_INTERNALS_KEY), + t.booleanLiteral(true) + ) + ); + } const registerComponentExpression = t.callExpression(registerComponentId, [ node as types.Expression, - t.objectExpression([ - t.objectProperty(t.identifier(TEMPLATE_KEY), templateIdentifier), - t.objectProperty(t.identifier(COMPONENT_NAME_KEY), componentRegisteredName), - // It's important that, at this point, we have an APIVersion rather than just a number. - // The client needs to trust the server that it's providing an actual known API version - t.objectProperty(t.identifier(API_VERSION_KEY), t.numericLiteral(apiVersion)), - ]), + t.objectExpression(properties), ]); // Example: diff --git a/packages/@lwc/babel-plugin-component/src/constants.ts b/packages/@lwc/babel-plugin-component/src/constants.ts index 0f4e07d703..3f243a9768 100644 --- a/packages/@lwc/babel-plugin-component/src/constants.ts +++ b/packages/@lwc/babel-plugin-component/src/constants.ts @@ -34,6 +34,7 @@ const TEMPLATE_KEY = 'tmpl'; const COMPONENT_NAME_KEY = 'sel'; const API_VERSION_KEY = 'apiVersion'; const COMPONENT_CLASS_ID = '__lwc_component_class_internal'; +const SYNTHETIC_ELEMENT_INTERNALS_KEY = 'enableSyntheticElementInternals'; export { DECORATOR_TYPES, @@ -46,4 +47,5 @@ export { COMPONENT_NAME_KEY, API_VERSION_KEY, COMPONENT_CLASS_ID, + SYNTHETIC_ELEMENT_INTERNALS_KEY, }; diff --git a/packages/@lwc/babel-plugin-component/src/types.ts b/packages/@lwc/babel-plugin-component/src/types.ts index 0c70254398..e0eb62f61b 100644 --- a/packages/@lwc/babel-plugin-component/src/types.ts +++ b/packages/@lwc/babel-plugin-component/src/types.ts @@ -21,6 +21,7 @@ export interface LwcBabelPluginOptions { name: string; instrumentation?: InstrumentationObject; apiVersion?: number; + enableSyntheticElementInternals?: boolean; } export interface LwcBabelPluginPass extends PluginPass { diff --git a/packages/@lwc/compiler/src/options.ts b/packages/@lwc/compiler/src/options.ts index c386477428..5ed34c15c4 100755 --- a/packages/@lwc/compiler/src/options.ts +++ b/packages/@lwc/compiler/src/options.ts @@ -108,6 +108,8 @@ export interface TransformOptions { experimentalDynamicDirective?: boolean; /** Flag to enable usage of dynamic component(lwc:is) directive in HTML template */ enableDynamicComponents?: boolean; + /** Flag to enable usage of ElementInternals in synthetic shadow DOM */ + enableSyntheticElementInternals?: boolean; // TODO [#3370]: remove experimental template expression flag /** Flag to enable use of (a subset of) JavaScript expressions in place of template bindings. Passed to `@lwc/template-compiler`. */ experimentalComplexExpressions?: boolean; @@ -153,6 +155,7 @@ type OptionalTransformKeys = | 'enableLwcOn' | 'enableLightningWebSecurityTransforms' | 'enableDynamicComponents' + | 'enableSyntheticElementInternals' | 'experimentalDynamicDirective' | 'experimentalDynamicComponent' | 'instrumentation'; diff --git a/packages/@lwc/compiler/src/transformers/javascript.ts b/packages/@lwc/compiler/src/transformers/javascript.ts index 724c503145..aba3e3e9ec 100755 --- a/packages/@lwc/compiler/src/transformers/javascript.ts +++ b/packages/@lwc/compiler/src/transformers/javascript.ts @@ -33,6 +33,7 @@ export default function scriptTransform( ): TransformResult { const { isExplicitImport, + enableSyntheticElementInternals, // TODO [#5031]: Unify dynamicImports and experimentalDynamicComponent options experimentalDynamicComponent: dynamicImports, outputConfig: { sourcemap }, @@ -46,6 +47,7 @@ export default function scriptTransform( const lwcBabelPluginOptions: LwcBabelPluginOptions = { isExplicitImport, dynamicImports, + enableSyntheticElementInternals, namespace, name, instrumentation, diff --git a/packages/@lwc/engine-core/src/framework/base-lightning-element.ts b/packages/@lwc/engine-core/src/framework/base-lightning-element.ts index de65dfd610..6e46a76c79 100644 --- a/packages/@lwc/engine-core/src/framework/base-lightning-element.ts +++ b/packages/@lwc/engine-core/src/framework/base-lightning-element.ts @@ -38,7 +38,11 @@ import { } from '../libs/reflection'; import { HTMLElementOriginalDescriptors } from './html-properties'; -import { getComponentAPIVersion, getWrappedComponentsListener } from './component'; +import { + getComponentAPIVersion, + getWrappedComponentsListener, + supportsSyntheticElementInternals, +} from './component'; import { isBeingConstructed, isInvokingRender, vmBeingConstructed } from './invoker'; import { associateVM, getAssociatedVM, RenderMode, ShadowMode } from './vm'; import { componentValueObserved } from './mutation-tracker'; @@ -493,6 +497,7 @@ function warnIfInvokedDuringConstruction(vm: VM, methodOrPropName: string) { attachInternals(): ElementInternals { const vm = getAssociatedVM(this); const { + def: { ctor }, elm, apiVersion, renderer: { attachInternals }, @@ -506,11 +511,28 @@ function warnIfInvokedDuringConstruction(vm: VM, methodOrPropName: string) { ); } - if (vm.shadowMode === ShadowMode.Synthetic) { + const internals = attachInternals(elm); + if (vm.shadowMode === ShadowMode.Synthetic && supportsSyntheticElementInternals(ctor)) { + const handler: ProxyHandler = { + get(target: ElementInternals, prop: keyof ElementInternals) { + if (prop === 'shadowRoot') { + return vm.shadowRoot; + } + const value = Reflect.get(target, prop); + if (typeof value === 'function') { + return value.bind(target); + } + return value; + }, + set(target: ElementInternals, prop: keyof ElementInternals, value: any) { + return Reflect.set(target, prop, value); + }, + }; + return new Proxy(internals, handler); + } else if (vm.shadowMode === ShadowMode.Synthetic) { throw new Error('attachInternals API is not supported in synthetic shadow.'); } - - return attachInternals(elm); + return internals; }, get isConnected(): boolean { diff --git a/packages/@lwc/engine-core/src/framework/component.ts b/packages/@lwc/engine-core/src/framework/component.ts index c3fc10e7fc..1bf23eed2b 100644 --- a/packages/@lwc/engine-core/src/framework/component.ts +++ b/packages/@lwc/engine-core/src/framework/component.ts @@ -24,6 +24,7 @@ type ComponentConstructorMetadata = { tmpl: Template; sel: string; apiVersion: APIVersion; + enableSyntheticElementInternals?: boolean | undefined; }; const registeredComponentMap: Map = new Map(); @@ -76,6 +77,10 @@ export function getComponentAPIVersion(Ctor: LightningElementConstructor): APIVe return apiVersion; } +export function supportsSyntheticElementInternals(Ctor: LightningElementConstructor): boolean { + return registeredComponentMap.get(Ctor)?.enableSyntheticElementInternals || false; +} + export function getTemplateReactiveObserver(vm: VM): ReactiveObserver { const reactiveObserver = createReactiveObserver(() => { const { isDirty } = vm; diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 1889dc28f4..094486909c 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -31,6 +31,7 @@ import { getTemplateReactiveObserver, getComponentAPIVersion, resetTemplateObserverAndUnsubscribe, + supportsSyntheticElementInternals, } from './component'; import { addCallbackToNextTick, EmptyArray, EmptyObject } from './utils'; import { invokeComponentCallback, invokeComponentConstructor } from './invoker'; @@ -972,9 +973,17 @@ export function forceRehydration(vm: VM) { } export function runFormAssociatedCustomElementCallback(vm: VM, faceCb: () => void, args?: any[]) { - const { renderMode, shadowMode } = vm; + const { + renderMode, + shadowMode, + def: { ctor }, + } = vm; - if (shadowMode === ShadowMode.Synthetic && renderMode !== RenderMode.Light) { + if ( + shadowMode === ShadowMode.Synthetic && + renderMode !== RenderMode.Light && + !supportsSyntheticElementInternals(ctor) + ) { throw new Error( 'Form associated lifecycle methods are not available in synthetic shadow. Please use native shadow or light DOM.' ); diff --git a/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js b/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js index 62bfa0a2d0..bdbda892f0 100644 --- a/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js +++ b/packages/@lwc/integration-not-karma/configs/plugins/serve-hydration.js @@ -32,6 +32,7 @@ async function compileModule(input, targetSSR, format) { loader: fileURLToPath(new URL('../../helpers/loader.js', import.meta.url)), strict: true, }, + enableSyntheticElementInternals: true, enableDynamicComponents: true, enableLwcOn: true, enableStaticContentOptimization: !DISABLE_STATIC_CONTENT_OPTIMIZATION, diff --git a/packages/@lwc/integration-not-karma/configs/plugins/serve-integration.js b/packages/@lwc/integration-not-karma/configs/plugins/serve-integration.js index 491b2fe688..18890b98f3 100644 --- a/packages/@lwc/integration-not-karma/configs/plugins/serve-integration.js +++ b/packages/@lwc/integration-not-karma/configs/plugins/serve-integration.js @@ -28,6 +28,7 @@ const createRollupPlugin = (input, options) => { }, enableDynamicComponents: true, enableLwcOn: true, + enableSyntheticElementInternals: true, experimentalComplexExpressions, enableStaticContentOptimization: !DISABLE_STATIC_CONTENT_OPTIMIZATION, disableSyntheticShadowSupport: DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER, diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/ai/syntheticShadowDom/syntheticShadowDom.html b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/ai/syntheticShadowDom/syntheticShadowDom.html new file mode 100644 index 0000000000..41a40c8d47 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/ai/syntheticShadowDom/syntheticShadowDom.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/ai/syntheticShadowDom/syntheticShadowDom.js b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/ai/syntheticShadowDom/syntheticShadowDom.js new file mode 100644 index 0000000000..d8b072be78 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/ai/syntheticShadowDom/syntheticShadowDom.js @@ -0,0 +1,19 @@ +import { LightningElement, api } from 'lwc'; + +export default class extends LightningElement { + internals; + + connectedCallback() { + this.internals = this.attachInternals(); + } + + @api + callAttachInternals() { + this.internals = this.attachInternals(); + } + + @api + hasElementInternalsBeenSet() { + return Boolean(this.internals); + } +} diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/index.spec.js b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/index.spec.js index 35f76ea431..9cf774de32 100644 --- a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/index.spec.js +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/api/index.spec.js @@ -1,6 +1,7 @@ import { createElement } from 'lwc'; import ShadowDomCmp from 'ai/shadowDom'; +import SyntheticShadowDomCmp from 'ai/syntheticShadowDom'; import LightDomCmp from 'ai/lightDom'; import BasicCmp from 'ai/basic'; import { @@ -67,13 +68,7 @@ describe.runIf(ENABLE_ELEMENT_INTERNALS_AND_FACE)('ElementInternals', () => { }); describe.skipIf(process.env.NATIVE_SHADOW)('synthetic shadow', () => { - it('should throw error when used inside a component', () => { - const elm = createElement('synthetic-shadow', { is: ShadowDomCmp }); - testConnectedCallbackError( - elm, - 'attachInternals API is not supported in synthetic shadow.' - ); - }); + attachInternalsSanityTest('synthetic-shadow', SyntheticShadowDomCmp); }); describe('light DOM', () => { diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/index.spec.js b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/index.spec.js index 1d68671a7e..02c43e9872 100644 --- a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/index.spec.js +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/index.spec.js @@ -6,43 +6,139 @@ import FormAssociatedFalse from 'x/formAssociatedFalse'; import NotFormAssociatedNoAttachInternals from 'x/notFormAssociatedNoAttachInternals'; import FormAssociatedNoAttachInternals from 'x/formAssociatedNoAttachInternals'; import FormAssociatedFalseNoAttachInternals from 'x/formAssociatedFalseNoAttachInternals'; -import { - ENABLE_ELEMENT_INTERNALS_AND_FACE, - IS_SYNTHETIC_SHADOW_LOADED, -} from '../../../../../helpers/constants.js'; - -describe.runIf( - ENABLE_ELEMENT_INTERNALS_AND_FACE && - typeof ElementInternals !== 'undefined' && - !IS_SYNTHETIC_SHADOW_LOADED -)('should throw an error when duplicate tag name used', () => { - it('with different formAssociated value', () => { - // Register tag with formAssociated = true - createElement('x-form-associated', { is: FormAssociated }); - // Try to register again with formAssociated = false - expect(() => createElement('x-form-associated', { is: FormAssociatedFalse })).toThrowError( - / was already registered with formAssociated=true. It cannot be re-registered with formAssociated=false. Please rename your component to have a different name than / - ); - }); +import { ENABLE_ELEMENT_INTERNALS_AND_FACE } from '../../../../../helpers/constants.js'; - it('should not throw when duplicate tag name used with the same formAssociated value', () => { - // formAssociated = true - createElement('x-form-associated', { is: FormAssociated }); - expect(() => createElement('x-form-associated', { is: FormAssociated })).not.toThrow(); - // formAssociated = false - createElement('x-form-associated-false', { is: FormAssociatedFalse }); - expect(() => - createElement('x-form-associated-false', { is: FormAssociatedFalse }) - ).not.toThrow(); - // formAssociated = undefined - createElement('x-not-form-associated', { is: NotFormAssociated }); - expect(() => - createElement('x-not-form-associated', { is: NotFormAssociated }) - ).not.toThrow(); - }); -}); +const readOnlyProperties = [ + 'shadowRoot', + 'states', + 'form', + 'willValidate', + 'validity', + 'validationMessage', + 'labels', +]; + +const formAssociatedFalsyTest = (tagName, ctor) => { + const form = document.createElement('form'); + document.body.appendChild(form); + + const elm = createElement(`x-${tagName}`, { is: ctor }); + form.appendChild(elm); + + const { internals } = elm; + expect(() => internals.form).toThrow(); + expect(() => internals.setFormValue('2019-03-15')).toThrow(); + expect(() => internals.willValidate).toThrow(); + expect(() => internals.validity).toThrow(); + expect(() => internals.checkValidity()).toThrow(); + expect(() => internals.reportValidity()).toThrow(); + expect(() => internals.setValidity('')).toThrow(); + expect(() => internals.validationMessage).toThrow(); + expect(() => internals.labels).toThrow(); + + document.body.removeChild(form); +}; + +describe.runIf(ENABLE_ELEMENT_INTERNALS_AND_FACE && typeof ElementInternals !== 'undefined')( + 'should throw an error when duplicate tag name used', + () => { + it('with different formAssociated value', () => { + // Register tag with formAssociated = true + createElement('x-form-associated', { is: FormAssociated }); + // Try to register again with formAssociated = false + expect(() => + createElement('x-form-associated', { is: FormAssociatedFalse }) + ).toThrowError( + / was already registered with formAssociated=true. It cannot be re-registered with formAssociated=false. Please rename your component to have a different name than / + ); + }); + + it('should not throw when duplicate tag name used with the same formAssociated value', () => { + // formAssociated = true + createElement('x-form-associated', { is: FormAssociated }); + expect(() => createElement('x-form-associated', { is: FormAssociated })).not.toThrow(); + // formAssociated = false + createElement('x-form-associated-false', { is: FormAssociatedFalse }); + expect(() => + createElement('x-form-associated-false', { is: FormAssociatedFalse }) + ).not.toThrow(); + // formAssociated = undefined + createElement('x-not-form-associated', { is: NotFormAssociated }); + expect(() => + createElement('x-not-form-associated', { is: NotFormAssociated }) + ).not.toThrow(); + }); + + it('should throw an error when accessing form related properties when formAssociated is false', () => { + formAssociatedFalsyTest('x-form-associated-false', FormAssociatedFalse); + }); + + it('should throw an error when accessing form related properties when formAssociated is undefined', () => { + formAssociatedFalsyTest('x-not-form-associated', NotFormAssociated); + }); + + it('should be able to use internals to validate form associated component', () => { + const elm = createElement('x-form-associated', { is: FormAssociated }); + const { internals } = elm; + expect(internals.willValidate).toBe(true); + expect(internals.validity.valid).toBe(true); + expect(internals.checkValidity()).toBe(true); + expect(internals.reportValidity()).toBe(true); + expect(internals.validationMessage).toBe(''); + + internals.setValidity({ rangeUnderflow: true }, 'pick future date'); + + expect(internals.validity.valid).toBe(false); + expect(internals.checkValidity()).toBe(false); + expect(internals.reportValidity()).toBe(false); + expect(internals.validationMessage).toBe('pick future date'); + }); + + it('should be able to use setFormValue on a form associated component', () => { + const form = document.createElement('form'); + document.body.appendChild(form); + + const elm = createElement('x-form-associated', { is: FormAssociated }); + const { internals } = elm; + form.appendChild(elm); + + expect(internals.form).toBe(form); + + elm.setAttribute('name', 'date'); + const inputElm = elm.shadowRoot + .querySelector('x-input') + .shadowRoot.querySelector('input'); + internals.setFormValue('2019-03-15', '3/15/2019', inputElm); + const formData = new FormData(form); + expect(formData.get('date')).toBe('2019-03-15'); + }); + + it('should be able to associate labels to a form associated component', () => { + const elm = createElement('x-form-associated', { is: FormAssociated }); + document.body.appendChild(elm); + const { internals } = elm; + + expect(internals.labels.length).toBe(0); + elm.id = 'test-id'; + const label = document.createElement('label'); + label.htmlFor = elm.id; + document.body.appendChild(label); + expect(internals.labels.length).toBe(1); + expect(internals.labels[0]).toBe(label); + }); + + for (const prop of readOnlyProperties) { + it(`should throw error when trying to set readonly ${prop} on form associated component`, () => { + const elm = createElement('x-form-associated', { is: FormAssociated }); + document.body.appendChild(elm); + const { internals } = elm; + expect(() => (internals[prop] = 'test')).toThrow(); + }); + } + } +); -it.runIf(typeof ElementInternals !== 'undefined' && !IS_SYNTHETIC_SHADOW_LOADED)( +it.runIf(typeof ElementInternals !== 'undefined')( 'disallows form association on older API versions', () => { const isFormAssociated = (elm) => { diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/formAssociated/formAssociated.html b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/formAssociated/formAssociated.html new file mode 100644 index 0000000000..b0cd8b1426 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/formAssociated/formAssociated.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/input/input.html b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/input/input.html new file mode 100644 index 0000000000..0b6f2445eb --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/input/input.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/input/input.js b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/input/input.js new file mode 100644 index 0000000000..ca8dce94e0 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/formAssociated/x/input/input.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement {} diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.css b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.css new file mode 100644 index 0000000000..50b3acdc76 --- /dev/null +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.css @@ -0,0 +1,3 @@ +:host(:state(--checked)) { + color: rgb(255, 0, 0); +} diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.js b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.js index f6509f7110..1c248e676d 100644 --- a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.js +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/ei/component/component.js @@ -19,4 +19,13 @@ export default class extends LightningElement { this.internals[prop] = value; } } + + @api + toggleChecked() { + if (!this.internals.states.has('--checked')) { + this.internals.states.add('--checked'); + } else { + this.internals.states.delete('--checked'); + } + } } diff --git a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/index.spec.js b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/index.spec.js index 71caf8af55..24b6ddf451 100644 --- a/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/index.spec.js +++ b/packages/@lwc/integration-not-karma/test/component/LightningElement.attachInternals/elementInternals/sanity/index.spec.js @@ -9,40 +9,48 @@ beforeEach(() => { document.body.appendChild(elm); }); -describe.runIf( - ENABLE_ELEMENT_INTERNALS_AND_FACE && - process.env.NATIVE_SHADOW && - typeof ElementInternals !== 'undefined' -)('ElementInternals', () => { - it('should be associated to the correct element', () => { - // Ensure external and internal views of shadowRoot are the same - expect(elm.internals.shadowRoot).toBe(elm.template); - expect(elm.internals.shadowRoot).toBe(elm.shadowRoot); - }); - - describe('accessibility', () => { - it('should be able to set ARIAMixin properties on ElementInternals', () => { - elm.setAllAriaProps('foo'); - // Verify ElementInternals proxy setter and getter - for (const ariaProp of ariaProperties) { - expect(elm.internals[ariaProp]).toEqual('foo'); - } +describe.runIf(ENABLE_ELEMENT_INTERNALS_AND_FACE && typeof ElementInternals !== 'undefined')( + 'ElementInternals', + () => { + it('should be associated to the correct element', () => { + // Ensure external and internal views of shadowRoot are the same + expect(elm.internals.shadowRoot).toBe(elm.template); + expect(elm.internals.shadowRoot).toBe(elm.shadowRoot); }); - it('should not reflect to aria-* attributes', () => { - elm.setAllAriaProps('foo'); - for (const attr of ariaAttributes) { - expect(elm.getAttribute(attr)).not.toEqual('foo'); - } + it('should be able to toggle states', () => { + elm.toggleChecked(); + expect(elm.internals.states.has('--checked')).toBe(true); + expect(getComputedStyle(elm).color).toBe('rgb(255, 0, 0)'); + elm.toggleChecked(); + expect(elm.internals.states.has('--checked')).toBe(false); + expect(getComputedStyle(elm).color).toBe('rgb(0, 0, 0)'); }); - it('aria-* attributes do not reflect to internals', () => { - for (const attr of ariaAttributes) { - elm.setAttribute(attr, 'bar'); - } - for (const prop of ariaProperties) { - expect(elm.internals[prop]).toBeFalsy(); - } + describe('accessibility', () => { + it('should be able to set ARIAMixin properties on ElementInternals', () => { + elm.setAllAriaProps('foo'); + // Verify ElementInternals proxy setter and getter + for (const ariaProp of ariaProperties) { + expect(elm.internals[ariaProp]).toEqual('foo'); + } + }); + + it('should not reflect to aria-* attributes', () => { + elm.setAllAriaProps('foo'); + for (const attr of ariaAttributes) { + expect(elm.getAttribute(attr)).not.toEqual('foo'); + } + }); + + it('aria-* attributes do not reflect to internals', () => { + for (const attr of ariaAttributes) { + elm.setAttribute(attr, 'bar'); + } + for (const prop of ariaProperties) { + expect(elm.internals[prop]).toBeFalsy(); + } + }); }); - }); -}); + } +); diff --git a/packages/@lwc/integration-not-karma/test/component/face-callbacks/index.spec.js b/packages/@lwc/integration-not-karma/test/component/face-callbacks/index.spec.js index d7890187e5..162513cafc 100644 --- a/packages/@lwc/integration-not-karma/test/component/face-callbacks/index.spec.js +++ b/packages/@lwc/integration-not-karma/test/component/face-callbacks/index.spec.js @@ -153,17 +153,8 @@ describe.runIf(typeof ElementInternals !== 'undefined')('ElementInternals', () = notFormAssociatedSanityTest('native-shadow', NotFormAssociated); }); describe.skipIf(process.env.NATIVE_SHADOW)('synthetic shadow', () => { - createFaceTests('synthetic-shadow', FormAssociated, (createFace) => { - it('cannot be used and throws an error', () => { - const face = createFace(); - const form = createFormElement(); - expect(() => - form.appendChild(face) - ).toThrowCallbackReactionErrorEvenInSyntheticLifecycleMode( - 'Form associated lifecycle methods are not available in synthetic shadow. Please use native shadow or light DOM.' - ); - }); - }); + faceSanityTest('synthetic-shadow', FormAssociated); + notFormAssociatedSanityTest('synthetic-shadow', NotFormAssociated); }); describe('light DOM', () => { faceSanityTest('light-dom', LightDomFormAssociated); @@ -189,33 +180,10 @@ describe.runIf(typeof ElementInternals !== 'undefined')('ElementInternals', () = ); describe.skipIf(scenario === 'lwc.createElement')(name, () => { - const lightOrNativeShadow = name === 'light DOM' || process.env.NATIVE_SHADOW; - - // Face throws error message when synthetic shadow is enabled - it.runIf(lightOrNativeShadow)( - `${name} calls face lifecycle methods when using CustomElementConstructor`, - () => { - // CustomElementConstructor is to be upgraded independently of LWC, it will always use native lifecycle - testFaceLifecycleMethodsCallable(createFace); - } - ); - - // synthetic shadow mode - it.skipIf(lightOrNativeShadow)( - `${name} cannot call face lifecycle methods when using CustomElementConstructor`, - () => { - // this is always a callback reaction error, even in "synthetic lifecycle" mode, - // because synthetic lifecycle mode only includes connected/disconnected callbacks, - // not the FACE callbacks - expect(() => { - const face = createFace(); - const form = createFormElement(); - form.appendChild(face); - }).toThrowCallbackReactionErrorEvenInSyntheticLifecycleMode( - 'Form associated lifecycle methods are not available in synthetic shadow. Please use native shadow or light DOM.' - ); - } - ); + it(`${name} calls face lifecycle methods when using CustomElementConstructor`, () => { + // CustomElementConstructor is to be upgraded independently of LWC, it will always use native lifecycle + testFaceLifecycleMethodsCallable(createFace); + }); }); }); }); diff --git a/packages/@lwc/rollup-plugin/src/index.ts b/packages/@lwc/rollup-plugin/src/index.ts index 7c8cd15781..565c7ee062 100644 --- a/packages/@lwc/rollup-plugin/src/index.ts +++ b/packages/@lwc/rollup-plugin/src/index.ts @@ -52,6 +52,8 @@ export interface RollupLwcOptions { /** The configuration to pass to `@lwc/template-compiler`. */ enableDynamicComponents?: boolean; /** The configuration to pass to `@lwc/compiler`. */ + enableSyntheticElementInternals?: boolean; + /** The configuration to pass to `@lwc/compiler`. */ enableLightningWebSecurityTransforms?: boolean; // TODO [#3370]: remove experimental template expression flag /** The configuration to pass to `@lwc/template-compiler`. */ @@ -181,6 +183,7 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { experimentalDynamicComponent, experimentalDynamicDirective, enableDynamicComponents, + enableSyntheticElementInternals, enableLwcOn, enableLightningWebSecurityTransforms, // TODO [#3370]: remove experimental template expression flag @@ -366,6 +369,7 @@ export default function lwc(pluginOptions: RollupLwcOptions = {}): Plugin { experimentalDynamicComponent, experimentalDynamicDirective, enableDynamicComponents, + enableSyntheticElementInternals, enableLwcOn, enableLightningWebSecurityTransforms, // TODO [#3370]: remove experimental template expression flag diff --git a/scripts/bundlesize/bundlesize.config.json b/scripts/bundlesize/bundlesize.config.json index 1e09db7e95..ac12507435 100644 --- a/scripts/bundlesize/bundlesize.config.json +++ b/scripts/bundlesize/bundlesize.config.json @@ -2,7 +2,7 @@ "files": [ { "path": "packages/@lwc/engine-dom/dist/index.js", - "maxSize": "24.80KB" + "maxSize": "24.90KB" }, { "path": "packages/@lwc/synthetic-shadow/dist/index.js",