Skip to content

Commit

Permalink
feat(engine): enable attachInternals API (#3670)
Browse files Browse the repository at this point in the history
  • Loading branch information
jmsjtu committed Aug 17, 2023
1 parent 0bf6354 commit 44a01ef
Show file tree
Hide file tree
Showing 16 changed files with 280 additions and 43 deletions.
13 changes: 13 additions & 0 deletions packages/@lwc/engine-core/src/framework/base-bridge-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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', {
Expand Down
25 changes: 25 additions & 0 deletions packages/@lwc/engine-core/src/framework/base-lightning-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
defineProperties,
defineProperty,
freeze,
isFalse,
isFunction,
isNull,
isObject,
Expand Down Expand Up @@ -144,6 +145,7 @@ type HTMLElementTheGoodParts = Pick<Object, 'toString'> &
HTMLElement,
| 'accessKey'
| 'addEventListener'
| 'attachInternals'
| 'children'
| 'childNodes'
| 'classList'
Expand Down Expand Up @@ -294,6 +296,8 @@ function warnIfInvokedDuringConstruction(vm: VM, methodOrPropName: string) {
}
}

const supportsElementInternals = typeof ElementInternals !== 'undefined';

// @ts-ignore
LightningElement.prototype = {
constructor: LightningElement,
Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions packages/@lwc/engine-core/src/framework/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,5 @@ export interface RendererAPI {
adapterContextToken: string,
subscriptionPayload: WireContextSubscriptionPayload
) => void;
attachInternals: (elm: E) => ElementInternals;
}
9 changes: 9 additions & 0 deletions packages/@lwc/engine-dom/src/renderer/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -231,4 +239,5 @@ export {
isConnected,
assertInstanceOfHTMLElement,
ownerDocument,
attachInternals,
};
87 changes: 44 additions & 43 deletions packages/@lwc/engine-server/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, CreateElementAndUpgrade> = 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 (
Expand Down Expand Up @@ -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<string, CreateElementAndUpgrade> = 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,
Expand Down Expand Up @@ -457,4 +457,5 @@ export const renderer = {
assertInstanceOfHTMLElement,
ownerDocument,
registerContextConsumer,
attachInternals,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<template></template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { LightningElement } from 'lwc';

export default class extends LightningElement {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template lwc:render-mode="light">
<p>Hello, Light DOM</p>
</template>
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { LightningElement } from 'lwc';

export default class extends LightningElement {
static renderMode = 'light';

connectedCallback() {
this.internals = this.attachInternals();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<template></template>
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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\./
);
}
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<template></template>
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
Loading

0 comments on commit 44a01ef

Please sign in to comment.