From eac9783c92af8adbf23891564ca3ea768995d9e3 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Thu, 19 Feb 2026 17:59:13 -0500 Subject: [PATCH 1/5] Fix method transforms when shadow DOM is used --- packages/core/src/core.ts | 35 ++++++------------ packages/core/src/transforms/index.ts | 8 +++- packages/core/src/transforms/method.ts | 29 ++++++++++++--- .../src/react-to-web-component.test.tsx | 37 ++++++++++++------- 4 files changed, 65 insertions(+), 44 deletions(-) diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 36938ce..6111317 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -1,5 +1,5 @@ import transforms, { R2WCType } from "./transforms" -import { toDashedCase, toCamelCase } from "./utils" +import { toDashedCase } from "./utils" type PropName = Exclude, "container"> type PropNames = Array> @@ -106,26 +106,7 @@ export default function r2wc( const type = propTypes[prop] const transform = type ? transforms[type] : null - if (type === "method") { - const methodName = toCamelCase(attribute) - - Object.defineProperty(this[propsSymbol].container, methodName, { - enumerable: true, - configurable: true, - get() { - return this[propsSymbol][methodName] - }, - set(value) { - this[propsSymbol][methodName] = value - this[renderSymbol]() - }, - }) - - //@ts-ignore - this[propsSymbol][prop] = transform.parse(value, attribute, this) - } - - if (transform?.parse && value) { + if (transform?.parse && (value || type === "method")) { //@ts-ignore this[propsSymbol][prop] = transform.parse(value, attribute, this) } @@ -164,7 +145,7 @@ export default function r2wc( const type = propTypes[prop] const transform = type ? transforms[type] : null - if (prop in propTypes && transform?.parse && value) { + if (prop in propTypes && transform?.parse && (value || type === "method")) { //@ts-ignore this[propsSymbol][prop] = transform.parse(value, attribute, this) @@ -207,9 +188,17 @@ export default function r2wc( const oldAttributeValue = this.getAttribute(attribute) if (oldAttributeValue !== attributeValue) { - this.setAttribute(attribute, attributeValue) + if (attributeValue == null) { + this.removeAttribute(attribute) + } else { + this.setAttribute(attribute, attributeValue) + } } } else { + if (prop in propTypes && transform?.parse && (value || type === "method")) { + //@ts-ignore + this[propsSymbol][prop] = transform.parse(value, attribute, this) + } this[renderSymbol]() } }, diff --git a/packages/core/src/transforms/index.ts b/packages/core/src/transforms/index.ts index 3bb4e9c..b363eb1 100644 --- a/packages/core/src/transforms/index.ts +++ b/packages/core/src/transforms/index.ts @@ -5,9 +5,13 @@ import method_ from "./method" import number from "./number" import string from "./string" +type R2WCElement = HTMLElement & { + container: R2WCElement +} + export interface Transform { - stringify?: (value: Type, attribute: string, element: HTMLElement) => string - parse: (value: string, attribute: string, element: HTMLElement) => Type + stringify?: (value: Type, attribute: string, element: R2WCElement) => string + parse: (value: string, attribute: string, element: R2WCElement) => Type } const transforms = { diff --git a/packages/core/src/transforms/method.ts b/packages/core/src/transforms/method.ts index 326209a..5b4c491 100644 --- a/packages/core/src/transforms/method.ts +++ b/packages/core/src/transforms/method.ts @@ -2,16 +2,35 @@ import { toCamelCase } from "../utils" import { Transform } from "./index" +const boundSymbol = Symbol.for("r2wc.bound") + const method_: Transform<(...args: unknown[]) => unknown> = { - stringify: (value) => value.name, parse: (value, attribute, element) => { const fn = (() => { const functionName = toCamelCase(attribute) - //@ts-expect-error - if (typeof element !== "undefined" && functionName in element.container) { - // @ts-expect-error - return element.container[functionName] + const r2wcElement = element as typeof element & { + container: typeof r2wcElement + } & { + [k in typeof functionName]: Function + } + + if (typeof r2wcElement !== "undefined") { + if ( + typeof r2wcElement !== "undefined" + && functionName in r2wcElement && typeof r2wcElement[functionName] !== "undefined" + ) { + let fn = r2wcElement[functionName] + if (!(boundSymbol in r2wcElement[functionName])) { + fn = fn.bind(r2wcElement) + Object.defineProperty(fn, boundSymbol, { value: true }) + } + return fn + } else { + return null + } + } else { + return null } })() diff --git a/packages/react-to-web-component/src/react-to-web-component.test.tsx b/packages/react-to-web-component/src/react-to-web-component.test.tsx index f8e5248..36b9dfe 100644 --- a/packages/react-to-web-component/src/react-to-web-component.test.tsx +++ b/packages/react-to-web-component/src/react-to-web-component.test.tsx @@ -364,7 +364,11 @@ describe("react-to-web-component 1", () => { }) }) - it("Supports class function to react props using method transform", async () => { + it.each( + [[undefined], ["open"], ["closed"]] + )( + `Supports class function to react props using method transform: (shadow: %s)`, + async (shadow) => { const ClassGreeting: React.FC<{ name: string; sayHello: () => void }> = ({ name, sayHello, @@ -380,14 +384,17 @@ describe("react-to-web-component 1", () => { name: "string", sayHello: "method", }, + shadow: shadow as unknown as Exclude[1], undefined>['shadow'], }) - customElements.define("class-greeting", WebClassGreeting) + const tagName = `class-greeting${shadow ? `-${shadow}`: ''}` - document.body.innerHTML = `` + customElements.define(tagName, WebClassGreeting) + + document.body.innerHTML = `<${tagName} name='Christopher'>` const el = document.querySelector void }>( - "class-greeting", + tagName, ) if (!el) { @@ -395,13 +402,15 @@ describe("react-to-web-component 1", () => { } const sayHello = function (this: HTMLElement) { - const nameElement = this.querySelector("h1") + const nameElement = this.container.querySelector("h1") if (nameElement) { nameElement.textContent = "Hello, again" } } - el.sayHello = sayHello.bind(el) + el.sayHello = sayHello//.bind(el) + + const docRoot = el.container.getRootNode() await new Promise((resolve, reject) => { const failIfNotClicked = setTimeout(() => { @@ -409,12 +418,12 @@ describe("react-to-web-component 1", () => { }, 1000) setTimeout(() => { - document - .querySelector("class-greeting button") + docRoot + .querySelector(`button`) ?.click() setTimeout(() => { - const element = document.querySelector("h1") + const element = docRoot.querySelector("h1") expect(element?.textContent).toEqual("Hello, again") clearTimeout(failIfNotClicked) resolve(true) @@ -423,13 +432,13 @@ describe("react-to-web-component 1", () => { }) const sayHelloRerendered = function (this: HTMLElement) { - const nameElement = this.querySelector("h1") + const nameElement = this.container.querySelector("h1") if (nameElement) { nameElement.textContent = "Hello, again rerendered" } } - el.sayHello = sayHelloRerendered.bind(el) + el.sayHello = sayHelloRerendered//.bind(el) await new Promise((resolve, reject) => { const failIfNotClicked = setTimeout(() => { @@ -437,12 +446,12 @@ describe("react-to-web-component 1", () => { }, 1000) setTimeout(() => { - document - .querySelector("class-greeting button") + docRoot + .querySelector(`button`) ?.click() setTimeout(() => { - const element = document.querySelector("h1") + const element = docRoot.querySelector("h1") expect(element?.textContent).toEqual("Hello, again rerendered") clearTimeout(failIfNotClicked) resolve(true) From 891a5317d33798a5613f04bb9b182e0f407cda62 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Thu, 19 Feb 2026 18:19:42 -0500 Subject: [PATCH 2/5] Fix types; further simplify method transformer --- packages/core/src/core.ts | 2 + packages/core/src/transforms/index.ts | 4 +- packages/core/src/transforms/method.ts | 45 ++++++++----------- .../src/react-to-web-component.test.tsx | 14 +++--- 4 files changed, 31 insertions(+), 34 deletions(-) diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 6111317..992e4e5 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -1,6 +1,8 @@ import transforms, { R2WCType } from "./transforms" import { toDashedCase } from "./utils" +export type { R2WCElement } from "./transforms" + type PropName = Exclude, "container"> type PropNames = Array> diff --git a/packages/core/src/transforms/index.ts b/packages/core/src/transforms/index.ts index b363eb1..f37c896 100644 --- a/packages/core/src/transforms/index.ts +++ b/packages/core/src/transforms/index.ts @@ -5,13 +5,13 @@ import method_ from "./method" import number from "./number" import string from "./string" -type R2WCElement = HTMLElement & { +export type R2WCElement = HTMLElement & { container: R2WCElement } export interface Transform { stringify?: (value: Type, attribute: string, element: R2WCElement) => string - parse: (value: string, attribute: string, element: R2WCElement) => Type + parse: (value: string, attribute: string, element: R2WCElement) => Type | undefined } const transforms = { diff --git a/packages/core/src/transforms/method.ts b/packages/core/src/transforms/method.ts index 5b4c491..0a3d159 100644 --- a/packages/core/src/transforms/method.ts +++ b/packages/core/src/transforms/method.ts @@ -6,35 +6,28 @@ const boundSymbol = Symbol.for("r2wc.bound") const method_: Transform<(...args: unknown[]) => unknown> = { parse: (value, attribute, element) => { - const fn = (() => { - const functionName = toCamelCase(attribute) + const functionName = toCamelCase(attribute) - const r2wcElement = element as typeof element & { - container: typeof r2wcElement - } & { - [k in typeof functionName]: Function - } + const r2wcElement = element as typeof element & { + container: typeof r2wcElement + } & { + [k in typeof functionName]: (...args: unknown[]) => unknown + } - if (typeof r2wcElement !== "undefined") { - if ( - typeof r2wcElement !== "undefined" - && functionName in r2wcElement && typeof r2wcElement[functionName] !== "undefined" - ) { - let fn = r2wcElement[functionName] - if (!(boundSymbol in r2wcElement[functionName])) { - fn = fn.bind(r2wcElement) - Object.defineProperty(fn, boundSymbol, { value: true }) - } - return fn - } else { - return null - } - } else { - return null + if ( + typeof r2wcElement !== "undefined" + && functionName in r2wcElement + && typeof r2wcElement[functionName] !== "undefined" + ) { + let fn = r2wcElement[functionName] + if (!(boundSymbol in r2wcElement[functionName])) { + fn = fn.bind(r2wcElement) + Object.defineProperty(fn, boundSymbol, { value: true }) } - })() - - return typeof fn === "function" ? fn.bind(element) : undefined + return fn + } else { + return undefined + } }, } diff --git a/packages/react-to-web-component/src/react-to-web-component.test.tsx b/packages/react-to-web-component/src/react-to-web-component.test.tsx index 36b9dfe..370a791 100644 --- a/packages/react-to-web-component/src/react-to-web-component.test.tsx +++ b/packages/react-to-web-component/src/react-to-web-component.test.tsx @@ -4,6 +4,8 @@ import PropTypes from "prop-types" import React from "react" import { describe, it, expect, assert } from "vitest" +import type { R2WCElement } from '@r2wc/core' + import r2wc from "./react-to-web-component" expect.extend(matchers) @@ -393,7 +395,7 @@ describe("react-to-web-component 1", () => { document.body.innerHTML = `<${tagName} name='Christopher'>` - const el = document.querySelector void }>( + const el = document.querySelector void }>( tagName, ) @@ -401,16 +403,16 @@ describe("react-to-web-component 1", () => { throw new Error("Element not found") } - const sayHello = function (this: HTMLElement) { + const sayHello = function (this: R2WCElement) { const nameElement = this.container.querySelector("h1") if (nameElement) { nameElement.textContent = "Hello, again" } } - el.sayHello = sayHello//.bind(el) + el.sayHello = sayHello - const docRoot = el.container.getRootNode() + const docRoot = el.container.getRootNode() as Document | DocumentFragment await new Promise((resolve, reject) => { const failIfNotClicked = setTimeout(() => { @@ -431,14 +433,14 @@ describe("react-to-web-component 1", () => { }, 0) }) - const sayHelloRerendered = function (this: HTMLElement) { + const sayHelloRerendered = function (this: R2WCElement) { const nameElement = this.container.querySelector("h1") if (nameElement) { nameElement.textContent = "Hello, again rerendered" } } - el.sayHello = sayHelloRerendered//.bind(el) + el.sayHello = sayHelloRerendered await new Promise((resolve, reject) => { const failIfNotClicked = setTimeout(() => { From 674ddd8e295f08b1bdce1d66fe6317cef7d0e507 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Thu, 19 Feb 2026 18:40:50 -0500 Subject: [PATCH 3/5] prettier fix in core package --- packages/core/src/core.ts | 12 ++++++++++-- packages/core/src/transforms/index.ts | 6 +++++- packages/core/src/transforms/method.ts | 6 +++--- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/core/src/core.ts b/packages/core/src/core.ts index 992e4e5..c776d24 100644 --- a/packages/core/src/core.ts +++ b/packages/core/src/core.ts @@ -147,7 +147,11 @@ export default function r2wc( const type = propTypes[prop] const transform = type ? transforms[type] : null - if (prop in propTypes && transform?.parse && (value || type === "method")) { + if ( + prop in propTypes && + transform?.parse && + (value || type === "method") + ) { //@ts-ignore this[propsSymbol][prop] = transform.parse(value, attribute, this) @@ -197,7 +201,11 @@ export default function r2wc( } } } else { - if (prop in propTypes && transform?.parse && (value || type === "method")) { + if ( + prop in propTypes && + transform?.parse && + (value || type === "method") + ) { //@ts-ignore this[propsSymbol][prop] = transform.parse(value, attribute, this) } diff --git a/packages/core/src/transforms/index.ts b/packages/core/src/transforms/index.ts index f37c896..27f41d1 100644 --- a/packages/core/src/transforms/index.ts +++ b/packages/core/src/transforms/index.ts @@ -11,7 +11,11 @@ export type R2WCElement = HTMLElement & { export interface Transform { stringify?: (value: Type, attribute: string, element: R2WCElement) => string - parse: (value: string, attribute: string, element: R2WCElement) => Type | undefined + parse: ( + value: string, + attribute: string, + element: R2WCElement, + ) => Type | undefined } const transforms = { diff --git a/packages/core/src/transforms/method.ts b/packages/core/src/transforms/method.ts index 0a3d159..e31c5ff 100644 --- a/packages/core/src/transforms/method.ts +++ b/packages/core/src/transforms/method.ts @@ -15,9 +15,9 @@ const method_: Transform<(...args: unknown[]) => unknown> = { } if ( - typeof r2wcElement !== "undefined" - && functionName in r2wcElement - && typeof r2wcElement[functionName] !== "undefined" + typeof r2wcElement !== "undefined" && + functionName in r2wcElement && + typeof r2wcElement[functionName] !== "undefined" ) { let fn = r2wcElement[functionName] if (!(boundSymbol in r2wcElement[functionName])) { From deee9851a2579117f21a76e91396c9577ed2d2f1 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Thu, 19 Feb 2026 18:41:55 -0500 Subject: [PATCH 4/5] fix linting error in r2wc package --- .../react-to-web-component/src/react-to-web-component.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-to-web-component/src/react-to-web-component.test.tsx b/packages/react-to-web-component/src/react-to-web-component.test.tsx index 370a791..b1973cd 100644 --- a/packages/react-to-web-component/src/react-to-web-component.test.tsx +++ b/packages/react-to-web-component/src/react-to-web-component.test.tsx @@ -4,7 +4,7 @@ import PropTypes from "prop-types" import React from "react" import { describe, it, expect, assert } from "vitest" -import type { R2WCElement } from '@r2wc/core' +import { R2WCElement } from '@r2wc/core' import r2wc from "./react-to-web-component" From 2d4aae3d86f5994deffa0befde8aa0c0583b6f53 Mon Sep 17 00:00:00 2001 From: Bradley Momberger Date: Thu, 19 Feb 2026 18:44:26 -0500 Subject: [PATCH 5/5] Prettify r2wc package --- .../src/react-to-web-component.test.tsx | 144 +++++++++--------- 1 file changed, 71 insertions(+), 73 deletions(-) diff --git a/packages/react-to-web-component/src/react-to-web-component.test.tsx b/packages/react-to-web-component/src/react-to-web-component.test.tsx index b1973cd..bef0391 100644 --- a/packages/react-to-web-component/src/react-to-web-component.test.tsx +++ b/packages/react-to-web-component/src/react-to-web-component.test.tsx @@ -4,7 +4,7 @@ import PropTypes from "prop-types" import React from "react" import { describe, it, expect, assert } from "vitest" -import { R2WCElement } from '@r2wc/core' +import { R2WCElement } from "@r2wc/core" import r2wc from "./react-to-web-component" @@ -366,99 +366,97 @@ describe("react-to-web-component 1", () => { }) }) - it.each( - [[undefined], ["open"], ["closed"]] - )( + it.each([[undefined], ["open"], ["closed"]])( `Supports class function to react props using method transform: (shadow: %s)`, async (shadow) => { - const ClassGreeting: React.FC<{ name: string; sayHello: () => void }> = ({ - name, - sayHello, - }) => ( -
-

Hello, {name}

- -
- ) - - const WebClassGreeting = r2wc(ClassGreeting, { - props: { - name: "string", - sayHello: "method", - }, - shadow: shadow as unknown as Exclude[1], undefined>['shadow'], - }) + const ClassGreeting: React.FC<{ name: string; sayHello: () => void }> = ({ + name, + sayHello, + }) => ( +
+

Hello, {name}

+ +
+ ) - const tagName = `class-greeting${shadow ? `-${shadow}`: ''}` + const WebClassGreeting = r2wc(ClassGreeting, { + props: { + name: "string", + sayHello: "method", + }, + shadow: shadow as unknown as Exclude< + Parameters[1], + undefined + >["shadow"], + }) - customElements.define(tagName, WebClassGreeting) + const tagName = `class-greeting${shadow ? `-${shadow}` : ""}` - document.body.innerHTML = `<${tagName} name='Christopher'>` + customElements.define(tagName, WebClassGreeting) - const el = document.querySelector void }>( - tagName, - ) + document.body.innerHTML = `<${tagName} name='Christopher'>` - if (!el) { - throw new Error("Element not found") - } + const el = document.querySelector< + R2WCElement & { sayHello?: () => void } + >(tagName) - const sayHello = function (this: R2WCElement) { - const nameElement = this.container.querySelector("h1") - if (nameElement) { - nameElement.textContent = "Hello, again" + if (!el) { + throw new Error("Element not found") } - } - el.sayHello = sayHello + const sayHello = function (this: R2WCElement) { + const nameElement = this.container.querySelector("h1") + if (nameElement) { + nameElement.textContent = "Hello, again" + } + } - const docRoot = el.container.getRootNode() as Document | DocumentFragment + el.sayHello = sayHello - await new Promise((resolve, reject) => { - const failIfNotClicked = setTimeout(() => { - reject() - }, 1000) + const docRoot = el.container.getRootNode() as Document | DocumentFragment - setTimeout(() => { - docRoot - .querySelector(`button`) - ?.click() + await new Promise((resolve, reject) => { + const failIfNotClicked = setTimeout(() => { + reject() + }, 1000) setTimeout(() => { - const element = docRoot.querySelector("h1") - expect(element?.textContent).toEqual("Hello, again") - clearTimeout(failIfNotClicked) - resolve(true) + docRoot.querySelector(`button`)?.click() + + setTimeout(() => { + const element = docRoot.querySelector("h1") + expect(element?.textContent).toEqual("Hello, again") + clearTimeout(failIfNotClicked) + resolve(true) + }, 0) }, 0) - }, 0) - }) + }) - const sayHelloRerendered = function (this: R2WCElement) { - const nameElement = this.container.querySelector("h1") - if (nameElement) { - nameElement.textContent = "Hello, again rerendered" + const sayHelloRerendered = function (this: R2WCElement) { + const nameElement = this.container.querySelector("h1") + if (nameElement) { + nameElement.textContent = "Hello, again rerendered" + } } - } - - el.sayHello = sayHelloRerendered - await new Promise((resolve, reject) => { - const failIfNotClicked = setTimeout(() => { - reject() - }, 1000) + el.sayHello = sayHelloRerendered - setTimeout(() => { - docRoot - .querySelector(`button`) - ?.click() + await new Promise((resolve, reject) => { + const failIfNotClicked = setTimeout(() => { + reject() + }, 1000) setTimeout(() => { - const element = docRoot.querySelector("h1") - expect(element?.textContent).toEqual("Hello, again rerendered") - clearTimeout(failIfNotClicked) - resolve(true) + docRoot.querySelector(`button`)?.click() + + setTimeout(() => { + const element = docRoot.querySelector("h1") + expect(element?.textContent).toEqual("Hello, again rerendered") + clearTimeout(failIfNotClicked) + resolve(true) + }, 0) }, 0) - }, 0) - }) - }) + }) + }, + ) })