191 lines
8.2 KiB
JavaScript
191 lines
8.2 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2018 Google LLC
|
|
* SPDX-License-Identifier: BSD-3-Clause
|
|
*/
|
|
const NODE_MODE = false;
|
|
const DEV_MODE = true;
|
|
const reservedReactProperties = new Set([
|
|
'children',
|
|
'localName',
|
|
'ref',
|
|
'style',
|
|
'className',
|
|
]);
|
|
const listenedEvents = new WeakMap();
|
|
/**
|
|
* Adds an event listener for the specified event to the given node. In the
|
|
* React setup, there should only ever be one event listener. Thus, for
|
|
* efficiency only one listener is added and the handler for that listener is
|
|
* updated to point to the given listener function.
|
|
*/
|
|
const addOrUpdateEventListener = (node, event, listener) => {
|
|
let events = listenedEvents.get(node);
|
|
if (events === undefined) {
|
|
listenedEvents.set(node, (events = new Map()));
|
|
}
|
|
let handler = events.get(event);
|
|
if (listener !== undefined) {
|
|
// If necessary, add listener and track handler
|
|
if (handler === undefined) {
|
|
events.set(event, (handler = { handleEvent: listener }));
|
|
node.addEventListener(event, handler);
|
|
// Otherwise just update the listener with new value
|
|
}
|
|
else {
|
|
handler.handleEvent = listener;
|
|
}
|
|
// Remove listener if one exists and value is undefined
|
|
}
|
|
else if (handler !== undefined) {
|
|
events.delete(event);
|
|
node.removeEventListener(event, handler);
|
|
}
|
|
};
|
|
/**
|
|
* Sets properties and events on custom elements. These properties and events
|
|
* have been pre-filtered so we know they should apply to the custom element.
|
|
*/
|
|
const setProperty = (node, name, value, old, events) => {
|
|
const event = events?.[name];
|
|
// Dirty check event value.
|
|
if (event !== undefined) {
|
|
if (value !== old) {
|
|
addOrUpdateEventListener(node, event, value);
|
|
}
|
|
return;
|
|
}
|
|
// But don't dirty check properties; elements are assumed to do this.
|
|
node[name] = value;
|
|
// This block is to replicate React's behavior for attributes of native
|
|
// elements where `undefined` or `null` values result in attributes being
|
|
// removed.
|
|
// https://github.com/facebook/react/blob/899cb95f52cc83ab5ca1eb1e268c909d3f0961e7/packages/react-dom-bindings/src/client/DOMPropertyOperations.js#L107-L141
|
|
//
|
|
// It's only needed here for native HTMLElement properties that reflect
|
|
// attributes of the same name but don't have that behavior like "id" or
|
|
// "draggable".
|
|
if ((value === undefined || value === null) &&
|
|
name in HTMLElement.prototype) {
|
|
node.removeAttribute(name);
|
|
}
|
|
};
|
|
/**
|
|
* Creates a React component for a custom element. Properties are distinguished
|
|
* from attributes automatically, and events can be configured so they are added
|
|
* to the custom element as event listeners.
|
|
*
|
|
* @param options An options bag containing the parameters needed to generate a
|
|
* wrapped web component.
|
|
*
|
|
* @param options.react The React module, typically imported from the `react`
|
|
* npm package.
|
|
* @param options.tagName The custom element tag name registered via
|
|
* `customElements.define`.
|
|
* @param options.elementClass The custom element class registered via
|
|
* `customElements.define`.
|
|
* @param options.events An object listing events to which the component can
|
|
* listen. The object keys are the event property names passed in via React
|
|
* props and the object values are the names of the corresponding events
|
|
* generated by the custom element. For example, given `{onactivate:
|
|
* 'activate'}` an event function may be passed via the component's `onactivate`
|
|
* prop and will be called when the custom element fires its `activate` event.
|
|
* @param options.displayName A React component display name, used in debugging
|
|
* messages. Default value is inferred from the name of custom element class
|
|
* registered via `customElements.define`.
|
|
*/
|
|
export const createComponent = ({ react: React, tagName, elementClass, events, displayName, }) => {
|
|
const eventProps = new Set(Object.keys(events ?? {}));
|
|
if (DEV_MODE && !NODE_MODE) {
|
|
for (const p of reservedReactProperties) {
|
|
if (p in elementClass.prototype && !(p in HTMLElement.prototype)) {
|
|
// Note, this effectively warns only for `ref` since the other
|
|
// reserved props are on HTMLElement.prototype. To address this
|
|
// would require crawling down the prototype, which doesn't feel worth
|
|
// it since implementing these properties on an element is extremely
|
|
// rare.
|
|
console.warn(`${tagName} contains property ${p} which is a React reserved ` +
|
|
`property. It will be used by React and not set on the element.`);
|
|
}
|
|
}
|
|
}
|
|
const ReactComponent = React.forwardRef((props, ref) => {
|
|
const prevElemPropsRef = React.useRef(new Map());
|
|
const elementRef = React.useRef(null);
|
|
// Props to be passed to React.createElement
|
|
const reactProps = {};
|
|
// Props to be set on element with setProperty
|
|
const elementProps = {};
|
|
for (const [k, v] of Object.entries(props)) {
|
|
if (reservedReactProperties.has(k)) {
|
|
// React does *not* handle `className` for custom elements so
|
|
// coerce it to `class` so it's handled correctly.
|
|
reactProps[k === 'className' ? 'class' : k] = v;
|
|
continue;
|
|
}
|
|
if (eventProps.has(k) || k in elementClass.prototype) {
|
|
elementProps[k] = v;
|
|
continue;
|
|
}
|
|
reactProps[k] = v;
|
|
}
|
|
// useLayoutEffect produces warnings during server rendering.
|
|
if (!NODE_MODE) {
|
|
// This one has no dependency array so it'll run on every re-render.
|
|
React.useLayoutEffect(() => {
|
|
if (elementRef.current === null) {
|
|
return;
|
|
}
|
|
const newElemProps = new Map();
|
|
for (const key in elementProps) {
|
|
setProperty(elementRef.current, key, props[key], prevElemPropsRef.current.get(key), events);
|
|
prevElemPropsRef.current.delete(key);
|
|
newElemProps.set(key, props[key]);
|
|
}
|
|
// "Unset" any props from previous render that no longer exist.
|
|
// Setting to `undefined` seems like the correct thing to "unset"
|
|
// but currently React will set it as `null`.
|
|
// See https://github.com/facebook/react/issues/28203
|
|
for (const [key, value] of prevElemPropsRef.current) {
|
|
setProperty(elementRef.current, key, undefined, value, events);
|
|
}
|
|
prevElemPropsRef.current = newElemProps;
|
|
});
|
|
// Empty dependency array so this will only run once after first render.
|
|
React.useLayoutEffect(() => {
|
|
elementRef.current?.removeAttribute('defer-hydration');
|
|
}, []);
|
|
}
|
|
if (NODE_MODE) {
|
|
// If component is to be server rendered with `@lit/ssr-react`, pass
|
|
// element properties in a special bag to be set by the server-side
|
|
// element renderer.
|
|
if ((React.createElement.name === 'litPatchedCreateElement' ||
|
|
globalThis.litSsrReactEnabled) &&
|
|
Object.keys(elementProps).length) {
|
|
// This property needs to remain unminified.
|
|
reactProps['_$litProps$'] = elementProps;
|
|
}
|
|
}
|
|
else {
|
|
// Suppress hydration warning for server-rendered attributes.
|
|
// This property needs to remain unminified.
|
|
reactProps['suppressHydrationWarning'] = true;
|
|
}
|
|
return React.createElement(tagName, {
|
|
...reactProps,
|
|
ref: React.useCallback((node) => {
|
|
elementRef.current = node;
|
|
if (typeof ref === 'function') {
|
|
ref(node);
|
|
}
|
|
else if (ref !== null) {
|
|
ref.current = node;
|
|
}
|
|
}, [ref]),
|
|
});
|
|
});
|
|
ReactComponent.displayName = displayName ?? elementClass.name;
|
|
return ReactComponent;
|
|
};
|
|
//# sourceMappingURL=create-component.js.map
|