-
Notifications
You must be signed in to change notification settings - Fork 382
/
base-bridge-element.ts
283 lines (264 loc) · 11 KB
/
base-bridge-element.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
/*
* Copyright (c) 2024, Salesforce, Inc.
* All rights reserved.
* SPDX-License-Identifier: MIT
* For full license text, see the LICENSE file in the repo root or https://opensource.org/licenses/MIT
*/
/**
* This module is responsible for creating the base bridge class BaseBridgeElement
* that represents the HTMLElement extension used for any LWC inserted in the DOM.
*/
import {
ArraySlice,
ArrayIndexOf,
create,
defineProperties,
defineProperty,
freeze,
getOwnPropertyNames,
getOwnPropertyDescriptors,
isUndefined,
seal,
keys,
htmlPropertyToAttribute,
isNull,
} from '@lwc/shared';
import { ariaReflectionPolyfillDescriptors } from '../libs/aria-reflection/aria-reflection';
import { logWarn } from '../shared/logger';
import { getAssociatedVM } from './vm';
import { getReadOnlyProxy } from './membrane';
import { HTMLElementConstructor, HTMLElementPrototype } from './html-element';
import { HTMLElementOriginalDescriptors } from './html-properties';
import { LightningElement } from './base-lightning-element';
// A bridge descriptor is a descriptor whose job is just to get the component instance
// from the element instance, and get the value or set a new value on the component.
// This means that across different elements, similar names can get the exact same
// descriptor, so we can cache them:
const cachedGetterByKey: Record<string, (this: HTMLElement) => any> = create(null);
const cachedSetterByKey: Record<string, (this: HTMLElement, newValue: any) => any> = create(null);
function createGetter(key: string) {
let fn = cachedGetterByKey[key];
if (isUndefined(fn)) {
fn = cachedGetterByKey[key] = function (this: HTMLElement): any {
const vm = getAssociatedVM(this);
const { getHook } = vm;
return getHook(vm.component, key);
};
}
return fn;
}
function createSetter(key: string) {
let fn = cachedSetterByKey[key];
if (isUndefined(fn)) {
fn = cachedSetterByKey[key] = function (this: HTMLElement, newValue: any): any {
const vm = getAssociatedVM(this);
const { setHook } = vm;
newValue = getReadOnlyProxy(newValue);
setHook(vm.component, key, newValue);
};
}
return fn;
}
function createMethodCaller(methodName: string): (...args: any[]) => any {
return function (this: HTMLElement): any {
const vm = getAssociatedVM(this);
const { callHook, component } = vm;
const fn = (component as any)[methodName];
return callHook(vm.component, fn, ArraySlice.call(arguments as unknown as unknown[]));
};
}
type AttributeChangedCallback = (
this: HTMLElement,
attrName: string,
oldValue: string,
newValue: string
) => void;
function createAttributeChangedCallback(
attributeToPropMap: Record<string, string>,
superAttributeChangedCallback?: AttributeChangedCallback
): AttributeChangedCallback {
return function attributeChangedCallback(
this: HTMLElement,
attrName: string,
oldValue: string,
newValue: string
) {
if (oldValue === newValue) {
// Ignore same values.
return;
}
const propName = attributeToPropMap[attrName];
if (isUndefined(propName)) {
if (!isUndefined(superAttributeChangedCallback)) {
// delegate unknown attributes to the super.
// Typescript does not like it when you treat the `arguments` object as an array
// @ts-expect-error type-mismatch
superAttributeChangedCallback.apply(this, arguments);
}
return;
}
// Reflect attribute change to the corresponding property when changed from outside.
(this as any)[propName] = newValue;
};
}
function createAccessorThatWarns(propName: string) {
let prop: any;
return {
get() {
logWarn(
`The property "${propName}" is not publicly accessible. Add the @api annotation to the property declaration or getter/setter in the component to make it accessible.`
);
return prop;
},
set(value: any) {
logWarn(
`The property "${propName}" is not publicly accessible. Add the @api annotation to the property declaration or getter/setter in the component to make it accessible.`
);
prop = value;
},
enumerable: true,
configurable: true,
};
}
export interface HTMLElementConstructor {
prototype: HTMLElement;
new (): HTMLElement;
}
export function HTMLBridgeElementFactory(
SuperClass: HTMLElementConstructor,
publicProperties: string[],
methods: string[],
observedFields: string[],
proto: LightningElement | null,
hasCustomSuperClass: boolean
): HTMLElementConstructor {
const HTMLBridgeElement = class extends SuperClass {};
// generating the hash table for attributes to avoid duplicate fields and facilitate validation
// and false positives in case of inheritance.
const attributeToPropMap: Record<string, string> = create(null);
const { attributeChangedCallback: superAttributeChangedCallback } = SuperClass.prototype as any;
const { observedAttributes: superObservedAttributes = [] } = SuperClass as any;
const descriptors: PropertyDescriptorMap = create(null);
// present a hint message so that developers are aware that they have not decorated property with @api
if (process.env.NODE_ENV !== 'production') {
// TODO [#3761]: enable for components that don't extend from LightningElement
if (!isUndefined(proto) && !isNull(proto) && !hasCustomSuperClass) {
const nonPublicPropertiesToWarnOn = new Set(
[
// getters, setters, and methods
...keys(getOwnPropertyDescriptors(proto)),
// class properties
...observedFields,
]
// we don't want to override HTMLElement props because these are meaningful in other ways,
// and can break tooling that expects it to be iterable or defined, e.g. Jest:
// https://github.com/jestjs/jest/blob/b4c9587/packages/pretty-format/src/plugins/DOMElement.ts#L95
// It also doesn't make sense to override e.g. "constructor".
.filter(
(propName) =>
!(propName in HTMLElementPrototype) &&
!(propName in ariaReflectionPolyfillDescriptors)
)
);
for (const propName of nonPublicPropertiesToWarnOn) {
if (ArrayIndexOf.call(publicProperties, propName) === -1) {
descriptors[propName] = createAccessorThatWarns(propName);
}
}
}
}
// expose getters and setters for each public props on the new Element Bridge
for (let i = 0, len = publicProperties.length; i < len; i += 1) {
const propName = publicProperties[i];
attributeToPropMap[htmlPropertyToAttribute(propName)] = propName;
descriptors[propName] = {
get: createGetter(propName),
set: createSetter(propName),
enumerable: true,
configurable: true,
};
}
// expose public methods as props on the new Element Bridge
for (let i = 0, len = methods.length; i < len; i += 1) {
const methodName = methods[i];
descriptors[methodName] = {
value: createMethodCaller(methodName),
writable: true,
configurable: true,
};
}
// creating a new attributeChangedCallback per bridge because they are bound to the corresponding
// map of attributes to props. We do this after all other props and methods to avoid the possibility
// of getting overrule by a class declaration in user-land, and we make it non-writable, non-configurable
// to preserve this definition.
descriptors.attributeChangedCallback = {
value: createAttributeChangedCallback(attributeToPropMap, superAttributeChangedCallback),
};
// To avoid leaking private component details, accessing internals from outside a component is not allowed.
descriptors.attachInternals = {
set() {
if (process.env.NODE_ENV !== 'production') {
logWarn(
'attachInternals cannot be accessed outside of a component. Use this.attachInternals instead.'
);
}
},
get() {
if (process.env.NODE_ENV !== 'production') {
logWarn(
'attachInternals cannot be accessed outside of a component. Use this.attachInternals instead.'
);
}
},
};
descriptors.formAssociated = {
set() {
if (process.env.NODE_ENV !== 'production') {
logWarn(
'formAssociated cannot be accessed outside of a component. Set the value within the component class.'
);
}
},
get() {
if (process.env.NODE_ENV !== 'production') {
logWarn(
'formAssociated cannot be accessed outside of a component. Set the value within the component class.'
);
}
},
};
// Specify attributes for which we want to reflect changes back to their corresponding
// properties via attributeChangedCallback.
defineProperty(HTMLBridgeElement, 'observedAttributes', {
get() {
return [...superObservedAttributes, ...keys(attributeToPropMap)];
},
});
defineProperties(HTMLBridgeElement.prototype, descriptors);
return HTMLBridgeElement as HTMLElementConstructor;
}
// We do some special handling of non-standard ARIA props like ariaLabelledBy as well as props without (as of this
// writing) broad cross-browser support like ariaBrailleLabel. This is so the reflection works correctly and preserves
// backwards compatibility with the previous global polyfill approach.
//
// The goal here is to expose `elm.aria*` property accessors to work from outside a component, and to reflect `aria-*`
// attrs. This is especially important because the template compiler compiles aria-* attrs on components to aria* props.
// Note this works regardless of whether the global ARIA reflection polyfill is applied or not.
//
// Also note this ARIA reflection only really makes sense in the browser. On the server, there is no
// `renderedCallback()`, so you cannot do e.g. `this.template.querySelector('x-child').ariaBusy = 'true'`. So we don't
// need to expose ARIA props outside the LightningElement
const basePublicProperties = [
...getOwnPropertyNames(HTMLElementOriginalDescriptors),
...(process.env.IS_BROWSER ? getOwnPropertyNames(ariaReflectionPolyfillDescriptors) : []),
];
export const BaseBridgeElement = HTMLBridgeElementFactory(
HTMLElementConstructor,
basePublicProperties,
[],
[],
null,
false
);
freeze(BaseBridgeElement);
seal(BaseBridgeElement.prototype);