Skip to content

Commit 208d09b

Browse files
authored
revisit API to add React and React-Dom as peer dependencies instead of being passed to the reactToWebcompnent function (#90)
* initial commit * added react17 and 18 as peer dependencies * only react18 as peer dependency * update prettier and eslint scripts * react as a peer dependency and test script improvements * temporarily remove depcheck * fix vite entry point for build * started unit tests for core and root * build options changes * update github workflow * regenerate package-lock.json * misc test update * improved build and testing * clean ups and legacy export
1 parent 1755739 commit 208d09b

23 files changed

+4082
-15068
lines changed

.github/workflows/verify-and-publish.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,12 +35,12 @@ jobs:
3535
- name: depcheck
3636
run: npm run depcheck
3737

38-
- name: Test
39-
run: npm run buildtests && npm run test
40-
4138
- name: Build
4239
run: npm run build
4340

41+
- name: Test
42+
run: npm run buildtests && npm run test
43+
4444
publish:
4545
if: github.event_name == 'workflow_dispatch'
4646
needs: verify

package-lock.json

Lines changed: 3403 additions & 1990 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 33 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,23 +2,39 @@
22
"name": "react-to-webcomponent",
33
"version": "2.0.0-alpha.3",
44
"description": "Convert react components to native Web Components.",
5-
"main": "./dist/react-to-webcomponent.es.js",
6-
"module": "./dist/react-to-webcomponent.es.js",
7-
"types": "./dist/react-to-webcomponent.d.ts",
5+
"type": "module",
86
"exports": {
97
".": {
10-
"import": "./dist/react-to-webcomponent.es.js",
11-
"require": "./dist/react-to-webcomponent.umd.js"
8+
"import": "./dist/root.js",
9+
"require": "./dist/root.cjs",
10+
"types": "./dist/root/root.d.ts"
11+
},
12+
"./root": {
13+
"import": "./dist/root.js",
14+
"require": "./dist/root.cjs",
15+
"types": "./dist/root/root.d.ts"
16+
},
17+
"./render": {
18+
"import": "./dist/render.js",
19+
"require": "./dist/render.cjs",
20+
"types": "./dist/render/render.d.ts"
21+
},
22+
"./legacy": {
23+
"import": "./dist/legacy.js",
24+
"require": "./dist/legacy.cjs",
25+
"types": "./dist/legacy/react-to-webcomponent.d.ts"
1226
}
1327
},
1428
"files": [
15-
"dist/",
29+
"dist/*",
1630
"src/",
1731
"!src/tests/"
1832
],
1933
"devDependencies": {
2034
"@bitovi/eslint-config": "^1.3.0",
2135
"@rollup/plugin-typescript": "^8.3.4",
36+
"@types/react": "^18.0.33",
37+
"@types/react-dom": "^18.0.11",
2238
"@webcomponents/custom-elements": "^1.2.4",
2339
"can-stache": "^4.17.20",
2440
"can-stache-bindings": "^4.10.9",
@@ -30,18 +46,20 @@
3046
"prettier": "^2.7.1",
3147
"prop-types": "^15.7.2",
3248
"typescript": "^4.3.5",
33-
"vite": "^3.0.3",
34-
"vitest": "^0.19.1"
49+
"vite": "^3.2.5",
50+
"vitest": "^0.29.8"
3551
},
3652
"scripts": {
3753
"build": "vite build",
3854
"ci": "npm run test",
3955
"buildtests": "chmod +x src/tests/buildtests && ./src/tests/buildtests",
40-
"test": "vitest src/tests/**/react-to-webcomponent.test.jsx",
56+
"test": "vitest src/tests/**/react-to-webcomponent.test.jsx && npm run test:core && npm run test:root",
57+
"test:core": "vitest src/core/core.test.tsx",
58+
"test:root": "vitest src/root/root.test.tsx",
4159
"start": "http-server -c-1",
4260
"typecheck": "tsc --noEmit",
43-
"eslint": "eslint ./src/*.ts",
44-
"prettier": "prettier --check ./src/*.ts",
61+
"eslint": "eslint ./src/**/*.ts",
62+
"prettier": "prettier --check ./src/**/*.ts",
4563
"depcheck": "depcheck ."
4664
},
4765
"repository": {
@@ -62,5 +80,9 @@
6280
"prettier": {
6381
"semi": false,
6482
"trailingComma": "all"
83+
},
84+
"peerDependencies": {
85+
"react": "^18.0.0 || ^17.0.0 || ^16.0.0",
86+
"react-dom": "^18.0.0 || ^17.0.0 || ^16.0.0"
6587
}
6688
}

src/core/core.test.tsx

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { test, expect, vi } from "vitest"
2+
import r2wc from "."
3+
4+
// mock renderer
5+
const mount = vi.fn()
6+
const unmount = vi.fn()
7+
8+
test("mount and unmount is called", async () => {
9+
function TestComponent() {
10+
11+
return <div>hello</div>
12+
}
13+
const body = document.body
14+
15+
const TestElement = r2wc(TestComponent, {}, { mount, unmount })
16+
customElements.define("test-element", TestElement)
17+
18+
const testEl = new TestElement();
19+
20+
body.appendChild(testEl)
21+
22+
expect(mount).toBeCalledTimes(1)
23+
24+
body.removeChild(testEl)
25+
26+
expect(unmount).toBeCalledTimes(1)
27+
28+
29+
})

src/core/core.ts

Lines changed: 259 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,259 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import React from "react"
3+
4+
const renderSymbol = Symbol.for("r2wc.reactRender")
5+
const shouldRenderSymbol = Symbol.for("r2wc.shouldRender")
6+
7+
function toDashedStyle(camelCase = "") {
8+
return camelCase.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase()
9+
}
10+
11+
function isAllCaps(word: string) {
12+
return word.split("").every((c: string) => c.toUpperCase() === c)
13+
}
14+
15+
function flattenIfOne(arr: object) {
16+
if (!Array.isArray(arr)) {
17+
return arr
18+
}
19+
if (arr.length === 1) {
20+
return arr[0]
21+
}
22+
return arr
23+
}
24+
25+
function mapChildren(node: Element) {
26+
if (node.nodeType === Node.TEXT_NODE) {
27+
return node.textContent?.toString()
28+
}
29+
30+
const arr = Array.from(node.childNodes as unknown as Element[]).map(
31+
(c: Element) => {
32+
if (c.nodeType === Node.TEXT_NODE) {
33+
return c.textContent?.toString()
34+
}
35+
// BR = br, ReactElement = ReactElement
36+
const nodeName = isAllCaps(c.nodeName)
37+
? c.nodeName.toLowerCase()
38+
: c.nodeName
39+
const children = flattenIfOne(mapChildren(c))
40+
41+
// we need to format c.attributes before passing it to createElement
42+
const attributes: Record<string, string | null> = {}
43+
for (const attr of c.getAttributeNames()) {
44+
attributes[attr] = c.getAttribute(attr)
45+
}
46+
47+
return React.createElement(nodeName, attributes, children)
48+
},
49+
)
50+
51+
return flattenIfOne(arr)
52+
}
53+
54+
const define = {
55+
// Creates a getter/setter that re-renders everytime a property is set.
56+
expando: function (
57+
receiver: Record<typeof renderSymbol, any>,
58+
key: string,
59+
value: unknown,
60+
) {
61+
Object.defineProperty(receiver, key, {
62+
enumerable: true,
63+
get: function () {
64+
return value
65+
},
66+
set: function (newValue) {
67+
value = newValue
68+
this[renderSymbol]()
69+
},
70+
})
71+
receiver[renderSymbol]()
72+
},
73+
}
74+
75+
/**
76+
* Converts a React component into a webcomponent by wrapping it in a Proxy object.
77+
* @param {ReactComponent}
78+
* @param {Object} config - Optional parameters
79+
* @param {String?} config.shadow - Shadow DOM mode as either open or closed.
80+
* @param {Object|Array?} config.props - Array of camelCasedProps to watch as Strings or { [camelCasedProp]: String | Number | Boolean | Function | Object | Array }
81+
*/
82+
// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types
83+
export default function (
84+
ReactComponent: FC<any> | ComponentClass<any>,
85+
config: R2WCOptions = {},
86+
renderer: Renderer<ReturnType<typeof React.createElement>>,
87+
): CustomElementConstructor {
88+
const propTypes: Record<string, any> = {} // { [camelCasedProp]: String | Number | Boolean | Function | Object | Array }
89+
const propAttrMap: Record<string, any> = {} // @TODO: add option to specify for asymetric mapping (eg "className" from "class")
90+
const attrPropMap: Record<string, any> = {} // cached inverse of propAttrMap
91+
92+
if (!config.props) {
93+
config.props = ReactComponent.propTypes
94+
? Object.keys(ReactComponent.propTypes)
95+
: []
96+
}
97+
98+
const propKeys = Array.isArray(config.props)
99+
? config.props.slice()
100+
: Object.keys(config.props)
101+
102+
propKeys.forEach((key) => {
103+
propTypes[key] = Array.isArray(config.props) ? String : config.props?.[key]
104+
propAttrMap[key] = toDashedStyle(key)
105+
attrPropMap[propAttrMap[key]] = key
106+
})
107+
const renderAddedProperties: Record<string, boolean> = {
108+
isConnected: "isConnected" in HTMLElement.prototype,
109+
}
110+
let rendering = false
111+
// Create the web component "class"
112+
const WebComponent = function (this: any, ...args: any[]) {
113+
const self: HTMLElement = Reflect.construct(
114+
HTMLElement,
115+
args,
116+
this.constructor,
117+
)
118+
if (typeof config.shadow === "string") {
119+
self.attachShadow({ mode: config.shadow } as ShadowRoot)
120+
} else if (config.shadow) {
121+
console.warn(
122+
'Specifying the "shadow" option as a boolean is deprecated and will be removed in a future version.',
123+
)
124+
self.attachShadow({ mode: "open" })
125+
}
126+
return self
127+
}
128+
129+
// Make the class extend HTMLElement
130+
const targetPrototype = Object.create(HTMLElement.prototype)
131+
targetPrototype.constructor = WebComponent
132+
133+
// But have that prototype be wrapped in a proxy.
134+
const proxyPrototype = new Proxy(targetPrototype, {
135+
has: function (target, key) {
136+
return key in propTypes || key in targetPrototype
137+
},
138+
139+
// when any undefined property is set, create a getter/setter that re-renders
140+
set: function (target, key, value, receiver) {
141+
if (rendering) {
142+
renderAddedProperties[key as string] = true
143+
}
144+
145+
if (
146+
typeof key === "symbol" ||
147+
renderAddedProperties[key] ||
148+
key in target
149+
) {
150+
// If the property is defined in the component props, just set it.
151+
if (
152+
ReactComponent.propTypes &&
153+
key in ReactComponent.propTypes &&
154+
typeof key === "string"
155+
) {
156+
define.expando(receiver, key, value)
157+
}
158+
// Set it on the HTML element as well.
159+
return Reflect.set(target, key, value, receiver)
160+
} else {
161+
define.expando(receiver, key, value)
162+
}
163+
return true
164+
},
165+
// makes sure the property looks writable
166+
getOwnPropertyDescriptor: function (target, key) {
167+
const own = Reflect.getOwnPropertyDescriptor(target, key)
168+
if (own) {
169+
return own
170+
}
171+
if (key in propTypes) {
172+
return {
173+
configurable: true,
174+
enumerable: true,
175+
writable: true,
176+
value: undefined,
177+
}
178+
}
179+
},
180+
})
181+
WebComponent.prototype = proxyPrototype
182+
183+
// Setup lifecycle methods
184+
targetPrototype.connectedCallback = function () {
185+
// Once connected, it will keep updating the innerHTML.
186+
// We could add a render method to allow this as well.
187+
this[shouldRenderSymbol] = true
188+
this[renderSymbol]()
189+
}
190+
targetPrototype.disconnectedCallback = function () {
191+
renderer.unmount(this)
192+
}
193+
targetPrototype[renderSymbol] = function () {
194+
if (this[shouldRenderSymbol] === true) {
195+
const data: Record<string, any> = {}
196+
Object.keys(this).forEach(function (this: any, key) {
197+
if (renderAddedProperties[key] !== false) {
198+
data[key] = this[key]
199+
}
200+
}, this)
201+
rendering = true
202+
// Container is either shadow DOM or light DOM depending on `shadow` option.
203+
const container = config.shadow ? this.shadowRoot : this
204+
205+
const children = flattenIfOne(mapChildren(this))
206+
207+
const element = React.createElement(ReactComponent, data, children)
208+
209+
// Use react to render element in container
210+
renderer.mount(container, element)
211+
rendering = false
212+
}
213+
}
214+
215+
// Handle attributes changing
216+
WebComponent.observedAttributes = Object.keys(attrPropMap)
217+
218+
targetPrototype.attributeChangedCallback = function (
219+
name: string,
220+
oldValue: any,
221+
newValue: any,
222+
) {
223+
const propertyName = attrPropMap[name] || name
224+
switch (propTypes[propertyName]) {
225+
case "ref":
226+
case Function:
227+
if (!newValue && propTypes[propertyName] === "ref") {
228+
newValue = React.createRef()
229+
break
230+
}
231+
if (typeof window !== "undefined") {
232+
newValue = window[newValue] || newValue
233+
} else if (typeof global !== "undefined") {
234+
newValue = global[newValue] || newValue
235+
}
236+
if (typeof newValue === "function") {
237+
newValue = newValue.bind(this) // this = instance of the WebComponent / HTMLElement
238+
}
239+
break
240+
case Number:
241+
newValue = parseFloat(newValue)
242+
break
243+
case Boolean:
244+
newValue = /^[ty1-9]/i.test(newValue)
245+
break
246+
case Object:
247+
case Array:
248+
newValue = JSON.parse(newValue)
249+
break
250+
case String:
251+
default:
252+
break
253+
}
254+
255+
this[propertyName] = newValue
256+
}
257+
258+
return WebComponent as unknown as CustomElementConstructor
259+
}

src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as default } from "./core"

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as default } from "./root"
2+
export { default as render } from "./render"

src/legacy/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { default as default } from "./react-to-webcomponent"

0 commit comments

Comments
 (0)