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

fix(engine-dom): use native lifecycle callbacks #3904

Merged
merged 10 commits into from
Jan 17, 2024
2 changes: 0 additions & 2 deletions .github/workflows/karma.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,8 +55,6 @@ jobs:
- run: DISABLE_SYNTHETIC=1 yarn sauce:ci
- run: LEGACY_BROWSERS=1 yarn sauce:ci
- run: FORCE_NATIVE_SHADOW_MODE_FOR_TEST=1 yarn sauce:ci
- run: ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 yarn sauce:ci
- run: ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE=1 DISABLE_SYNTHETIC=1 yarn sauce:ci
- run: API_VERSION=58 yarn sauce:ci
- run: API_VERSION=58 DISABLE_SYNTHETIC=1 yarn sauce:ci
- run: API_VERSION=59 yarn sauce:ci
Expand Down
6 changes: 5 additions & 1 deletion packages/@lwc/engine-core/src/framework/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,11 @@ export interface RendererAPI {
isConnected: (node: N) => boolean;
insertStylesheet: (content: string, target?: ShadowRoot) => void;
assertInstanceOfHTMLElement: (elm: any, msg: string) => void;
createCustomElement: (tagName: string, upgradeCallback: LifecycleCallback) => E;
createCustomElement: (
tagName: string,
upgradeCallback: LifecycleCallback,
useNativeLifecycle: boolean
) => E;
defineCustomElement: (tagName: string) => void;
ownerDocument(elm: E): Document;
registerContextConsumer: (
Expand Down
12 changes: 8 additions & 4 deletions packages/@lwc/engine-core/src/framework/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import {
import { logError } from '../shared/logger';
import { getComponentTag } from '../shared/format';
import { RendererAPI } from './renderer';
import { EmptyArray } from './utils';
import { EmptyArray, shouldUseNativeCustomElementLifecycle } from './utils';
import { markComponentAsDirty } from './component';
import { getScopeTokenClass } from './stylesheet';
import { lockDomMutation, patchElementWithRestrictions, unlockDomMutation } from './restrictions';
Expand Down Expand Up @@ -69,6 +69,7 @@ import { applyStaticClassAttribute } from './modules/static-class-attr';
import { applyStaticStyleAttribute } from './modules/static-style-attr';
import { applyRefs } from './modules/refs';
import { applyStaticParts } from './modules/static-parts';
import { LightningElementConstructor } from './base-lightning-element';

export function patchChildren(
c1: VNodes,
Expand Down Expand Up @@ -308,7 +309,7 @@ function mountCustomElement(
anchor: Node | null,
renderer: RendererAPI
) {
const { sel, owner } = vnode;
const { sel, owner, ctor } = vnode;
const { createCustomElement } = renderer;
/**
* Note: if the upgradable constructor does not expect, or throw when we new it
Expand All @@ -328,7 +329,10 @@ function mountCustomElement(
// compiler may generate tagnames with uppercase letters so - for backwards
// compatibility, we lower case the tagname here.
const normalizedTagname = sel.toLowerCase();
const elm = createCustomElement(normalizedTagname, upgradeCallback);
const useNativeLifecycle = shouldUseNativeCustomElementLifecycle(
ctor as LightningElementConstructor
);
const elm = createCustomElement(normalizedTagname, upgradeCallback, useNativeLifecycle);

vnode.elm = elm;
vnode.vm = vm;
Expand All @@ -345,7 +349,7 @@ function mountCustomElement(

if (vm) {
if (process.env.IS_BROWSER) {
if (!lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
if (!useNativeLifecycle) {
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
24 changes: 23 additions & 1 deletion packages/@lwc/engine-core/src/framework/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,19 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { ArrayPush, create, isArray, isFunction, keys, seal } from '@lwc/shared';
import {
ArrayPush,
create,
isArray,
isFunction,
keys,
seal,
isAPIFeatureEnabled,
APIFeature,
} from '@lwc/shared';
import { StylesheetFactory, TemplateStylesheetFactories } from './stylesheet';
import { getComponentAPIVersion } from './component';
import { LightningElementConstructor } from './base-lightning-element';

type Callback = () => void;

Expand Down Expand Up @@ -54,6 +65,17 @@ export function guid(): string {
return s4() + s4() + '-' + s4() + '-' + s4() + '-' + s4() + '-' + s4() + s4() + s4();
}

export function shouldUseNativeCustomElementLifecycle(ctor: LightningElementConstructor) {
if (lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
// temporary "kill switch"
return false;
}

const apiVersion = getComponentAPIVersion(ctor);

return isAPIFeatureEnabled(APIFeature.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE, apiVersion);
}

// Borrowed from Vue template compiler.
// https://github.com/vuejs/vue/blob/531371b818b0e31a989a06df43789728f23dc4e8/src/platforms/web/util/style.js#L5-L16
const DECLARATION_DELIMITER = /;(?![^(]*\))/g;
Expand Down
18 changes: 15 additions & 3 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,13 @@ import {
getTemplateReactiveObserver,
getComponentAPIVersion,
} from './component';
import { addCallbackToNextTick, EmptyArray, EmptyObject, flattenStylesheets } from './utils';
import {
addCallbackToNextTick,
EmptyArray,
EmptyObject,
flattenStylesheets,
shouldUseNativeCustomElementLifecycle,
} from './utils';
import { invokeComponentCallback, invokeComponentConstructor } from './invoker';
import { Template } from './template';
import { ComponentDef, getComponentInternalDef } from './def';
Expand Down Expand Up @@ -280,7 +286,11 @@ function resetComponentStateWhenRemoved(vm: VM) {
// old vnode.children is removed from the DOM.
export function removeVM(vm: VM) {
if (process.env.NODE_ENV !== 'production') {
if (!lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE) {
if (
!shouldUseNativeCustomElementLifecycle(
jmsjtu marked this conversation as resolved.
Show resolved Hide resolved
vm.component.constructor as LightningElementConstructor
)
) {
// With native lifecycle, we cannot be certain that connectedCallback was called before a component
// was removed from the VDOM. If the component is disconnected, then connectedCallback will not fire
// in native mode, although it will fire in synthetic mode due to appendChild triggering it.
Expand Down Expand Up @@ -691,7 +701,9 @@ export function runConnectedCallback(vm: VM) {
// we're in dev mode. This is to detect a particular issue with synthetic lifecycle.
if (
process.env.IS_BROWSER &&
!lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE &&
!shouldUseNativeCustomElementLifecycle(
vm.component.constructor as LightningElementConstructor
) &&
(process.env.NODE_ENV !== 'production' || isReportingEnabled())
) {
if (!vm.renderer.isConnected(vm.elm)) {
Expand Down
27 changes: 23 additions & 4 deletions packages/@lwc/engine-dom/src/apis/create-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ import {
isUndefined,
toString,
StringToLowerCase,
isAPIFeatureEnabled,
APIFeature,
} from '@lwc/shared';
import {
createVM,
connectRootElement,
disconnectRootElement,
LightningElement,
getComponentAPIVersion,
} from '@lwc/engine-core';
import { renderer } from '../renderer';

Expand All @@ -45,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 @@ -111,6 +121,13 @@ 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 =
// temporary "kill switch"
!lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE &&
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 @@ -124,12 +141,14 @@ 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);
}
};

const element = createCustomElement(tagName, upgradeCallback);
return element;
return createCustomElement(tagName, upgradeCallback, useNativeCustomElementLifecycle);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
import { isUndefined, entries } from '@lwc/shared';
import { isUndefined, entries, isTrue } from '@lwc/shared';
import {
LifecycleCallback,
connectRootElement,
Expand All @@ -14,20 +14,20 @@ import {
runFormResetCallback,
runFormStateRestoreCallback,
} from '@lwc/engine-core';

const LIFECYCLE_CALLBACKS = {
connectedCallback: connectRootElement,
disconnectedCallback: disconnectRootElement,
formAssociatedCallback: runFormAssociatedCallback,
formDisabledCallback: runFormDisabledCallback,
formResetCallback: runFormResetCallback,
formStateRestoreCallback: runFormStateRestoreCallback,
};

const cachedConstructors = new Map<string, CustomElementConstructor>();
const elementsUpgradedOutsideLWC = new WeakSet<HTMLElement>();
let elementBeingUpgradedByLWC = false;
const nativeLifecycleElementsToUpgradedByLWC = new WeakMap<HTMLElement, boolean>();

const lifecycleCallbacks = lwcRuntimeFlags.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE
? {
connectedCallback: connectRootElement,
disconnectedCallback: disconnectRootElement,
formAssociatedCallback: runFormAssociatedCallback,
formDisabledCallback: runFormDisabledCallback,
formResetCallback: runFormResetCallback,
formStateRestoreCallback: runFormStateRestoreCallback,
}
: undefined;
let elementBeingUpgradedByLWC = false;

// 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.
Expand All @@ -39,34 +39,34 @@ const createUpgradableConstructor = () => {
class UpgradableConstructor extends HTMLElement {
static formAssociated = true;

constructor(upgradeCallback: LifecycleCallback) {
constructor(upgradeCallback: LifecycleCallback, useNativeLifecycle: boolean) {
super();

if (useNativeLifecycle) {
// When in native lifecycle mode, we need to keep track of instances that were created outside LWC
// (i.e. not created by `lwc.createElement()`). If the element uses synthetic lifecycle, then we don't
// need to track this.
nativeLifecycleElementsToUpgradedByLWC.set(this, elementBeingUpgradedByLWC);
ekashida marked this conversation as resolved.
Show resolved Hide resolved
}

// If the element is not created using lwc.createElement(), e.g. `document.createElement('x-foo')`,
// then elementBeingUpgraded will be false
// then elementBeingUpgradedByLWC will be false
if (elementBeingUpgradedByLWC) {
upgradeCallback(this);
} else if (!isUndefined(lifecycleCallbacks)) {
// If this element has any lifecycle 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.
}
// 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/etc., as it introduces perf overhead
// See: https://github.com/salesforce/lwc/pull/3162#issuecomment-1311851174
if (!isUndefined(lifecycleCallbacks)) {
for (const [propName, callback] of entries(lifecycleCallbacks)) {
(UpgradableConstructor.prototype as any)[propName] = function () {
if (!elementsUpgradedOutsideLWC.has(this)) {
callback(this);
}
};
}
for (const [propName, callback] of entries(LIFECYCLE_CALLBACKS)) {
(UpgradableConstructor.prototype as any)[propName] = function () {
// If the element is in the WeakMap (i.e. it's marked as native lifecycle), and if it was upgraded by LWC,
// then it can use native lifecycle
if (isTrue(nativeLifecycleElementsToUpgradedByLWC.get(this))) {
callback(this);
}
};
}

return UpgradableConstructor;
Expand All @@ -88,12 +88,16 @@ export function getUpgradableConstructor(tagName: string) {
return UpgradableConstructor;
}

export const createCustomElement = (tagName: string, upgradeCallback: LifecycleCallback) => {
export const createCustomElement = (
tagName: string,
upgradeCallback: LifecycleCallback,
useNativeLifecycle: boolean
) => {
const UpgradableConstructor = getUpgradableConstructor(tagName);

elementBeingUpgradedByLWC = true;
try {
return new UpgradableConstructor(upgradeCallback);
return new UpgradableConstructor(upgradeCallback, useNativeLifecycle);
} finally {
elementBeingUpgradedByLWC = false;
}
Expand Down
25 changes: 1 addition & 24 deletions packages/@lwc/engine-server/src/__tests__/fixtures.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,9 @@ import fs from 'fs';
import path from 'path';

import { rollup, RollupLog } from 'rollup';
// @ts-ignore
import lwcRollupPlugin from '@lwc/rollup-plugin';
import { isVoidElement, HTML_NAMESPACE } from '@lwc/shared';
import { testFixtureDir } from '@lwc/jest-utils-lwc-internals';
import { setFeatureFlagForTest } from '../index';
import type { FeatureFlagMap } from '@lwc/features';
import type * as lwc from '../index';

interface FixtureModule {
Expand Down Expand Up @@ -206,26 +203,6 @@ function testFixtures() {
);
}

// Run the fixtures with both synthetic and native custom element lifecycle.
// The expectation is that the fixtures will be exactly the same for both.
describe('fixtures', () => {
describe('synthetic custom element lifecycle', () => {
testFixtures();
});

function testWithFeatureFlagEnabled(flagName: keyof FeatureFlagMap) {
beforeEach(() => {
setFeatureFlagForTest(flagName, true);
});

afterEach(() => {
setFeatureFlagForTest(flagName, false);
});

testFixtures();
}

describe('native custom element lifecycle', () => {
testWithFeatureFlagEnabled('ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE');
});
testFixtures();
});
7 changes: 6 additions & 1 deletion packages/@lwc/engine-server/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,7 +366,12 @@ function getUpgradableElement(
return ctor;
}

function createCustomElement(tagName: string, upgradeCallback: LifecycleCallback): HostElement {
// Note that SSR does not have any concept of native vs synthetic custom element lifecycle
function createCustomElement(
tagName: string,
upgradeCallback: LifecycleCallback,
_useNativeLifecycle: boolean
): HostElement {
const UpgradableConstructor = getUpgradableElement(tagName);
return new (UpgradableConstructor as any)(upgradeCallback);
}
Expand Down
2 changes: 1 addition & 1 deletion packages/@lwc/features/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ const features: FeatureFlagMap = {
PLACEHOLDER_TEST_FLAG: null,
ENABLE_FORCE_NATIVE_SHADOW_MODE_FOR_TEST: null,
ENABLE_MIXED_SHADOW_MODE: null,
ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE: null,
DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE: null,
ENABLE_WIRE_SYNC_EMIT: null,
DISABLE_LIGHT_DOM_UNSCOPED_CSS: null,
ENABLE_FROZEN_TEMPLATE: null,
Expand Down