Skip to content

Commit

Permalink
refactor: apply formAssociated on demand and enable for `CustomElem…
Browse files Browse the repository at this point in the history
…entConstructor` (#4035)
  • Loading branch information
jmsjtu authored Mar 7, 2024
1 parent 88a5909 commit adf9bf6
Show file tree
Hide file tree
Showing 22 changed files with 354 additions and 387 deletions.
106 changes: 11 additions & 95 deletions packages/@lwc/engine-core/src/framework/base-lightning-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,23 @@
*/
import {
AccessibleElementProperties,
APIFeature,
create,
defineProperties,
defineProperty,
entries,
freeze,
hasOwnProperty,
isAPIFeatureEnabled,
isFunction,
isNull,
isObject,
isString,
isUndefined,
KEY__SYNTHETIC_MODE,
keys,
setPrototypeOf,
} from '@lwc/shared';

import { logError, logWarn } from '../shared/logger';
import { logError } from '../shared/logger';
import { getComponentTag } from '../shared/format';
import { ariaReflectionPolyfillDescriptors } from '../libs/aria-reflection/aria-reflection';

Expand Down Expand Up @@ -313,92 +313,6 @@ function warnIfInvokedDuringConstruction(vm: VM, methodOrPropName: string) {
}
}

// List of properties on ElementInternals that are formAssociated can be found in the spec:
// https://html.spec.whatwg.org/multipage/custom-elements.html#form-associated-custom-elements
const formAssociatedProps = new Set([
'setFormValue',
'form',
'setValidity',
'willValidate',
'validity',
'validationMessage',
'checkValidity',
'reportValidity',
'labels',
]);

// Verify that access to a form-associated property of the ElementInternals proxy has formAssociated set in the LWC.
function verifyPropForFormAssociation(propertyKey: string | symbol, isFormAssociated: boolean) {
if (isString(propertyKey) && formAssociatedProps.has(propertyKey) && !isFormAssociated) {
//Note this error message mirrors Chrome and Firefox error messages, in Safari the error is slightly different.
throw new DOMException(
`Failed to execute '${propertyKey}' on 'ElementInternals': The target element is not a form-associated custom element.`
);
}
}

const elementInternalsAccessorAllowList = new Set(['shadowRoot', 'role', ...formAssociatedProps]);

// Prevent access to properties not defined in the HTML spec in case browsers decide to
// provide new APIs that provide access to form associated properties.
// This can be removed along with UpgradeableConstructor.
function isAllowedElementInternalAccessor(propertyKey: string | symbol) {
let isAllowedAccessor = false;
// As of this writing all ElementInternal property keys as described in the spec are implemented with strings
// in Chrome, Firefox, and Safari
if (isString(propertyKey)) {
// Allow list is based on HTML spec:
// https://html.spec.whatwg.org/multipage/custom-elements.html#the-elementinternals-interface
isAllowedAccessor =
elementInternalsAccessorAllowList.has(propertyKey) || /^aria/.test(propertyKey);
if (!isAllowedAccessor && process.env.NODE_ENV !== 'production') {
logWarn('Only properties defined in the ElementInternals HTML spec are available.');
}
}

return isAllowedAccessor;
}

// Wrap all ElementInternal objects in a proxy to prevent form association when `formAssociated` is not set on an LWC.
// This is needed because the 1UpgradeableConstructor1 always sets `formAssociated=true`, which means all
// ElementInternal objects will have form-associated properties set when an LWC is placed in a form.
// We are doing this to guard against customers taking a dependency on form elements being associated to ElementInternals
// when 'formAssociated' has not been set on the LWC.
function createElementInternalsProxy(
elementInternals: ElementInternals,
isFormAssociated: boolean
) {
const elementInternalsProxy = new Proxy(elementInternals, {
set(target, propertyKey, newValue) {
if (isAllowedElementInternalAccessor(propertyKey)) {
// Verify that formAssociated is set for form associated properties
verifyPropForFormAssociation(propertyKey, isFormAssociated);
return Reflect.set(target, propertyKey, newValue);
}
// As of this writing ElementInternals do not have non-string properties that can be set.
return false;
},
get(target, propertyKey) {
if (
// Pass through Object.prototype methods such as toString()
hasOwnProperty.call(Object.prototype, propertyKey) ||
// As of this writing, ElementInternals only uses Symbol.toStringTag which is called
// on Object.hasOwnProperty invocations
Symbol.for('Symbol.toStringTag') === propertyKey ||
// ElementInternals allow listed properties
isAllowedElementInternalAccessor(propertyKey)
) {
// Verify that formAssociated is set for form associated properties
verifyPropForFormAssociation(propertyKey, isFormAssociated);
const propertyValue = Reflect.get(target, propertyKey);
return isFunction(propertyValue) ? propertyValue.bind(target) : propertyValue;
}
},
});

return elementInternalsProxy;
}

// Type assertion because we need to build the prototype before it satisfies the interface.
(LightningElement as { prototype: Partial<LightningElement> }).prototype = {
constructor: LightningElement,
Expand Down Expand Up @@ -568,19 +482,21 @@ function createElementInternalsProxy(
const vm = getAssociatedVM(this);
const {
elm,
def: { formAssociated },
apiVersion,
renderer: { attachInternals },
} = vm;

if (vm.shadowMode === ShadowMode.Synthetic) {
if (!isAPIFeatureEnabled(APIFeature.ENABLE_ELEMENT_INTERNALS, apiVersion)) {
throw new Error(
'attachInternals API is not supported in light DOM or synthetic shadow.'
`The attachInternals API is only supported in API version 61 and above. To use this API please update the LWC component API version.`
);
}

const internals = attachInternals(elm);
// #TODO[2970]: remove proxy once `UpgradeableConstructor` has been removed
return createElementInternalsProxy(internals, Boolean(formAssociated));
if (vm.shadowMode === ShadowMode.Synthetic) {
throw new Error('attachInternals API is not supported in synthetic shadow.');
}

return attachInternals(elm);
},

get isConnected(): boolean {
Expand Down
2 changes: 1 addition & 1 deletion packages/@lwc/engine-core/src/framework/hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -321,7 +321,7 @@ function hydrateCustomElement(

const { sel, mode, ctor, owner } = vnode;
const { defineCustomElement, getTagName } = renderer;
defineCustomElement(StringToLowerCase.call(getTagName(elm)));
defineCustomElement(StringToLowerCase.call(getTagName(elm)), Boolean(ctor.formAssociated));

const vm = createVM(elm, ctor, renderer, {
mode,
Expand Down
5 changes: 3 additions & 2 deletions packages/@lwc/engine-core/src/framework/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,10 @@ export interface RendererAPI {
createCustomElement: (
tagName: string,
upgradeCallback: LifecycleCallback,
useNativeLifecycle: boolean
useNativeLifecycle: boolean,
isFormAssociated: boolean
) => E;
defineCustomElement: (tagName: string) => void;
defineCustomElement: (tagName: string, isFormAssociated: boolean) => void;
ownerDocument(elm: E): Document;
registerContextConsumer: (
element: E,
Expand Down
8 changes: 7 additions & 1 deletion packages/@lwc/engine-core/src/framework/rendering.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,13 @@ function mountCustomElement(
const useNativeLifecycle = shouldUseNativeCustomElementLifecycle(
ctor as LightningElementConstructor
);
const elm = createCustomElement(normalizedTagname, upgradeCallback, useNativeLifecycle);
const isFormAssociated = Boolean(ctor.formAssociated);
const elm = createCustomElement(
normalizedTagname,
upgradeCallback,
useNativeLifecycle,
isFormAssociated
);

vnode.elm = elm;
vnode.vm = vm;
Expand Down
18 changes: 2 additions & 16 deletions packages/@lwc/engine-core/src/framework/vm.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
} from '@lwc/shared';

import { addErrorComponentStack } from '../shared/error';
import { logError, logWarn, logWarnOnce } from '../shared/logger';
import { logError, logWarnOnce } from '../shared/logger';

import { HostNode, HostElement, RendererAPI } from './renderer';
import {
Expand Down Expand Up @@ -944,21 +944,7 @@ export function forceRehydration(vm: VM) {
}

export function runFormAssociatedCustomElementCallback(vm: VM, faceCb: () => void) {
const {
renderMode,
shadowMode,
def: { formAssociated },
} = vm;

// Technically the UpgradableConstructor always sets `static formAssociated = true` but silently fail here to match browser behavior.
if (isUndefined(formAssociated) || isFalse(formAssociated)) {
if (process.env.NODE_ENV !== 'production') {
logWarn(
`Form associated lifecycle methods must have the 'static formAssociated' value set in the component's prototype chain.`
);
}
return;
}
const { renderMode, shadowMode } = vm;

if (shadowMode === ShadowMode.Synthetic && renderMode !== RenderMode.Light) {
throw new Error(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
createVM,
disconnectRootElement,
getComponentHtmlPrototype,
runFormAssociatedCallback,
runFormDisabledCallback,
runFormResetCallback,
runFormStateRestoreCallback,
} from '@lwc/engine-core';
import { isNull } from '@lwc/shared';
import { renderer } from '../renderer';
Expand Down Expand Up @@ -120,6 +124,25 @@ export function buildCustomElementConstructor(Ctor: ComponentConstructor): HTMLE
attributeChangedCallback.call(this, name, oldValue, newValue);
}

formAssociatedCallback() {
runFormAssociatedCallback(this);
}

formDisabledCallback() {
runFormDisabledCallback(this);
}

formResetCallback() {
runFormResetCallback(this);
}

formStateRestoreCallback() {
runFormStateRestoreCallback(this);
}

static observedAttributes = observedAttributes;
// Note CustomElementConstructor is not upgraded by LWC and inherits directly from HTMLElement which means it calls the native
// attachInternals API.
static formAssociated = Boolean(Ctor.formAssociated);
};
}
9 changes: 8 additions & 1 deletion packages/@lwc/engine-dom/src/apis/create-element.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ export function createElement(
!lwcRuntimeFlags.DISABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE &&
isAPIFeatureEnabled(APIFeature.ENABLE_NATIVE_CUSTOM_ELEMENT_LIFECYCLE, apiVersion);

const isFormAssociated = Boolean(Ctor.formAssociated);

// 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 @@ -157,5 +159,10 @@ export function createElement(
}
};

return createCustomElement(tagName, upgradeCallback, useNativeCustomElementLifecycle);
return createCustomElement(
tagName,
upgradeCallback,
useNativeCustomElementLifecycle,
isFormAssociated
);
}
5 changes: 4 additions & 1 deletion packages/@lwc/engine-dom/src/apis/hydrate-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ export function hydrateComponent(

try {
const { defineCustomElement, getTagName } = renderer;
defineCustomElement(StringToLowerCase.call(getTagName(element)));
defineCustomElement(
StringToLowerCase.call(getTagName(element)),
Boolean(Ctor.formAssociated)
);
const vm = createVMWithProps(element, Ctor, props);

hydrateRoot(vm);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,11 +34,10 @@ let elementBeingUpgradedByLWC = false;
// 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 = () => {
const createUpgradableConstructor = (isFormAssociated: boolean) => {
// TODO [#2972]: this class should expose observedAttributes as necessary
class UpgradableConstructor extends HTMLElement {
// TODO [#3983]: Re-enable formAssociated once there is a solution for the observable behavior it introduces.
// static formAssociated = true;
static formAssociated = isFormAssociated;

constructor(upgradeCallback: LifecycleCallback, useNativeLifecycle: boolean) {
super();
Expand Down Expand Up @@ -73,7 +72,7 @@ const createUpgradableConstructor = () => {
return UpgradableConstructor;
};

export function getUpgradableConstructor(tagName: string) {
export function getUpgradableConstructor(tagName: string, isFormAssociated: boolean) {
let UpgradableConstructor = cachedConstructors.get(tagName);

if (isUndefined(UpgradableConstructor)) {
Expand All @@ -82,7 +81,7 @@ export function getUpgradableConstructor(tagName: string) {
`Unexpected tag name "${tagName}". This name is a registered custom element, preventing LWC to upgrade the element.`
);
}
UpgradableConstructor = createUpgradableConstructor();
UpgradableConstructor = createUpgradableConstructor(isFormAssociated);
customElements.define(tagName, UpgradableConstructor);
cachedConstructors.set(tagName, UpgradableConstructor);
}
Expand All @@ -92,12 +91,18 @@ export function getUpgradableConstructor(tagName: string) {
export const createCustomElement = (
tagName: string,
upgradeCallback: LifecycleCallback,
useNativeLifecycle: boolean
useNativeLifecycle: boolean,
isFormAssociated: boolean
) => {
const UpgradableConstructor = getUpgradableConstructor(tagName);
const UpgradableConstructor = getUpgradableConstructor(tagName, isFormAssociated);

elementBeingUpgradedByLWC = true;
try {
if (UpgradableConstructor.formAssociated !== isFormAssociated) {
throw new Error(
`<${tagName}> was already registered with formAssociated=${UpgradableConstructor.formAssociated}. It cannot be re-registered with formAssociated=${isFormAssociated}. Please rename your component to have a different name than <${tagName}>`
);
}
return new UpgradableConstructor(upgradeCallback, useNativeLifecycle);
} finally {
elementBeingUpgradedByLWC = false;
Expand Down
6 changes: 3 additions & 3 deletions packages/@lwc/engine-server/src/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,8 +353,7 @@ function createUpgradableElementConstructor(tagName: string): CreateElementAndUp

function getUpgradableElement(
tagName: string,
_connectedCallback?: LifecycleCallback,
_disconnectedCallback?: LifecycleCallback
_isFormAssociated?: boolean
): CreateElementAndUpgrade {
let ctor = localRegistryRecord.get(tagName);
if (!isUndefined(ctor)) {
Expand All @@ -370,7 +369,8 @@ function getUpgradableElement(
function createCustomElement(
tagName: string,
upgradeCallback: LifecycleCallback,
_useNativeLifecycle: boolean
_useNativeLifecycle: boolean,
_isFormAssociated: boolean
): HostElement {
const UpgradableConstructor = getUpgradableElement(tagName);
return new (UpgradableConstructor as any)(upgradeCallback);
Expand Down
Loading

0 comments on commit adf9bf6

Please sign in to comment.