diff --git a/packages/@lwc/compiler/package.json b/packages/@lwc/compiler/package.json index 2d61e874fe..e72b359749 100755 --- a/packages/@lwc/compiler/package.json +++ b/packages/@lwc/compiler/package.json @@ -47,7 +47,7 @@ "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-object-rest-spread": "7.20.7", "@babel/plugin-transform-async-to-generator": "7.22.5", - "@locker/babel-plugin-transform-unforgeables": "0.19.8", + "@locker/babel-plugin-transform-unforgeables": "0.19.9", "@lwc/babel-plugin-component": "3.1.3", "@lwc/errors": "3.1.3", "@lwc/shared": "3.1.3", diff --git a/packages/@lwc/engine-core/src/framework/base-bridge-element.ts b/packages/@lwc/engine-core/src/framework/base-bridge-element.ts index 80168c8e1a..d1e95a1d97 100644 --- a/packages/@lwc/engine-core/src/framework/base-bridge-element.ts +++ b/packages/@lwc/engine-core/src/framework/base-bridge-element.ts @@ -21,6 +21,7 @@ import { htmlPropertyToAttribute, } from '@lwc/shared'; import { applyAriaReflection } from '@lwc/aria-reflection'; +import { logError } from '../shared/logger'; import { getAssociatedVM } from './vm'; import { getReadOnlyProxy } from './membrane'; import { HTMLElementConstructor } from './html-element'; @@ -148,6 +149,18 @@ export function HTMLBridgeElementFactory( descriptors.attributeChangedCallback = { value: createAttributeChangedCallback(attributeToPropMap, superAttributeChangedCallback), }; + + // To avoid leaking private component details, accessing internals from outside a component is not allowed. + descriptors.attachInternals = { + get() { + if (process.env.NODE_ENV !== 'production') { + logError( + 'attachInternals cannot be accessed outside of a component. Use this.attachInternals instead.' + ); + } + }, + }; + // Specify attributes for which we want to reflect changes back to their corresponding // properties via attributeChangedCallback. defineProperty(HTMLBridgeElement, 'observedAttributes', { 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 92d984ae2b..08a1e1d4d6 100644 --- a/packages/@lwc/engine-core/src/framework/base-lightning-element.ts +++ b/packages/@lwc/engine-core/src/framework/base-lightning-element.ts @@ -18,6 +18,7 @@ import { defineProperties, defineProperty, freeze, + isFalse, isFunction, isNull, isObject, @@ -144,6 +145,7 @@ type HTMLElementTheGoodParts = Pick & HTMLElement, | 'accessKey' | 'addEventListener' + | 'attachInternals' | 'children' | 'childNodes' | 'classList' @@ -294,6 +296,8 @@ function warnIfInvokedDuringConstruction(vm: VM, methodOrPropName: string) { } } +const supportsElementInternals = typeof ElementInternals !== 'undefined'; + // @ts-ignore LightningElement.prototype = { constructor: LightningElement, @@ -459,6 +463,27 @@ LightningElement.prototype = { return getBoundingClientRect(elm); }, + attachInternals(): ElementInternals { + const vm = getAssociatedVM(this); + const { + elm, + renderer: { attachInternals }, + } = vm; + + if (isFalse(supportsElementInternals)) { + // Browsers that don't support attachInternals will need to be polyfilled before LWC is loaded. + throw new Error('attachInternals API is not supported in this browser environment.'); + } + + if (vm.renderMode === RenderMode.Light || vm.shadowMode === ShadowMode.Synthetic) { + throw new Error( + 'attachInternals API is not supported in light DOM or synthetic shadow.' + ); + } + + return attachInternals(elm); + }, + get isConnected(): boolean { const vm = getAssociatedVM(this); const { diff --git a/packages/@lwc/engine-core/src/framework/main.ts b/packages/@lwc/engine-core/src/framework/main.ts index bd43bb7dbe..9b8996df95 100644 --- a/packages/@lwc/engine-core/src/framework/main.ts +++ b/packages/@lwc/engine-core/src/framework/main.ts @@ -8,10 +8,13 @@ // Internal APIs used by renderers ----------------------------------------------------------------- export { getComponentHtmlPrototype } from './def'; export { - createVM, + RenderMode, + ShadowMode, connectRootElement, + createVM, disconnectRootElement, getAssociatedVMIfPresent, + computeShadowAndRenderMode, } from './vm'; export { createContextProviderWithRegister } from './wiring'; diff --git a/packages/@lwc/engine-core/src/framework/renderer.ts b/packages/@lwc/engine-core/src/framework/renderer.ts index 0ba1d763d1..8781dbf94e 100644 --- a/packages/@lwc/engine-core/src/framework/renderer.ts +++ b/packages/@lwc/engine-core/src/framework/renderer.ts @@ -73,4 +73,5 @@ export interface RendererAPI { adapterContextToken: string, subscriptionPayload: WireContextSubscriptionPayload ) => void; + attachInternals: (elm: E) => ElementInternals; } diff --git a/packages/@lwc/engine-core/src/framework/vm.ts b/packages/@lwc/engine-core/src/framework/vm.ts index 7c3d2bba21..52d8590652 100644 --- a/packages/@lwc/engine-core/src/framework/vm.ts +++ b/packages/@lwc/engine-core/src/framework/vm.ts @@ -268,8 +268,8 @@ export function removeVM(vm: VM) { resetComponentStateWhenRemoved(vm); } -function getNearestShadowAncestor(vm: VM): VM | null { - let ancestor = vm.owner; +function getNearestShadowAncestor(owner: VM | null): VM | null { + let ancestor = owner; while (!isNull(ancestor) && ancestor.renderMode === RenderMode.Light) { ancestor = ancestor.owner; } @@ -344,16 +344,13 @@ export function createVM( } vm.stylesheets = computeStylesheets(vm, def.ctor); - vm.shadowMode = computeShadowMode(vm, renderer); + vm.shadowMode = computeShadowMode(def, vm.owner, renderer); vm.tro = getTemplateReactiveObserver(vm); if (process.env.NODE_ENV !== 'production') { vm.toString = (): string => { return `[object:vm ${def.name} (${vm.idx})]`; }; - if (lwcRuntimeFlags.ENABLE_FORCE_NATIVE_SHADOW_MODE_FOR_TEST) { - vm.shadowMode = ShadowMode.Native; - } } // Create component instance associated to the vm and the element. @@ -429,8 +426,30 @@ function warnOnStylesheetsMutation(ctor: LightningElementConstructor) { } } -function computeShadowMode(vm: VM, renderer: RendererAPI) { - const { def } = vm; +// Compute the shadowMode/renderMode without creating a VM. This is used in some scenarios like hydration. +export function computeShadowAndRenderMode( + Ctor: LightningElementConstructor, + renderer: RendererAPI +) { + const def = getComponentInternalDef(Ctor); + const { renderMode } = def; + + // Assume null `owner` - this is what happens in hydration cases anyway + const shadowMode = computeShadowMode(def, /* owner */ null, renderer); + + return { renderMode, shadowMode }; +} + +function computeShadowMode(def: ComponentDef, owner: VM | null, renderer: RendererAPI) { + // Force the shadow mode to always be native. Used for running tests with synthetic shadow patches + // on, but components running in actual native shadow mode + if ( + process.env.NODE_ENV !== 'production' && + lwcRuntimeFlags.ENABLE_FORCE_NATIVE_SHADOW_MODE_FOR_TEST + ) { + return ShadowMode.Native; + } + const { isSyntheticShadowDefined } = renderer; let shadowMode; @@ -443,7 +462,7 @@ function computeShadowMode(vm: VM, renderer: RendererAPI) { if (def.shadowSupportMode === ShadowSupportMode.Any) { shadowMode = ShadowMode.Native; } else { - const shadowAncestor = getNearestShadowAncestor(vm); + const shadowAncestor = getNearestShadowAncestor(owner); if (!isNull(shadowAncestor) && shadowAncestor.shadowMode === ShadowMode.Native) { // Transitive support for native Shadow DOM. A component in native mode // transitively opts all of its descendants into native. diff --git a/packages/@lwc/engine-dom/src/apis/build-custom-element-constructor.ts b/packages/@lwc/engine-dom/src/apis/build-custom-element-constructor.ts index 875bc13f2d..008a05f36c 100644 --- a/packages/@lwc/engine-dom/src/apis/build-custom-element-constructor.ts +++ b/packages/@lwc/engine-dom/src/apis/build-custom-element-constructor.ts @@ -6,11 +6,14 @@ */ import { + LightningElement, + RenderMode, + ShadowMode, + computeShadowAndRenderMode, connectRootElement, createVM, disconnectRootElement, getComponentHtmlPrototype, - LightningElement, } from '@lwc/engine-core'; import { isNull } from '@lwc/shared'; import { renderer } from '../renderer'; @@ -62,6 +65,7 @@ export function buildCustomElementConstructor(Ctor: ComponentConstructor): HTMLE return class extends HTMLElement { constructor() { super(); + if (!isNull(this.shadowRoot)) { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console @@ -71,15 +75,27 @@ export function buildCustomElementConstructor(Ctor: ComponentConstructor): HTMLE } clearNode(this.shadowRoot); } - if (this.childNodes.length > 0) { + + // Compute renderMode/shadowMode in advance. This must be done before `createVM` because `createVM` may + // mutate the element. + const { shadowMode, renderMode } = computeShadowAndRenderMode(Ctor, renderer); + + // Native shadow components are allowed to have pre-existing `childNodes` before upgrade. This supports + // use cases where a custom element has declaratively-defined slotted content, e.g.: + // https://github.com/salesforce/lwc/issues/3639 + const isNativeShadow = + renderMode === RenderMode.Shadow && shadowMode === ShadowMode.Native; + if (!isNativeShadow && this.childNodes.length > 0) { if (process.env.NODE_ENV !== 'production') { // eslint-disable-next-line no-console console.warn( - `Custom elements cannot have child nodes. Ensure the element is empty, including whitespace.` + `Light DOM and synthetic shadow custom elements cannot have child nodes. ` + + `Ensure the element is empty, including whitespace.` ); } clearNode(this); } + createVM(this, Ctor, renderer, { mode: 'open', owner: null, diff --git a/packages/@lwc/engine-dom/src/renderer/index.ts b/packages/@lwc/engine-dom/src/renderer/index.ts index 691bef3ce3..cd07452fc7 100644 --- a/packages/@lwc/engine-dom/src/renderer/index.ts +++ b/packages/@lwc/engine-dom/src/renderer/index.ts @@ -193,6 +193,14 @@ function getTagName(elm: Element): string { return elm.tagName; } +// Use the attachInternals method from HTMLElement.prototype because access to it is removed +// in HTMLBridgeElement, ie: elm.attachInternals is undefined. +// Additionally, cache the attachInternals method to protect against 3rd party monkey-patching. +const attachInternalsFunc = HTMLElement.prototype.attachInternals; +function attachInternals(elm: HTMLElement): ElementInternals { + return attachInternalsFunc.call(elm); +} + export { registerContextConsumer, registerContextProvider } from './context'; export { @@ -231,4 +239,5 @@ export { isConnected, assertInstanceOfHTMLElement, ownerDocument, + attachInternals, }; diff --git a/packages/@lwc/engine-server/src/renderer.ts b/packages/@lwc/engine-server/src/renderer.ts index 99f2524835..1b440990ae 100644 --- a/packages/@lwc/engine-server/src/renderer.ts +++ b/packages/@lwc/engine-server/src/renderer.ts @@ -324,25 +324,61 @@ function isConnected(node: HostNode) { return !isNull(node[HostParentKey]); } +function getTagName(elm: HostElement): string { + // tagName is lowercased on the server, but to align with DOM APIs, we always return uppercase + return elm.tagName.toUpperCase(); +} + +type CreateElementAndUpgrade = (upgradeCallback: LifecycleCallback) => HostElement; + +const localRegistryRecord: Map = new Map(); + +function createUpgradableElementConstructor(tagName: string): CreateElementAndUpgrade { + return function Ctor(upgradeCallback: LifecycleCallback) { + const elm = createElement(tagName); + if (isFunction(upgradeCallback)) { + upgradeCallback(elm); // nothing to do with the result for now + } + return elm; + }; +} + +function getUpgradableElement(tagName: string): CreateElementAndUpgrade { + let ctor = localRegistryRecord.get(tagName); + if (!isUndefined(ctor)) { + return ctor; + } + + ctor = createUpgradableElementConstructor(tagName); + localRegistryRecord.set(tagName, ctor); + return ctor; +} + +function createCustomElement(tagName: string, upgradeCallback: LifecycleCallback): HostElement { + const UpgradableConstructor = getUpgradableElement(tagName); + return new (UpgradableConstructor as any)(upgradeCallback); +} + +/** Noop in SSR */ + // Noop on SSR (for now). This need to be reevaluated whenever we will implement support for // synthetic shadow. const insertStylesheet = noop as (content: string, target: any) => void; - -// Noop on SSR. const addEventListener = noop as ( target: HostNode, type: string, callback: EventListener, options?: AddEventListenerOptions | boolean ) => void; - -// Noop on SSR. const removeEventListener = noop as ( target: HostNode, type: string, callback: EventListener, options?: AddEventListenerOptions | boolean ) => void; +const assertInstanceOfHTMLElement = noop as (elm: any, msg: string) => void; + +/** Unsupported methods in SSR */ const dispatchEvent = unsupportedMethod('dispatchEvent') as (target: any, event: Event) => boolean; const getBoundingClientRect = unsupportedMethod('getBoundingClientRect') as ( @@ -376,46 +412,10 @@ const getLastChild = unsupportedMethod('getLastChild') as (element: HostElement) const getLastElementChild = unsupportedMethod('getLastElementChild') as ( element: HostElement ) => HostElement | null; - -function getTagName(elm: HostElement): string { - // tagName is lowercased on the server, but to align with DOM APIs, we always return uppercase - return elm.tagName.toUpperCase(); -} - -/* noop */ -const assertInstanceOfHTMLElement = noop as (elm: any, msg: string) => void; - -type CreateElementAndUpgrade = (upgradeCallback: LifecycleCallback) => HostElement; - -const localRegistryRecord: Map = new Map(); - -function createUpgradableElementConstructor(tagName: string): CreateElementAndUpgrade { - return function Ctor(upgradeCallback: LifecycleCallback) { - const elm = createElement(tagName); - if (isFunction(upgradeCallback)) { - upgradeCallback(elm); // nothing to do with the result for now - } - return elm; - }; -} - -function getUpgradableElement(tagName: string): CreateElementAndUpgrade { - let ctor = localRegistryRecord.get(tagName); - if (!isUndefined(ctor)) { - return ctor; - } - - ctor = createUpgradableElementConstructor(tagName); - localRegistryRecord.set(tagName, ctor); - return ctor; -} - -function createCustomElement(tagName: string, upgradeCallback: LifecycleCallback): HostElement { - const UpgradableConstructor = getUpgradableElement(tagName); - return new (UpgradableConstructor as any)(upgradeCallback); -} - const ownerDocument = unsupportedMethod('ownerDocument') as (element: HostElement) => Document; +const attachInternals = unsupportedMethod('attachInternals') as ( + elm: HTMLElement +) => ElementInternals; export const renderer = { isSyntheticShadowDefined, @@ -457,4 +457,5 @@ export const renderer = { assertInstanceOfHTMLElement, ownerDocument, registerContextConsumer, + attachInternals, }; diff --git a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/index.spec.js b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/index.spec.js index 46155c4008..b011731ea7 100644 --- a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/index.spec.js +++ b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/index.spec.js @@ -7,6 +7,8 @@ import DefinedComponent from 'x/definedComponent'; import UndefinedComponent from 'x/undefinedComponent'; import AttrChanged from 'x/attrChanged'; import ReflectCamel from 'x/reflectCamel'; +import WithChildElmsHasSlot from 'x/withChildElmsHasSlot'; +import WithChildElmsHasSlotLight from 'x/withChildElmsHasSlotLight'; it('should throw when trying to claim abstract LightningElement as custom element', () => { expect(() => LightningElement.CustomElementConstructor).toThrowError( @@ -69,7 +71,16 @@ describe('non-empty custom element', () => { afterEach(() => { consoleSpy.reset(); }); - it('should log error if custom element has children', () => { + + function expectWarnings(expectedWarnings) { + const observedWarnings = consoleSpy.calls.warn + .flat() + .map((err) => (err instanceof Error ? err.message : err)); + + expect(observedWarnings).toEqual(expectedWarnings); + } + + it('should log error if non-native-shadow custom element has children', () => { const elm = document.createElement('test-custom-element-preexisting2'); elm.innerHTML = '
child1
child2
'; document.body.appendChild(elm); @@ -78,19 +89,76 @@ describe('non-empty custom element', () => { // "creating" a new component, so we can register under a different tag class extends WithChildElms.CustomElementConstructor {} ); - const observedErrors = consoleSpy.calls.warn - .flat() - .map((err) => (err instanceof Error ? err.message : err)); + if (process.env.NODE_ENV !== 'production' && !process.env.NATIVE_SHADOW) { + expectWarnings([ + 'Light DOM and synthetic shadow custom elements cannot have child nodes. Ensure the element is empty, including whitespace.', + ]); + } else { + expectWarnings([]); + } - if (process.env.NODE_ENV === 'production') { - expect(observedErrors).toEqual([]); + expect(elm.shadowRoot.childNodes.length).toBe(1); + expect(elm.shadowRoot.childNodes[0].tagName).toBe('DIV'); + expect(elm.shadowRoot.childNodes[0].textContent).toBe(''); + + if (process.env.NATIVE_SHADOW) { + // slotted pre-existing content is supported for native shadow + expect(elm.innerHTML).toBe('
child1
child2
'); } else { - expect(observedErrors).toEqual([ - 'Custom elements cannot have child nodes. Ensure the element is empty, including whitespace.', + expect(elm.childNodes.length).toBe(0); + } + }); + + it('should log error if slotted light dom custom element has children', () => { + const elm = document.createElement('test-with-child-elms-has-slot-light'); + elm.innerHTML = '
Slotted
'; + document.body.appendChild(elm); + customElements.define( + 'test-with-child-elms-has-slot-light', + // "creating" a new component, so we can register under a different tag + class extends WithChildElmsHasSlotLight.CustomElementConstructor {} + ); + + if (process.env.NODE_ENV !== 'production') { + expectWarnings([ + 'Light DOM and synthetic shadow custom elements cannot have child nodes. Ensure the element is empty, including whitespace.', ]); + } else { + expectWarnings([]); } - expect(elm.shadowRoot.innerHTML).toBe('
'); + + expect(elm.innerHTML).toBe(''); }); + + it('should log error if slotted synthetic shadow dom custom element has children', () => { + const elm = document.createElement('test-with-child-elms-has-slot'); + elm.innerHTML = '
Slotted
'; + document.body.appendChild(elm); + customElements.define( + 'test-with-child-elms-has-slot', + // "creating" a new component, so we can register under a different tag + class extends WithChildElmsHasSlot.CustomElementConstructor {} + ); + if (process.env.NODE_ENV !== 'production' && !process.env.NATIVE_SHADOW) { + expectWarnings([ + 'Light DOM and synthetic shadow custom elements cannot have child nodes. Ensure the element is empty, including whitespace.', + ]); + } else { + expectWarnings([]); + } + + expect(elm.shadowRoot.childNodes.length).toBe(1); + expect(elm.shadowRoot.childNodes[0].tagName).toBe('SLOT'); + expect(elm.shadowRoot.childNodes[0].textContent).toBe(''); + + if (process.env.NATIVE_SHADOW) { + // slotted pre-existing content is supported for native shadow + expect(elm.innerHTML).toBe('
Slotted
'); + } else { + expect(elm.childNodes.length).toBe(0); + } + }); + it('should log error if custom element has shadow root', () => { const elm = document.createElement('test-custom-element-preexisting3'); elm.attachShadow({ mode: 'open' }); @@ -101,14 +169,11 @@ describe('non-empty custom element', () => { // "creating" a new component, so we can register under a different tag class extends WithChildElms.CustomElementConstructor {} ); - const observedErrors = consoleSpy.calls.warn - .flat() - .map((err) => (err instanceof Error ? err.message : err)); if (process.env.NODE_ENV === 'production') { - expect(observedErrors).toEqual([]); + expectWarnings([]); } else { - expect(observedErrors).toEqual([ + expectWarnings([ 'Found an existing shadow root for the custom element "Child". Call `hydrateComponent` instead.', ]); } @@ -124,18 +189,17 @@ describe('non-empty custom element', () => { elm.innerHTML = 'Slotted'; document.body.appendChild(elm); - const observedErrors = consoleSpy.calls.warn - .flat() - .map((err) => (err instanceof Error ? err.message : err)); - if (process.env.NODE_ENV === 'production') { - expect(observedErrors).toEqual([]); - } else { - expect(observedErrors).toEqual([ - 'Custom elements cannot have child nodes. Ensure the element is empty, including whitespace.', + if (process.env.NODE_ENV !== 'production' && !process.env.NATIVE_SHADOW) { + expectWarnings([ + 'Light DOM and synthetic shadow custom elements cannot have child nodes. Ensure the element is empty, including whitespace.', ]); + } else { + expectWarnings([]); } - expect(elm.children[0].shadowRoot.innerHTML).toBe('
'); + + expect(elm.childNodes.length).toBe(1); + expect(elm.childNodes[0].shadowRoot.innerHTML).toBe('
'); }); }); diff --git a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElms/withChildElms.html b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElms/withChildElms.html index a1c80d08ef..0ada1d8a6a 100644 --- a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElms/withChildElms.html +++ b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElms/withChildElms.html @@ -1,3 +1 @@ - + diff --git a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlot/withChildElmsHasSlot.html b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlot/withChildElmsHasSlot.html new file mode 100644 index 0000000000..6806310398 --- /dev/null +++ b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlot/withChildElmsHasSlot.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlot/withChildElmsHasSlot.js b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlot/withChildElmsHasSlot.js new file mode 100644 index 0000000000..3f0d7e7e52 --- /dev/null +++ b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlot/withChildElmsHasSlot.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class Child extends LightningElement {} diff --git a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlotLight/withChildElmsHasSlotLight.html b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlotLight/withChildElmsHasSlotLight.html new file mode 100644 index 0000000000..bf14f95bb9 --- /dev/null +++ b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlotLight/withChildElmsHasSlotLight.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlotLight/withChildElmsHasSlotLight.js b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlotLight/withChildElmsHasSlotLight.js new file mode 100644 index 0000000000..cd310547e9 --- /dev/null +++ b/packages/@lwc/integration-karma/test/api/CustomElementConstructor-getter/x/withChildElmsHasSlotLight/withChildElmsHasSlotLight.js @@ -0,0 +1,5 @@ +import { LightningElement } from 'lwc'; + +export default class Child extends LightningElement { + static renderMode = 'light'; +} diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/basic/basic.html b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/basic/basic.html new file mode 100644 index 0000000000..41a40c8d47 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/basic/basic.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/basic/basic.js b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/basic/basic.js new file mode 100644 index 0000000000..ca8dce94e0 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/basic/basic.js @@ -0,0 +1,3 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement {} diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/lightDom/lightDom.html b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/lightDom/lightDom.html new file mode 100644 index 0000000000..76eaf56f23 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/lightDom/lightDom.html @@ -0,0 +1,3 @@ + diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/lightDom/lightDom.js b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/lightDom/lightDom.js new file mode 100644 index 0000000000..8ea8056eed --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/lightDom/lightDom.js @@ -0,0 +1,9 @@ +import { LightningElement } from 'lwc'; + +export default class extends LightningElement { + static renderMode = 'light'; + + connectedCallback() { + this.internals = this.attachInternals(); + } +} diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/shadowDom/shadowDom.html b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/shadowDom/shadowDom.html new file mode 100644 index 0000000000..41a40c8d47 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/shadowDom/shadowDom.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/shadowDom/shadowDom.js b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/shadowDom/shadowDom.js new file mode 100644 index 0000000000..d8b072be78 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/ai/shadowDom/shadowDom.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-karma/test/component/LightningElement.attachInternals/api/index.spec.js b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/index.spec.js new file mode 100644 index 0000000000..5278060f88 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/api/index.spec.js @@ -0,0 +1,83 @@ +import { createElement } from 'lwc'; +import { customElementConnectedErrorListener } from 'test-utils'; + +import ShadowDomCmp from 'ai/shadowDom'; +import LightDomCmp from 'ai/lightDom'; +import BasicCmp from 'ai/basic'; + +const testConnectedCallbackError = (elm, msg) => { + const error = customElementConnectedErrorListener(() => { + document.body.appendChild(elm); + }); + expect(error).not.toBeUndefined(); + expect(error.message).toBe(msg); +}; + +const createTestElement = (name, def) => { + const elm = createElement(name, { is: def }); + document.body.appendChild(elm); + return elm; +}; + +if (typeof ElementInternals !== 'undefined') { + if (process.env.NATIVE_SHADOW) { + describe('native shadow', () => { + let elm; + beforeEach(() => { + elm = createTestElement('ai-shadow-component', ShadowDomCmp); + }); + + afterEach(() => { + document.body.removeChild(elm); + }); + + it('should be able to create ElementInternals object', () => { + expect(elm.hasElementInternalsBeenSet()).toBeTruthy(); + }); + + it('should throw an error when called twice on the same element', () => { + // The error type is different between browsers + expect(() => elm.callAttachInternals()).toThrowError(); + }); + }); + } else { + describe('synthetic shadow', () => { + it('should throw error when used inside a component', () => { + const elm = createElement('ai-synthetic-shadow-component', { is: ShadowDomCmp }); + testConnectedCallbackError( + elm, + 'attachInternals API is not supported in light DOM or synthetic shadow.' + ); + }); + }); + } + + describe('light DOM', () => { + it('should throw error when used inside a component', () => { + const elm = createElement('ai-light-dom-component', { is: LightDomCmp }); + testConnectedCallbackError( + elm, + 'attachInternals API is not supported in light DOM or synthetic shadow.' + ); + }); + }); +} else { + it('should throw an error when used with unsupported browser environments', () => { + const elm = createElement('ai-unsupported-env-component', { is: ShadowDomCmp }); + testConnectedCallbackError( + elm, + 'attachInternals API is not supported in this browser environment.' + ); + }); +} + +it('should not be callable outside a component', () => { + const elm = createTestElement('ai-component', BasicCmp); + if (process.env.NODE_ENV === 'production') { + expect(elm.attachInternals).toBeUndefined(); + } else { + expect(() => elm.attachInternals).toLogErrorDev( + /Error: \[LWC error]: attachInternals cannot be accessed outside of a component\. Use this.attachInternals instead\./ + ); + } +}); diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/ei/component/component.html b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/ei/component/component.html new file mode 100644 index 0000000000..cc340bc4c9 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/ei/component/component.html @@ -0,0 +1 @@ + diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/ei/component/component.js b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/ei/component/component.js new file mode 100644 index 0000000000..747ad42073 --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/ei/component/component.js @@ -0,0 +1,22 @@ +import { LightningElement, api } from 'lwc'; +import { ariaProperties } from 'test-utils'; + +export default class extends LightningElement { + @api + internals; + + @api + template; + + connectedCallback() { + this.internals = this.attachInternals(); + this.template = super.template; + } + + @api + setAllAriaProps(value) { + for (const prop of ariaProperties) { + this.internals[prop] = value; + } + } +} diff --git a/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/index.spec.js b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/index.spec.js new file mode 100644 index 0000000000..54c35073da --- /dev/null +++ b/packages/@lwc/integration-karma/test/component/LightningElement.attachInternals/elementInternals/index.spec.js @@ -0,0 +1,44 @@ +import { createElement } from 'lwc'; +import { ariaProperties, ariaAttributes } from 'test-utils'; + +import ElementInternal from 'ei/component'; + +if (process.env.NATIVE_SHADOW && process.env.ELEMENT_INTERNALS_DEFINED) { + let elm; + beforeEach(() => { + elm = createElement('ei-component', { is: ElementInternal }); + document.body.appendChild(elm); + }); + + afterEach(() => { + document.body.removeChild(elm); + }); + + describe('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 not reflect to aria-* attributes', () => { + elm.setAllAriaProps('foo'); + // Firefox does not support ARIAMixin inside ElementInternals + 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'); + } + // Firefox does not support ARIAMixin inside ElementInternals + for (const prop of ariaProperties) { + expect(elm.internals[prop]).toBeFalsy(); + } + }); + }); + }); +} diff --git a/packages/@lwc/integration-karma/test/component/properties/index.spec.js b/packages/@lwc/integration-karma/test/component/properties/index.spec.js index 1a1b821c30..6e1f093be7 100644 --- a/packages/@lwc/integration-karma/test/component/properties/index.spec.js +++ b/packages/@lwc/integration-karma/test/component/properties/index.spec.js @@ -7,6 +7,7 @@ import Component from 'x/component'; const expectedEnumerableProps = [ 'accessKey', 'addEventListener', + 'attachInternals', 'childNodes', 'children', 'classList', @@ -50,6 +51,7 @@ const expectedEnumerableProps = [ const expectedEnumerableAndWritableProps = [ 'addEventListener', + 'attachInternals', 'dispatchEvent', 'getAttribute', 'getAttributeNS', diff --git a/yarn.lock b/yarn.lock index 8251e0e036..8059628a08 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1134,10 +1134,10 @@ resolved "https://registry.yarnpkg.com/@ljharb/through/-/through-2.3.9.tgz#85f221eb82f9d555e180e87d6e50fb154af85408" integrity sha512-yN599ZBuMPPK4tdoToLlvgJB4CLK8fGl7ntfy0Wn7U6ttNvHYurd81bfUiK/6sMkiIwm65R6ck4L6+Y3DfVbNQ== -"@locker/babel-plugin-transform-unforgeables@0.19.8": - version "0.19.8" - resolved "https://registry.yarnpkg.com/@locker/babel-plugin-transform-unforgeables/-/babel-plugin-transform-unforgeables-0.19.8.tgz#246942d5e1b8c549534e2d230b8c92287cbcf88d" - integrity sha512-E5cSM/qYTL0TdscEXaYiI8sPqpZW/ry1ltzILKtm0D6MBmtR6/z83+rgm65RGCtlJB1lIGQeXuOwoMVVfTRezA== +"@locker/babel-plugin-transform-unforgeables@0.19.9": + version "0.19.9" + resolved "https://registry.yarnpkg.com/@locker/babel-plugin-transform-unforgeables/-/babel-plugin-transform-unforgeables-0.19.9.tgz#43981a48a2b31994afb69dd4808fa7c4f7d038f6" + integrity sha512-MMshwLWU/WdeyMJRwgxrcgpQUEH2/Iul9pr1VS9uHP5QkKIG755MJ6IjNtdvLTVQdhxkVxr+bJjsg8wGhEBAzA== dependencies: "@babel/generator" "7.21.4" match-json "1.3.5"