Skip to content

Commit

Permalink
[labs/react] introduce a options object (#2988)
Browse files Browse the repository at this point in the history
* create params object

* add changeset

* update readme

* eeek, this requires generics

* found correct return type

* more refined type

* add ref typing

* adjust ref typings

* type forwarded instance

* expose types at top of file

* organize types

* no react window module

* no react window module

* more merge main

* initial params bag

* add changeset, remove commented code

* ReactOrParams

* destructure params

* update tests, react is optional

* remove optional react

* remove default react

* change is a patch

* forgot options.react
  • Loading branch information
taylor-vann committed Oct 12, 2022
1 parent 9e978a0 commit 2d10c26
Show file tree
Hide file tree
Showing 4 changed files with 130 additions and 44 deletions.
5 changes: 5 additions & 0 deletions .changeset/cyan-needles-sparkle.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@lit-labs/react': minor
---

Provide a params object to createComponent to improve developer experience and make it easier to maintain and add future features.
76 changes: 69 additions & 7 deletions packages/labs/react/src/create-component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ export type ReactWebComponent<
React.PropsWithoutRef<WebComponentProps<I, E>> & React.RefAttributes<I>
>;

interface Options<I extends HTMLElement, E extends EventNames = {}> {
tagName: string;
elementClass: Constructor<I>;
react: typeof window.React;
events?: E;
displayName?: string;
}

type Constructor<T> = {new (): T};

const reservedReactProperties = new Set([
Expand Down Expand Up @@ -136,6 +144,36 @@ const setRef = (ref: React.Ref<unknown>, value: Element | null) => {
};

/**
* 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 function createComponent<
I extends HTMLElement,
E extends EventNames = {}
>(options: Options<I, E>): ReactWebComponent<I, E>;
/**
* @deprecated Use `createComponent(options)` instead of individual arguments.
*
* 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.
Expand All @@ -156,16 +194,40 @@ const setRef = (ref: React.Ref<unknown>, value: Element | null) => {
* messages. Default value is inferred from the name of custom element class
* registered via `customElements.define`.
*/
export const createComponent = <
export function createComponent<
I extends HTMLElement,
E extends EventNames = {}
>(
React: typeof window.React,
ReactOrOptions: typeof window.React,
tagName: string,
elementClass: Constructor<I>,
events?: E,
displayName?: string
): ReactWebComponent<I, E> => {
): ReactWebComponent<I, E>;
export function createComponent<
I extends HTMLElement,
E extends EventNames = {}
>(
ReactOrOptions: typeof window.React | Options<I, E> = window.React,
tagName?: string,
elementClass?: Constructor<I>,
events?: E,
displayName?: string
): ReactWebComponent<I, E> {
// digest overloaded parameters
let React: typeof window.React;
let tag: string;
let element: Constructor<I>;
if (tagName === undefined) {
const options = ReactOrOptions as Options<I, E>;
({tagName: tag, elementClass: element, events, displayName} = options);
React = options.react;
} else {
React = ReactOrOptions as typeof window.React;
element = elementClass as Constructor<I>;
tag = tagName;
}

const Component = React.Component;
const createElement = React.createElement;
const eventProps = new Set(Object.keys(events ?? {}));
Expand All @@ -178,7 +240,7 @@ export const createComponent = <
private _userRef?: React.Ref<I>;
private _ref?: React.RefCallback<I>;

static displayName = displayName ?? elementClass.name;
static displayName = displayName ?? element.name;

private _updateElement(oldProps?: Props) {
if (this._element === null) {
Expand Down Expand Up @@ -254,7 +316,7 @@ export const createComponent = <
eventProps.has(k) ||
(!reservedReactProperties.has(k) &&
!(k in HTMLElement.prototype) &&
k in elementClass.prototype)
k in element.prototype)
) {
this._elementProps[k] = v;
} else {
Expand All @@ -263,7 +325,7 @@ export const createComponent = <
props[k === 'className' ? 'class' : k] = v;
}
}
return createElement<React.HTMLAttributes<I>, I>(tagName, props);
return createElement<React.HTMLAttributes<I>, I>(tag, props);
}
}

Expand All @@ -282,4 +344,4 @@ export const createComponent = <
ForwardedComponent.displayName = ReactComponent.displayName;

return ForwardedComponent;
};
}
86 changes: 54 additions & 32 deletions packages/labs/react/src/test/create-component_test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@
* SPDX-License-Identifier: BSD-3-Clause
*/

import type * as ReactModule from 'react';
import type {EventName, ReactWebComponent} from '@lit-labs/react';

import {ReactiveElement} from '@lit/reactive-element';
import {property} from '@lit/reactive-element/decorators/property.js';
import {customElement} from '@lit/reactive-element/decorators/custom-element.js';
import type * as ReactModule from 'react';
import 'react/umd/react.development.js';
import 'react-dom/umd/react-dom.development.js';
import {createComponent} from '@lit-labs/react';
Expand All @@ -22,8 +22,8 @@ interface Foo {
foo?: boolean;
}

const elementName = 'basic-element';
@customElement(elementName)
const tagName = 'basic-element';
@customElement(tagName)
class BasicElement extends ReactiveElement {
@property({type: Boolean})
bool = false;
Expand Down Expand Up @@ -65,7 +65,7 @@ class BasicElement extends ReactiveElement {

declare global {
interface HTMLElementTagNameMap {
[elementName]: BasicElement;
[tagName]: BasicElement;
}
}

Expand All @@ -88,12 +88,14 @@ suite('createComponent', () => {
onBar: 'bar',
};

const BasicElementComponent = createComponent(
window.React,
elementName,
BasicElement,
basicElementEvents
);
// if some tag, run options
// otherwise
const BasicElementComponent = createComponent({
react: window.React,
elementClass: BasicElement,
events: basicElementEvents,
tagName,
});

let el: BasicElement;

Expand All @@ -104,28 +106,48 @@ suite('createComponent', () => {
<BasicElementComponent {...props}/>,
container
);
el = container.querySelector(elementName)! as BasicElement;
el = container.querySelector(tagName)! as BasicElement;
await el.updateComplete;
};


test('deprecated createComponent without options creates a component', async () => {
const ComponentWithoutEventMap = createComponent(
window.React,
tagName,
BasicElement,
);

const name = 'Component made with deprecated params.';
window.ReactDOM.render(
<ComponentWithoutEventMap>{name}</ComponentWithoutEventMap>,
container
);

el = container.querySelector(tagName)! as BasicElement;
await el.updateComplete;

assert.equal(el.textContent, name);
});

/*
The following test will not build if an incorrect typing occurs
when events are not provided to `createComponent`.
*/
test('renders element without optional event map', async () => {
const ComponentWithoutEventMap = createComponent(
window.React,
elementName,
BasicElement,
);
const ComponentWithoutEventMap = createComponent({
react: window.React,
elementClass: BasicElement,
tagName,
});

const name = 'Component without event map.';
window.ReactDOM.render(
<ComponentWithoutEventMap>{name}</ComponentWithoutEventMap>,
container
);

el = container.querySelector(elementName)! as BasicElement;
el = container.querySelector(tagName)! as BasicElement;
await el.updateComplete;

assert.equal(el.textContent, 'Component without event map.');
Expand Down Expand Up @@ -153,21 +175,21 @@ suite('createComponent', () => {
<BasicElementComponent>Hello {name}</BasicElementComponent>,
container
);
el = container.querySelector(elementName)! as BasicElement;
el = container.querySelector(tagName)! as BasicElement;
await el.updateComplete;
assert.equal(el.textContent, 'Hello World');
});

test('has valid displayName', () => {
assert.equal(BasicElementComponent.displayName, 'BasicElement');

const NamedComponent = createComponent(
window.React,
elementName,
BasicElement,
basicElementEvents,
'FooBar'
);
const NamedComponent = createComponent({
react: window.React,
elementClass: BasicElement,
events: basicElementEvents,
displayName: 'FooBar',
tagName,
});

assert.equal(NamedComponent.displayName, 'FooBar');
});
Expand All @@ -193,13 +215,13 @@ suite('createComponent', () => {

test('ref does not create new attribute on element', async () => {
await renderReactComponent({ref: undefined});
const el = container.querySelector(elementName);
const el = container.querySelector(tagName);
const outerHTML = el?.outerHTML;

const elementRef1 = window.React.createRef<BasicElement>();
await renderReactComponent({ref: elementRef1});

const elAfterRef = container.querySelector(elementName);
const elAfterRef = container.querySelector(tagName);
const outerHTMLAfterRef = elAfterRef?.outerHTML;

assert.equal(outerHTML, outerHTMLAfterRef);
Expand All @@ -211,13 +233,13 @@ suite('createComponent', () => {
const ref2Calls: Array<string | undefined> = [];
const refCb2 = (e: Element | null) => ref2Calls.push(e?.localName);
renderReactComponent({ref: refCb1});
assert.deepEqual(ref1Calls, [elementName]);
assert.deepEqual(ref1Calls, [tagName]);
renderReactComponent({ref: refCb2});
assert.deepEqual(ref1Calls, [elementName, undefined]);
assert.deepEqual(ref2Calls, [elementName]);
assert.deepEqual(ref1Calls, [tagName, undefined]);
assert.deepEqual(ref2Calls, [tagName]);
renderReactComponent({ref: refCb1});
assert.deepEqual(ref1Calls, [elementName, undefined, elementName]);
assert.deepEqual(ref2Calls, [elementName, undefined]);
assert.deepEqual(ref1Calls, [tagName, undefined, tagName]);
assert.deepEqual(ref2Calls, [tagName, undefined]);
});

test('can set attributes', async () => {
Expand Down
7 changes: 2 additions & 5 deletions packages/labs/ssr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -98,10 +98,8 @@ import './app-components.js';

const ssrResult = render(html`
<html>
<head>
</head>
<head> </head>
<body>
<app-shell>
<app-page-one></app-page-one>
<app-page-two></app-page-two>
Expand All @@ -112,7 +110,7 @@ const ssrResult = render(html`
// native declarative shadow roots)
import {
hasNativeDeclarativeShadowRoots,
hydrateShadowRoots
hydrateShadowRoots,
} from './node_modules/@webcomponents/template-shadowroot/template-shadowroot.js';
if (!hasNativeDeclarativeShadowRoots()) {
hydrateShadowRoots(document.body);
Expand All @@ -121,7 +119,6 @@ const ssrResult = render(html`
// Load and hydrate components lazily
import('./app-components.js');
</script>
</body>
</html>
`);
Expand Down

0 comments on commit 2d10c26

Please sign in to comment.