-
Notifications
You must be signed in to change notification settings - Fork 879
/
create-component.ts
335 lines (303 loc) · 10.4 KB
/
create-component.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
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
/**
* @license
* Copyright 2018 Google LLC
* SPDX-License-Identifier: BSD-3-Clause
*/
import type React from 'react';
const NODE_MODE = false;
const DEV_MODE = true;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type DistributiveOmit<T, K extends string | number | symbol> = T extends any
? K extends keyof T
? Omit<T, K>
: T
: T;
type PropsWithoutRef<T> = DistributiveOmit<T, 'ref'>;
/**
* Creates a type to be used for the props of a web component used directly in
* React JSX.
*
* Example:
*
* ```ts
* declare module "react" {
* namespace JSX {
* interface IntrinsicElements {
* 'x-foo': WebComponentProps<XFoo>;
* }
* }
* }
* ```
*/
export type WebComponentProps<I extends HTMLElement> = React.DetailedHTMLProps<
React.HTMLAttributes<I>,
I
> &
ElementProps<I>;
/**
* Type of the React component wrapping the web component. This is the return
* type of `createComponent`.
*/
export type ReactWebComponent<
I extends HTMLElement,
E extends EventNames = {}
> = React.ForwardRefExoticComponent<
// TODO(augustjk): Remove and use `React.PropsWithoutRef` when
// https://github.com/preactjs/preact/issues/4124 is fixed.
PropsWithoutRef<ComponentProps<I, E>> & React.RefAttributes<I>
>;
// Props derived from custom element class. Currently has limitations of making
// all properties optional and also surfaces life cycle methods in autocomplete.
// TODO(augustjk) Consider omitting keyof LitElement to remove "internal"
// lifecycle methods or allow user to explicitly provide props.
type ElementProps<I> = Partial<Omit<I, keyof HTMLElement>>;
// Acceptable props to the React component.
type ComponentProps<I, E extends EventNames = {}> = Omit<
React.HTMLAttributes<I>,
// Prefer type of provided event handler props or those on element over
// built-in HTMLAttributes
keyof E | keyof ElementProps<I>
> &
EventListeners<E> &
ElementProps<I>;
/**
* Type used to cast an event name with an event type when providing the
* `events` option to `createComponent` for better typing of the event handler
* prop.
*
* Example:
*
* ```ts
* const FooComponent = createComponent({
* ...
* events: {
* onfoo: 'foo' as EventName<FooEvent>,
* }
* });
* ```
*
* `onfoo` prop will have the type `(e: FooEvent) => void`.
*/
export type EventName<T extends Event = Event> = string & {
__eventType: T;
};
// A key value map matching React prop names to event names.
type EventNames = Record<string, EventName | string>;
// A map of expected event listener types based on EventNames.
type EventListeners<R extends EventNames> = {
[K in keyof R]?: R[K] extends EventName
? (e: R[K]['__eventType']) => void
: (e: Event) => void;
};
export interface Options<I extends HTMLElement, E extends EventNames = {}> {
react: typeof React;
tagName: string;
elementClass: Constructor<I>;
events?: E;
displayName?: string;
}
type Constructor<T> = {new (): T};
const reservedReactProperties = new Set([
'children',
'localName',
'ref',
'style',
'className',
]);
const listenedEvents = new WeakMap<Element, Map<string, EventListenerObject>>();
/**
* 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: Element,
event: string,
listener: (event?: Event) => void
) => {
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 = <E extends Element>(
node: E,
name: string,
value: unknown,
old: unknown,
events?: EventNames
) => {
const event = events?.[name];
// Dirty check event value.
if (event !== undefined && value !== old) {
addOrUpdateEventListener(node, event, value as (e?: Event) => void);
return;
}
// But don't dirty check properties; elements are assumed to do this.
node[name as keyof E] = value as E[keyof E];
// 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 = <
I extends HTMLElement,
E extends EventNames = {}
>({
react: React,
tagName,
elementClass,
events,
displayName,
}: Options<I, E>): ReactWebComponent<I, E> => {
const eventProps = new Set(Object.keys(events ?? {}));
if (DEV_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.`
);
}
}
}
type Props = ComponentProps<I, E>;
const ReactComponent = React.forwardRef<I, Props>((props, ref) => {
const prevPropsRef = React.useRef<Props | null>(null);
const elementRef = React.useRef<I | null>(null);
// Props to be passed to React.createElement
const reactProps: Record<string, unknown> = {};
// Props to be set on element with setProperty
const elementProps: Record<string, unknown> = {};
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;
}
for (const prop in elementProps) {
setProperty(
elementRef.current,
prop,
props[prop],
prevPropsRef.current ? prevPropsRef.current[prop] : undefined,
events
);
}
// Note, the spirit of React might be to "unset" any old values that
// are no longer included; however, there's no reasonable value to set
// them to so we just leave the previous state as is.
prevPropsRef.current = props;
});
// 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' &&
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: I) => {
elementRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref !== null) {
ref.current = node;
}
},
[ref]
),
});
});
ReactComponent.displayName = displayName ?? elementClass.name;
return ReactComponent;
};