Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(versioning): use native custom element lifecycle #3352

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
14 changes: 7 additions & 7 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,9 @@ commands:
force_native_shadow_mode:
type: boolean
default: false
enable_native_custom_element_lifecycle:
api_version_59:
type: boolean
default: false
default: true
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would love to use the integer type here, but I could not get it to work, and I figured there's little point in debugging Circle CI oddities since we may move away from Circle CI anyway.

disable_aria_reflection_polyfill:
type: boolean
default: false
Expand Down Expand Up @@ -86,9 +86,9 @@ commands:
exit 1
fi
}
<<# parameters.api_version_59 >> API_VERSION=59 <</ parameters.api_version_59 >> \
<<# parameters.disable_synthetic >> DISABLE_SYNTHETIC=1 <</ parameters.disable_synthetic >> \
<<# parameters.force_native_shadow_mode >> FORCE_NATIVE_SHADOW_MODE_FOR_TEST=1 <</ parameters.force_native_shadow_mode >> \
<<# parameters.enable_native_custom_element_lifecycle >> ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 <</ parameters.enable_native_custom_element_lifecycle >> \
<<# parameters.disable_aria_reflection_polyfill >> DISABLE_ARIA_REFLECTION_POLYFILL=1 <</ parameters.disable_aria_reflection_polyfill >> \
<<# parameters.node_env_production >> NODE_ENV_FOR_TEST=production <</ parameters.node_env_production >> \
<<# parameters.disable_synthetic_shadow_support_in_compiler >> DISABLE_SYNTHETIC_SHADOW_SUPPORT_IN_COMPILER=1 <</ parameters.disable_synthetic_shadow_support_in_compiler >> \
Expand Down Expand Up @@ -176,7 +176,7 @@ commands:
force_native_shadow_mode:
type: boolean
default: false
enable_native_custom_element_lifecycle:
api_version_59:
type: boolean
default: false
disable_aria_reflection_polyfill:
Expand All @@ -200,7 +200,7 @@ commands:
working_directory: packages/@lwc/integration-karma
disable_synthetic: << parameters.disable_synthetic >>
force_native_shadow_mode: << parameters.force_native_shadow_mode >>
enable_native_custom_element_lifecycle: << parameters.enable_native_custom_element_lifecycle >>
api_version_59: << parameters.api_version_59 >>
disable_aria_reflection_polyfill: << parameters.disable_aria_reflection_polyfill >>
node_env_production: << parameters.node_env_production >>
disable_synthetic_shadow_support_in_compiler: << parameters.disable_synthetic_shadow_support_in_compiler >>
Expand Down Expand Up @@ -262,10 +262,10 @@ jobs:
- run_karma:
force_native_shadow_mode: true
- run_karma:
enable_native_custom_element_lifecycle: true
api_version_59: true
- run_karma:
disable_synthetic: true
enable_native_custom_element_lifecycle: true
api_version_59: true
- run_karma:
disable_synthetic: true
disable_aria_reflection_polyfill: true
Expand Down
4 changes: 2 additions & 2 deletions packages/@lwc/engine-core/src/framework/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,8 @@ export interface RendererAPI {
createCustomElement: (
tagName: string,
upgradeCallback: LifecycleCallback,
connectedCallback?: LifecycleCallback,
disconnectedCallback?: LifecycleCallback
connectedCallback: LifecycleCallback,
disconnectedCallback: LifecycleCallback
) => E;
ownerDocument(elm: E): Document;
registerContextConsumer: (
Expand Down
40 changes: 27 additions & 13 deletions packages/@lwc/engine-core/src/framework/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import {
ArrayPush,
APIFeature,
ArrayPop,
ArrayPush,
ArraySome,
assert,
create,
isAPIFeatureEnabled,
isArray,
isFalse,
isNull,
Expand All @@ -23,9 +25,9 @@ import {

import { logError } from '../shared/logger';
import { getComponentTag } from '../shared/format';
import { LifecycleCallback, RendererAPI } from './renderer';
import { RendererAPI } from './renderer';
import { EmptyArray } from './utils';
import { markComponentAsDirty } from './component';
import { getComponentAPIVersion, markComponentAsDirty } from './component';
import { getScopeTokenClass } from './stylesheet';
import { lockDomMutation, patchElementWithRestrictions, unlockDomMutation } from './restrictions';
import {
Expand Down Expand Up @@ -70,6 +72,7 @@ import { patchStyleAttribute } from './modules/computed-style-attr';
import { applyEventListeners } from './modules/events';
import { applyStaticClassAttribute } from './modules/static-class-attr';
import { applyStaticStyleAttribute } from './modules/static-style-attr';
import { LightningElementConstructor } from './base-lightning-element';

export function patchChildren(
c1: VNodes,
Expand Down Expand Up @@ -295,6 +298,18 @@ function mountStatic(
insertNode(elm, parent, anchor, renderer);
}

function shouldVMUseNativeCustomElementLifecycle(vm: VM | undefined) {
if (isUndefined(vm)) {
return false;
}

const apiVersion = getComponentAPIVersion(
vm.component.constructor as LightningElementConstructor
);

return isAPIFeatureEnabled(APIFeature.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE, apiVersion);
}

function mountCustomElement(
vnode: VCustomElement,
parent: ParentNode,
Expand All @@ -316,17 +331,16 @@ function mountCustomElement(
vm = createViewModelHook(elm, vnode, renderer);
};

let connectedCallback: LifecycleCallback | undefined;
let disconnectedCallback: LifecycleCallback | undefined;

if (lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
connectedCallback = (elm: HTMLElement) => {
const connectedCallback = (elm: HTMLElement) => {
if (shouldVMUseNativeCustomElementLifecycle(vm)) {
connectRootElement(elm);
};
disconnectedCallback = (elm: HTMLElement) => {
}
};
const disconnectedCallback = (elm: HTMLElement) => {
if (shouldVMUseNativeCustomElementLifecycle(vm)) {
disconnectRootElement(elm);
};
}
}
};

// Should never get a tag with upper case letter at this point; the compiler
// should produce only tags with lowercase letters. However, the Java
Expand Down Expand Up @@ -355,7 +369,7 @@ function mountCustomElement(

if (vm) {
if (process.env.IS_BROWSER) {
if (!lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
if (!shouldVMUseNativeCustomElementLifecycle(vm)) {
if (process.env.NODE_ENV !== 'production') {
// With synthetic lifecycle callbacks, it's possible for elements to be removed without the engine
// noticing it (e.g. `appendChild` the same host element twice). This test ensures we don't regress.
Expand Down
45 changes: 31 additions & 14 deletions packages/@lwc/engine-dom/src/apis/create-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,17 @@ import {
isNull,
isObject,
isUndefined,
toString,
StringToLowerCase,
toString,
APIFeature,
isAPIFeatureEnabled,
} from '@lwc/shared';
import {
createVM,
connectRootElement,
createVM,
disconnectRootElement,
LightningElement,
LifecycleCallback,
getComponentAPIVersion,
} from '@lwc/engine-core';
import { renderer } from '../renderer';

Expand All @@ -46,7 +48,14 @@ function callNodeSlot(node: Node, slot: WeakMap<any, NodeSlotCallback>): Node {
return node; // for convenience
}

if (!lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
let monkeyPatched = false;

function monkeyPatchDomAPIs() {
if (monkeyPatched) {
// don't double-patch
return;
}
monkeyPatched = true;
// Monkey patching Node methods to be able to detect the insertions and removal of root elements
// created via createElement.
const { appendChild, insertBefore, removeChild, replaceChild } = _Node.prototype;
Expand Down Expand Up @@ -112,6 +121,12 @@ export function createElement(
// the following line guarantees that this does not leaks beyond this point.
const tagName = StringToLowerCase.call(sel);

const apiVersion = getComponentAPIVersion(Ctor);
const useNativeCustomElementLifecycle = isAPIFeatureEnabled(
APIFeature.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE,
apiVersion
);

// the custom element from the registry is expecting an upgrade callback
/**
* Note: if the upgradable constructor does not expect, or throw when we new it
Expand All @@ -125,23 +140,25 @@ export function createElement(
mode: options.mode !== 'closed' ? 'open' : 'closed',
owner: null,
});
if (!lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
if (!useNativeCustomElementLifecycle) {
// Monkey-patch on-demand, because if there are no components on the page using an old API
// version, then we don't want to monkey patch at all
monkeyPatchDomAPIs();
ConnectingSlot.set(elm, connectRootElement);
DisconnectingSlot.set(elm, disconnectRootElement);
}
};

let connectedCallback: LifecycleCallback | undefined;
let disconnectedCallback: LifecycleCallback | undefined;

if (lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
connectedCallback = (elm: HTMLElement) => {
const connectedCallback = (elm: HTMLElement) => {
if (useNativeCustomElementLifecycle) {
connectRootElement(elm);
};
disconnectedCallback = (elm: HTMLElement) => {
}
};
const disconnectedCallback = (elm: HTMLElement) => {
if (useNativeCustomElementLifecycle) {
disconnectRootElement(elm);
};
}
}
};

const element = createCustomElement(
tagName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,67 +8,58 @@ import { isUndefined } from '@lwc/shared';
import type { LifecycleCallback } from '@lwc/engine-core';

const cachedConstructors = new Map<string, CustomElementConstructor>();
const elementsUpgradedOutsideLWC = new WeakSet<HTMLElement>();

let elementBeingUpgradedByLWC = false;
let upgradeCallbackToUse: LifecycleCallback | undefined;
let connectedCallbackToUse: LifecycleCallback | undefined;
let disconnectedCallbackToUse: LifecycleCallback | undefined;

const instancesToConnectedCallbacks = new WeakMap<HTMLElement, LifecycleCallback>();
const instancesToDisconnectedCallbacks = new WeakMap<HTMLElement, LifecycleCallback>();

// Creates a constructor that is intended to be used directly as a custom element, except that the upgradeCallback is
// passed in to the constructor so LWC can reuse the same custom element constructor for multiple components.
// Another benefit is that only LWC can create components that actually do anything – if you do
// `customElements.define('x-foo')`, then you don't have access to the upgradeCallback, so it's a dummy custom element.
// This class should be created once per tag name.
const createUpgradableConstructor = (
connectedCallback?: LifecycleCallback,
disconnectedCallback?: LifecycleCallback
) => {
const hasConnectedCallback = !isUndefined(connectedCallback);
const hasDisconnectedCallback = !isUndefined(disconnectedCallback);

const createUpgradableConstructor = () => {
// TODO [#2972]: this class should expose observedAttributes as necessary
class UpgradableConstructor extends HTMLElement {
constructor(upgradeCallback: LifecycleCallback) {
return class UpgradableConstructor extends HTMLElement {
constructor() {
super();
// If the element is not created using lwc.createElement(), e.g. `document.createElement('x-foo')`,
// then elementBeingUpgraded will be false
if (elementBeingUpgradedByLWC) {
upgradeCallback(this);
} else if (hasConnectedCallback || hasDisconnectedCallback) {
// If this element has connected or disconnected callbacks, then we need to keep track of
// instances that were created outside LWC (i.e. not created by `lwc.createElement()`).
// If the element has no connected or disconnected callbacks, then we don't need to track this.
elementsUpgradedOutsideLWC.add(this);

// TODO [#2970]: LWC elements cannot be upgraded via new Ctor()
// Do we want to support this? Throw an error? Currently for backwards compat it's a no-op.
// Cache the callbacks in the weak maps
instancesToConnectedCallbacks.set(this, connectedCallbackToUse!);
instancesToDisconnectedCallbacks.set(this, disconnectedCallbackToUse!);
upgradeCallbackToUse!(this);
}
// TODO [#2970]: LWC elements cannot be upgraded via new Ctor()
// Do we want to support this? Throw an error? Currently for backwards compat it's a no-op.
}
}

// Do not unnecessarily add a connectedCallback/disconnectedCallback, as it introduces perf overhead
// See: https://github.com/salesforce/lwc/pull/3162#issuecomment-1311851174
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What I'm doing here is a known perf regression (adding the connectedCallback / disconnectedCallback to the prototype). But there is no other way to solve this, so this is just a hit we have to take.

We may be able to solve the perf regression if we do #3202 someday.

if (hasConnectedCallback) {
(UpgradableConstructor.prototype as any).connectedCallback = function () {
if (!elementsUpgradedOutsideLWC.has(this)) {
connectedCallback() {
const connectedCallback = instancesToConnectedCallbacks.get(this);
if (!isUndefined(connectedCallback)) {
connectedCallback(this);
}
};
}
}

if (hasDisconnectedCallback) {
(UpgradableConstructor.prototype as any).disconnectedCallback = function () {
if (!elementsUpgradedOutsideLWC.has(this)) {
disconnectedCallback() {
const disconnectedCallback = instancesToDisconnectedCallbacks.get(this);
if (!isUndefined(disconnectedCallback)) {
disconnectedCallback(this);
}
};
}

return UpgradableConstructor;
}
};
};

export const createCustomElementUsingUpgradableConstructor = (
tagName: string,
upgradeCallback: LifecycleCallback,
connectedCallback?: LifecycleCallback,
disconnectedCallback?: LifecycleCallback
connectedCallback: LifecycleCallback,
disconnectedCallback: LifecycleCallback
) => {
// use global custom elements registry
let UpgradableConstructor = cachedConstructors.get(tagName);
Expand All @@ -79,18 +70,21 @@ export const createCustomElementUsingUpgradableConstructor = (
`Unexpected tag name "${tagName}". This name is a registered custom element, preventing LWC to upgrade the element.`
);
}
UpgradableConstructor = createUpgradableConstructor(
connectedCallback,
disconnectedCallback
);
UpgradableConstructor = createUpgradableConstructor();
customElements.define(tagName, UpgradableConstructor);
cachedConstructors.set(tagName, UpgradableConstructor);
}

elementBeingUpgradedByLWC = true;
upgradeCallbackToUse = upgradeCallback;
connectedCallbackToUse = connectedCallback;
disconnectedCallbackToUse = disconnectedCallback;
try {
return new UpgradableConstructor(upgradeCallback);
return new UpgradableConstructor();
} finally {
elementBeingUpgradedByLWC = false;
upgradeCallbackToUse = undefined;
connectedCallbackToUse = undefined;
disconnectedCallbackToUse = undefined;
}
};
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,8 @@ import type { LifecycleCallback } from '@lwc/engine-core';
export let createCustomElement: (
tagName: string,
upgradeCallback: LifecycleCallback,
connectedCallback?: LifecycleCallback,
disconnectedCallback?: LifecycleCallback
connectedCallback: LifecycleCallback,
disconnectedCallback: LifecycleCallback
) => HTMLElement;

if (hasCustomElements) {
Expand Down