From 15e6f2fc7bb390f61a25dee8247ae35e38bb71ae Mon Sep 17 00:00:00 2001 From: Gyles Fohl Date: Mon, 28 Sep 2026 15:57:58 -0500 Subject: [PATCH 1/7] fix(react): render Stencil props as attributes during SSR The client wrapper set every Stencil prop as a JS property after mount, so server HTML had bare tags and elements upgraded with default props until React hydrated. On the server, render the tag with the props as attributes, mapped through the element class (prototype accessors and observedAttributes). Non-primitive props stay client-only and false is omitted, matching the SSR wrapper. suppressHydrationWarning keeps React from reporting the extra attributes. Fixes #848 --- .../src/runtime/create-component.test.ts | 88 +++++++++++++++++++ .../react/src/runtime/create-component.ts | 61 ++++++++++++- 2 files changed, 148 insertions(+), 1 deletion(-) diff --git a/packages/react/src/runtime/create-component.test.ts b/packages/react/src/runtime/create-component.test.ts index 9101cbd6..a8c39441 100644 --- a/packages/react/src/runtime/create-component.test.ts +++ b/packages/react/src/runtime/create-component.test.ts @@ -57,3 +57,91 @@ describe('mergeClassNames', () => { expect(mergeClassNames(['md', 'hydrated'], '', '')).toBe('md hydrated'); }); }); + +describe('createComponent on the server', () => { + // Shape of a Stencil custom-elements class in Node: prop accessors plus observedAttributes. + class FakeElement { + static observedAttributes = ['variant', 'icon-start', 'full-width', 'count']; + get variant() { + return ''; + } + get iconStart() { + return ''; + } + get fullWidth() { + return false; + } + get count() { + return 0; + } + get items() { + return [] as unknown[]; + } + } + + const make = (elementClass: unknown = FakeElement) => + createComponent>({ + tagName: 'fake-element', + elementClass: elementClass as any, + react: React, + events: {}, + defineCustomElement: vi.fn(), + }); + + it('renders Stencil props as kebab-case attributes', async () => { + const { renderToString } = await import('react-dom/server'); + const Fake = make(); + + const html = renderToString( + React.createElement(Fake, { variant: 'tertiary', iconStart: 'home', fullWidth: true, count: 3 }, 'Open') + ); + + expect(html).toBe('Open'); + }); + + it('omits false, nullish, function and object-valued Stencil props', async () => { + const { renderToString } = await import('react-dom/server'); + const Fake = make(); + + const html = renderToString( + React.createElement(Fake, { + fullWidth: false, + variant: undefined, + iconStart: null, + items: [{ label: 'a' }], + onClick: () => undefined, + }) + ); + + expect(html).toBe(''); + }); + + it('passes non-Stencil props through untouched and maps className to class', async () => { + const { renderToString } = await import('react-dom/server'); + const Fake = make(); + + const html = renderToString( + React.createElement(Fake, { + className: 'a b', + id: 'x', + slot: 'footer', + 'aria-label': 'Open', + 'data-test': 'y', + style: { color: 'red' }, + }) + ); + + expect(html).toBe( + '' + ); + }); + + it('renders only pass-through props when the element class exposes no attributes', async () => { + const { renderToString } = await import('react-dom/server'); + const Fake = make(class Bare {}); + + const html = renderToString(React.createElement(Fake, { variant: 'tertiary', id: 'x' })); + + expect(html).toBe(''); + }); +}); diff --git a/packages/react/src/runtime/create-component.ts b/packages/react/src/runtime/create-component.ts index d1b28e3f..ac7f0f81 100644 --- a/packages/react/src/runtime/create-component.ts +++ b/packages/react/src/runtime/create-component.ts @@ -63,6 +63,59 @@ export const mergeClassNames = ( // client-only anyway, so fall back to `useEffect` on the server. const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; +// Stencil attribute name for a prop: `iconStart` -> `icon-start`. +const toAttributeName = (propName: string): string => propName.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); + +// Server render: emit Stencil props as attributes so the element upgrades in its final state. +// Props are read off the element class; objects and functions stay client-only, `false` is omitted. +const createServerComponent = ( + tagName: string, + elementClass: Options['elementClass'], + displayName: string | undefined +): StencilReactComponent => { + const observedAttributes: unknown = (elementClass as { observedAttributes?: unknown }).observedAttributes; + const attributeNames = new Set(Array.isArray(observedAttributes) ? observedAttributes : []); + const prototype: object = elementClass.prototype; + + const ServerComponent = React.forwardRef>((props, _ref) => { + const { + className, + class: classProp, + children, + ...rest + } = props as StencilProps & { + className?: string; + class?: string; + children?: React.ReactNode; + }; + // The client render has no attributes; keep React from flagging a mismatch. + const attributes: Record = { suppressHydrationWarning: true }; + const classes = className ?? classProp; + if (classes) { + attributes.class = classes; + } + + for (const [key, value] of Object.entries(rest)) { + if (value === null || value === undefined || typeof value === 'function') { + continue; + } + if (key in prototype) { + const attributeName = toAttributeName(key); + if (attributeNames.has(attributeName) && typeof value !== 'object' && value !== false) { + attributes[attributeName] = value === true ? '' : value; + } + continue; + } + attributes[key] = value; + } + + return React.createElement(tagName, attributes, children); + }); + ServerComponent.displayName = displayName ?? tagName; + + return ServerComponent as unknown as StencilReactComponent; +}; + /** * Defines a custom element and creates a React component. * @public @@ -85,6 +138,11 @@ export const createComponent = < defineCustomElement(); } const finalTagName = transformTag ? transformTag(tagName) : tagName; + + if (typeof window === 'undefined') { + return createServerComponent(finalTagName, options.elementClass, options.displayName); + } + const ReactComponent = createComponentWrapper({ ...options, tagName: finalTagName }); /** @@ -127,7 +185,8 @@ export const createComponent = < [ref] ); - return React.createElement(ReactComponent, { ...restProps, ref: setRef } as any); + // Server HTML carries attributes this render does not set. + return React.createElement(ReactComponent, { ...restProps, ref: setRef, suppressHydrationWarning: true } as any); }); WrappedComponent.displayName = options.displayName ?? finalTagName; From dfb0496b7a1a350c302db53041ccb3ce5d499721 Mon Sep 17 00:00:00 2001 From: Gyles Fohl Date: Tue, 29 Sep 2026 09:03:17 -0500 Subject: [PATCH 2/7] fix(react): resolve SSR attribute names from the generated properties map Generate the same prop-to-attribute map the server wrappers use into the client wrappers and prefer it on the server, so @Prop({ attribute }) names are honoured. The observedAttributes lookup stays as a fallback for hand-written wrappers. Document that this covers host attributes only. --- packages/react/README.md | 6 ++++++ .../src/create-component-wrappers.test.ts | 3 +++ .../create-stencil-react-components.test.ts | 1 + .../src/create-stencil-react-components.ts | 19 +++++++++---------- .../src/runtime/create-component.test.ts | 17 +++++++++++++++++ .../react/src/runtime/create-component.ts | 18 ++++++++++++++---- 6 files changed, 50 insertions(+), 14 deletions(-) diff --git a/packages/react/README.md b/packages/react/README.md index af2e16b8..5e67b3c0 100644 --- a/packages/react/README.md +++ b/packages/react/README.md @@ -78,6 +78,12 @@ npm install typescript@5 --save-dev That's it! You can now import and use your Stencil components as React components in your React application or library. +### Server-side rendering + +When a wrapper is rendered on the server (Next.js, Remix, Vite SSR), it emits the component's props as attributes on the host tag, for example ``. The element then upgrades in its final state on the client instead of flashing its defaults until React hydrates. + +This covers host attributes only. Shadow DOM and styles are still created in the browser. To render the full component on the server, use the `hydrateModule` option (see below) or [@stencil/ssr](https://github.com/stenciljs/output-targets/tree/main/packages/ssr). + ## Output Target Options | Property | Description | diff --git a/packages/react/src/create-component-wrappers.test.ts b/packages/react/src/create-component-wrappers.test.ts index 65a98b85..59098bf5 100644 --- a/packages/react/src/create-component-wrappers.test.ts +++ b/packages/react/src/create-component-wrappers.test.ts @@ -53,6 +53,7 @@ export type MyComponentEvents = NonNullable; export const MyComponent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-component', elementClass: MyComponentElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComponentEvents, @@ -115,6 +116,7 @@ export type MyComponentEvents = NonNullable; export const MyComponent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-component', elementClass: MyComponentElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComponentEvents, @@ -226,6 +228,7 @@ export type MyComponentEvents = { export const MyComponent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-component', elementClass: MyComponentElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { diff --git a/packages/react/src/create-stencil-react-components.test.ts b/packages/react/src/create-stencil-react-components.test.ts index 70587968..1e2a8b6d 100644 --- a/packages/react/src/create-stencil-react-components.test.ts +++ b/packages/react/src/create-stencil-react-components.test.ts @@ -35,6 +35,7 @@ describe('createStencilReactComponents', () => { .toContain(`export const MyComponent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-component', elementClass: MyComponentElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComponentEvents, diff --git a/packages/react/src/create-stencil-react-components.ts b/packages/react/src/create-stencil-react-components.ts index 458be07a..8d43c8ee 100644 --- a/packages/react/src/create-stencil-react-components.ts +++ b/packages/react/src/create-stencil-react-components.ts @@ -164,6 +164,13 @@ export const createStencilReactComponents = ({ exports.push(`export type ${componentEventNamesType} = ${eventType};`); + // Only props with an attribute; complex props stay client-side properties. + const serverProperties = component.properties + .filter(({ attribute }) => Boolean(attribute)) + .map(({ name, attribute }) => `${name}: '${attribute}'`) + .join(',\n '); + const propertiesLine = ` properties: {${serverProperties ? `\n ${serverProperties}\n ` : ''}},`; + const clientComponentCall = [ `/*@__PURE__*/ createComponent<${componentElement}, ${componentEventNamesType}, Components.${reactTagName}${requiredGeneric}>({`, ` tagName: '${tagName}',`, @@ -172,25 +179,17 @@ export const createStencilReactComponents = ({ ` react: React,`, ` events: ${events.length ? `{\n ${events.map((e) => `${e.name}: '${e.originalName}'`).join(',\n ')}\n }` : '{}'} as ${componentEventNamesType},`, ` defineCustomElement: define${reactTagName},`, + propertiesLine, transformTag && ` transformTag,`, `})`, ] .filter(Boolean) .join('\n'); - /** - * Filter out properties that don't have an attribute. - * These are properties with complex types and can't be serialized. - */ - const serverProperties = component.properties - .filter(({ attribute }) => Boolean(attribute)) - .map(({ name, attribute }) => `${name}: '${attribute}'`) - .join(',\n '); - const serverComponentCall = [ `/*@__PURE__*/ createComponent<${componentElement}, ${componentEventNamesType}, Components.${reactTagName}${requiredGeneric}>({`, ` tagName: '${tagName}',`, - ` properties: {${serverProperties ? `\n ${serverProperties}\n ` : ''}},`, + propertiesLine, ` hydrateModule: typeof window === 'undefined' ? (import('${hydrateModule}') as Promise) : undefined,`, ` clientModule: clientComponents.${reactTagName} as StencilReactComponent<${componentElement}, ${componentEventNamesType}, Components.${reactTagName}${requiredGeneric}>,`, ` serializeShadowRoot,`, diff --git a/packages/react/src/runtime/create-component.test.ts b/packages/react/src/runtime/create-component.test.ts index a8c39441..438c6c3c 100644 --- a/packages/react/src/runtime/create-component.test.ts +++ b/packages/react/src/runtime/create-component.test.ts @@ -136,6 +136,23 @@ describe('createComponent on the server', () => { ); }); + it('prefers the generated properties map over the derived attribute name', async () => { + const { renderToString } = await import('react-dom/server'); + const Fake = createComponent>({ + tagName: 'fake-element', + elementClass: FakeElement as any, + properties: { iconStart: 'data-icon', variant: 'variant' }, + react: React, + events: {}, + defineCustomElement: vi.fn(), + }); + + const html = renderToString(React.createElement(Fake, { iconStart: 'home', variant: 'tertiary', count: 3 })); + + // `count` is a Stencil prop without an attribute in the map, so it stays client-side. + expect(html).toBe(''); + }); + it('renders only pass-through props when the element class exposes no attributes', async () => { const { renderToString } = await import('react-dom/server'); const Fake = make(class Bare {}); diff --git a/packages/react/src/runtime/create-component.ts b/packages/react/src/runtime/create-component.ts index ac7f0f81..1b003249 100644 --- a/packages/react/src/runtime/create-component.ts +++ b/packages/react/src/runtime/create-component.ts @@ -67,15 +67,23 @@ const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayou const toAttributeName = (propName: string): string => propName.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); // Server render: emit Stencil props as attributes so the element upgrades in its final state. -// Props are read off the element class; objects and functions stay client-only, `false` is omitted. +// Objects and functions stay client-only, `false` is omitted, matching `ssr.tsx`. const createServerComponent = ( tagName: string, elementClass: Options['elementClass'], + properties: Record | undefined, displayName: string | undefined ): StencilReactComponent => { const observedAttributes: unknown = (elementClass as { observedAttributes?: unknown }).observedAttributes; const attributeNames = new Set(Array.isArray(observedAttributes) ? observedAttributes : []); const prototype: object = elementClass.prototype; + // Generated wrappers pass the prop-to-attribute map (honours `@Prop({ attribute })`); + // without it, fall back to the kebab-case name checked against `observedAttributes`. + const attributeNameFor = (propName: string): string | undefined => { + if (properties) return properties[propName]; + const attributeName = toAttributeName(propName); + return attributeNames.has(attributeName) ? attributeName : undefined; + }; const ServerComponent = React.forwardRef>((props, _ref) => { const { @@ -100,8 +108,8 @@ const createServerComponent = ({ defineCustomElement, + properties, tagName, transformTag, ...options }: Options & { defineCustomElement: () => void; + properties?: Record; transformTag?: (tagName: string) => string; }): StencilReactComponent => { if (typeof defineCustomElement !== 'undefined') { @@ -140,7 +150,7 @@ export const createComponent = < const finalTagName = transformTag ? transformTag(tagName) : tagName; if (typeof window === 'undefined') { - return createServerComponent(finalTagName, options.elementClass, options.displayName); + return createServerComponent(finalTagName, options.elementClass, properties, options.displayName); } const ReactComponent = createComponentWrapper({ ...options, tagName: finalTagName }); From 6b688d9b16dfa2b58b1ce34c53a24d745465e7e1 Mon Sep 17 00:00:00 2001 From: Gyles Fohl Date: Tue, 29 Sep 2026 09:08:15 -0500 Subject: [PATCH 3/7] fix(react): detect Stencil props by own accessors above HTMLElement `key in prototype` walks the whole chain, so under a server DOM shim inherited props like id or title would be mistaken for Stencil props and dropped. Check own accessors on the component prototype chain only, stopping at HTMLElement. --- .../src/runtime/create-component.test.ts | 29 +++++++++++++++++++ .../react/src/runtime/create-component.ts | 16 ++++++++-- 2 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/react/src/runtime/create-component.test.ts b/packages/react/src/runtime/create-component.test.ts index 438c6c3c..b852ced5 100644 --- a/packages/react/src/runtime/create-component.test.ts +++ b/packages/react/src/runtime/create-component.test.ts @@ -153,6 +153,35 @@ describe('createComponent on the server', () => { expect(html).toBe(''); }); + it('treats accessors inherited from HTMLElement as pass-through props', async () => { + const { renderToString } = await import('react-dom/server'); + // Server DOM shims (jsdom, happy-dom) put id/title on HTMLElement.prototype. + class HTMLElementStub { + get id() { + return ''; + } + get title() { + return ''; + } + } + vi.stubGlobal('HTMLElement', HTMLElementStub); + try { + class Fixture extends HTMLElementStub { + static observedAttributes = ['variant']; + get variant() { + return ''; + } + } + const Fake = make(Fixture); + + const html = renderToString(React.createElement(Fake, { variant: 'tertiary', id: 'x', title: 'Open' })); + + expect(html).toBe(''); + } finally { + vi.unstubAllGlobals(); + } + }); + it('renders only pass-through props when the element class exposes no attributes', async () => { const { renderToString } = await import('react-dom/server'); const Fake = make(class Bare {}); diff --git a/packages/react/src/runtime/create-component.ts b/packages/react/src/runtime/create-component.ts index 1b003249..a8b2cbe9 100644 --- a/packages/react/src/runtime/create-component.ts +++ b/packages/react/src/runtime/create-component.ts @@ -76,7 +76,19 @@ const createServerComponent = => { const observedAttributes: unknown = (elementClass as { observedAttributes?: unknown }).observedAttributes; const attributeNames = new Set(Array.isArray(observedAttributes) ? observedAttributes : []); - const prototype: object = elementClass.prototype; + // Stencil defines prop accessors on the component's own prototype chain, above HTMLElement. + // Anything inherited from the DOM (`id`, `title`, `slot`) is a pass-through prop. + const componentPrototypes: object[] = []; + const domPrototype: object | null = typeof HTMLElement === 'function' ? HTMLElement.prototype : null; + for ( + let p: object | null = elementClass.prototype; + p && p !== Object.prototype && p !== domPrototype; + p = Object.getPrototypeOf(p) + ) { + componentPrototypes.push(p); + } + const isStencilProp = (propName: string): boolean => + componentPrototypes.some((p) => Object.prototype.hasOwnProperty.call(p, propName)); // Generated wrappers pass the prop-to-attribute map (honours `@Prop({ attribute })`); // without it, fall back to the kebab-case name checked against `observedAttributes`. const attributeNameFor = (propName: string): string | undefined => { @@ -107,7 +119,7 @@ const createServerComponent = Date: Tue, 29 Sep 2026 09:25:36 -0500 Subject: [PATCH 4/7] chore(react): regenerate example wrappers with the properties map --- .../component-library-react/src/components.ts | 157 ++++++++++++++++++ 1 file changed, 157 insertions(+) diff --git a/example-project/component-library-react/src/components.ts b/example-project/component-library-react/src/components.ts index 3f9788d8..fdcd13b7 100644 --- a/example-project/component-library-react/src/components.ts +++ b/example-project/component-library-react/src/components.ts @@ -44,6 +44,21 @@ export type MyButtonEvents = { export const MyButton: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-button', elementClass: MyButtonElement, + properties: { + color: 'color', + buttonType: 'button-type', + disabled: 'disabled', + expand: 'expand', + fill: 'fill', + download: 'download', + href: 'href', + rel: 'rel', + shape: 'shape', + size: 'size', + strong: 'strong', + target: 'target', + type: 'type' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -62,6 +77,21 @@ export type MyButtonScopedEvents = { export const MyButtonScoped: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-button-scoped', elementClass: MyButtonScopedElement, + properties: { + color: 'color', + buttonType: 'button-type', + disabled: 'disabled', + expand: 'expand', + fill: 'fill', + download: 'download', + href: 'href', + rel: 'rel', + shape: 'shape', + size: 'size', + strong: 'strong', + target: 'target', + type: 'type' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -82,6 +112,17 @@ export type MyCheckboxEvents = { export const MyCheckbox: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-checkbox', elementClass: MyCheckboxElement, + properties: { + color: 'color', + name: 'name', + checked: 'checked', + indeterminate: 'indeterminate', + disabled: 'disabled', + value: 'value', + labelPlacement: 'label-placement', + justify: 'justify', + alignment: 'alignment' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -99,6 +140,7 @@ export type MyComplexPropsEvents = NonNullable; export const MyComplexProps: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-complex-props', elementClass: MyComplexPropsElement, + properties: { grault: 'grault' }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComplexPropsEvents, @@ -111,6 +153,7 @@ export type MyComplexPropsScopedEvents = NonNullable; export const MyComplexPropsScoped: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-complex-props-scoped', elementClass: MyComplexPropsScopedElement, + properties: { grault: 'grault' }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComplexPropsScopedEvents, @@ -125,6 +168,11 @@ export type MyComponentEvents = { export const MyComponent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-component', elementClass: MyComponentElement, + properties: { + first: 'first', + middleName: 'middle-name', + last: 'last' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -139,6 +187,7 @@ export type MyComponentDelegatesFocusEvents = NonNullable; export const MyComponentDelegatesFocus: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-component-delegates-focus', elementClass: MyComponentDelegatesFocusElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComponentDelegatesFocusEvents, @@ -153,6 +202,11 @@ export type MyComponentScopedEvents = { export const MyComponentScoped: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-component-scoped', elementClass: MyComponentScopedElement, + properties: { + first: 'first', + middleName: 'middle-name', + last: 'last' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -169,6 +223,7 @@ export type MyCounterEvents = { export const MyCounter: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-counter', elementClass: MyCounterElement, + properties: { startValue: 'start-value' }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -188,6 +243,34 @@ export type MyInputEvents = { export const MyInput: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-input', elementClass: MyInputElement, + properties: { + color: 'color', + accept: 'accept', + autocapitalize: 'autocapitalize', + autocomplete: 'autocomplete', + autocorrect: 'autocorrect', + autofocus: 'autofocus', + clearInput: 'clear-input', + clearOnEdit: 'clear-on-edit', + disabled: 'disabled', + enterkeyhint: 'enterkeyhint', + inputmode: 'inputmode', + max: 'max', + maxlength: 'maxlength', + min: 'min', + minlength: 'minlength', + multiple: 'multiple', + name: 'name', + pattern: 'pattern', + placeholder: 'placeholder', + readonly: 'readonly', + required: 'required', + spellcheck: 'spellcheck', + step: 'step', + size: 'size', + type: 'type', + value: 'value' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -210,6 +293,34 @@ export type MyInputScopedEvents = { export const MyInputScoped: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-input-scoped', elementClass: MyInputScopedElement, + properties: { + color: 'color', + accept: 'accept', + autocapitalize: 'autocapitalize', + autocomplete: 'autocomplete', + autocorrect: 'autocorrect', + autofocus: 'autofocus', + clearInput: 'clear-input', + clearOnEdit: 'clear-on-edit', + disabled: 'disabled', + enterkeyhint: 'enterkeyhint', + inputmode: 'inputmode', + max: 'max', + maxlength: 'maxlength', + min: 'min', + minlength: 'minlength', + multiple: 'multiple', + name: 'name', + pattern: 'pattern', + placeholder: 'placeholder', + readonly: 'readonly', + required: 'required', + spellcheck: 'spellcheck', + step: 'step', + size: 'size', + type: 'type', + value: 'value' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -227,6 +338,7 @@ export type MyListEvents = NonNullable; export const MyList: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-list', elementClass: MyListElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyListEvents, @@ -239,6 +351,7 @@ export type MyListItemEvents = NonNullable; export const MyListItem: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-list-item', elementClass: MyListItemElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyListItemEvents, @@ -251,6 +364,7 @@ export type MyListItemScopedEvents = NonNullable; export const MyListItemScoped: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-list-item-scoped', elementClass: MyListItemScopedElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyListItemScopedEvents, @@ -263,6 +377,7 @@ export type MyListScopedEvents = NonNullable; export const MyListScoped: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-list-scoped', elementClass: MyListScopedElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyListScopedEvents, @@ -280,6 +395,16 @@ export type MyPopoverEvents = { export const MyPopover: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-popover', elementClass: MyPopoverElement, + properties: { + component: 'component', + keyboardClose: 'keyboard-close', + cssClass: 'css-class', + backdropDismiss: 'backdrop-dismiss', + event: 'event', + showBackdrop: 'show-backdrop', + translucent: 'translucent', + animated: 'animated' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -300,6 +425,15 @@ export type MyRadioEvents = { export const MyRadio: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-radio', elementClass: MyRadioElement, + properties: { + color: 'color', + name: 'name', + disabled: 'disabled', + value: 'value', + labelPlacement: 'label-placement', + justify: 'justify', + alignment: 'alignment' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -317,6 +451,12 @@ export type MyRadioGroupEvents = { export const MyRadioGroup: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-radio-group', elementClass: MyRadioGroupElement, + properties: { + allowEmptySelection: 'allow-empty-selection', + compareWith: 'compare-with', + name: 'name', + value: 'value' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -335,6 +475,20 @@ export type MyRangeEvents = { export const MyRange: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-range', elementClass: MyRangeElement, + properties: { + color: 'color', + debounce: 'debounce', + name: 'name', + dualKnobs: 'dual-knobs', + min: 'min', + max: 'max', + pin: 'pin', + snaps: 'snaps', + step: 'step', + ticks: 'ticks', + disabled: 'disabled', + value: 'value' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -351,6 +505,7 @@ export type MyToggleEvents = NonNullable; export const MyToggle: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-toggle', elementClass: MyToggleElement, + properties: {}, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyToggleEvents, @@ -363,6 +518,7 @@ export type MyToggleContentEvents = NonNullable; export const MyToggleContent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-toggle-content', elementClass: MyToggleContentElement, + properties: { visible: 'visible' }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyToggleContentEvents, @@ -375,6 +531,7 @@ export type MyTransformTestEvents = NonNullable; export const MyTransformTest: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-transform-test', elementClass: MyTransformTestElement, + properties: { message: 'message' }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyTransformTestEvents, From 2f4321c47af7e3590bdf84aa54ac9753c84299c4 Mon Sep 17 00:00:00 2001 From: Gyles Fohl Date: Mon, 12 Oct 2026 10:51:02 -0500 Subject: [PATCH 5/7] fix(react): keep server-rendered attributes in sync and dasherize digits like Stencil - Client wrapper mirrors the server's attribute set in a layout effect, so a prop set back to false or undefined removes its attribute; @lit/react only sets properties and Stencil does not reflect them - Attribute fallback splits on digits (line1 -> line-1), matching Stencil's toDashCase - Prop detection and attribute naming shared between the server and client paths - jsdom spec: server HTML hydrates without warnings and attributes follow prop changes - Keep suppressHydrationWarning on the client render: @lit/react's node build does not set it --- packages/react/package.json | 1 + .../runtime/create-component.client.test.tsx | 76 ++++++++++++++++++ .../src/runtime/create-component.test.ts | 18 +++++ .../react/src/runtime/create-component.ts | 78 ++++++++++++++----- pnpm-lock.yaml | 3 + 5 files changed, 157 insertions(+), 19 deletions(-) create mode 100644 packages/react/src/runtime/create-component.client.test.tsx diff --git a/packages/react/package.json b/packages/react/package.json index 24cf1b13..bbf3044c 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -70,6 +70,7 @@ "@types/node": "^20.19.43", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", + "jsdom": "^26.1.0", "npm-run-all2": "^6.2.6", "stencil-output-targets-shared": "workspace:*", "ts-dedent": "^2.3.0", diff --git a/packages/react/src/runtime/create-component.client.test.tsx b/packages/react/src/runtime/create-component.client.test.tsx new file mode 100644 index 00000000..e3d16365 --- /dev/null +++ b/packages/react/src/runtime/create-component.client.test.tsx @@ -0,0 +1,76 @@ +// @vitest-environment jsdom +import React, { act } from 'react'; +import { hydrateRoot } from 'react-dom/client'; +import { renderToString } from 'react-dom/server'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +// Stencil-like element: prop accessors on the prototype, attributes not reflected back. +class FakeElement extends HTMLElement { + static observedAttributes = ['disabled', 'variant']; + private _disabled = false; + private _variant = ''; + get disabled() { + return this._disabled; + } + set disabled(value: boolean) { + this._disabled = value; + } + get variant() { + return this._variant; + } + set variant(value: string) { + this._variant = value; + } +} +customElements.define('fake-element', FakeElement); + +const options = { + tagName: 'fake-element', + elementClass: FakeElement as any, + react: React, + events: {}, + defineCustomElement: () => {}, +}; + +// `createComponent` picks the server or client wrapper when it is called, so build the server +// one while `window` is absent and the client one with the real jsdom window. +async function serverAndClient() { + const { createComponent } = await import('./create-component'); + vi.stubGlobal('window', undefined); + const Server = createComponent(options); + vi.unstubAllGlobals(); + const Client = createComponent(options); + return { Server, Client }; +} + +describe('createComponent hydration', () => { + afterEach(() => { + document.body.innerHTML = ''; + vi.restoreAllMocks(); + }); + + it('hydrates server attributes without warnings and keeps them in sync with the props', async () => { + const { Server, Client } = await serverAndClient(); + const container = document.createElement('div'); + document.body.appendChild(container); + container.innerHTML = renderToString(React.createElement(Server, { disabled: true, variant: 'outline' }, 'Save')); + expect(container.innerHTML).toContain('disabled=""'); + const error = vi.spyOn(console, 'error').mockImplementation(() => {}); + + let root: ReturnType; + await act(async () => { + root = hydrateRoot(container, React.createElement(Client, { disabled: true, variant: 'outline' }, 'Save')); + }); + const element = container.querySelector('fake-element')!; + expect(error.mock.calls.flat().join(' ')).not.toMatch(/hydrat/i); + expect(element.getAttribute('variant')).toBe('outline'); + + await act(async () => { + root.render(React.createElement(Client, { disabled: false, variant: 'solid' }, 'Save')); + }); + expect(element.hasAttribute('disabled')).toBe(false); + expect(element.getAttribute('variant')).toBe('solid'); + }); +}); diff --git a/packages/react/src/runtime/create-component.test.ts b/packages/react/src/runtime/create-component.test.ts index b852ced5..6e49d8e6 100644 --- a/packages/react/src/runtime/create-component.test.ts +++ b/packages/react/src/runtime/create-component.test.ts @@ -182,6 +182,24 @@ describe('createComponent on the server', () => { } }); + it('derives attribute names with digits the way Stencil does', async () => { + const { renderToString } = await import('react-dom/server'); + class Digits { + static observedAttributes = ['line-1', 'icon-start']; + get line1() { + return ''; + } + get iconStart() { + return ''; + } + } + const Fake = make(Digits); + + const html = renderToString(React.createElement(Fake, { line1: 'Gold', iconStart: 'home' })); + + expect(html).toBe(''); + }); + it('renders only pass-through props when the element class exposes no attributes', async () => { const { renderToString } = await import('react-dom/server'); const Fake = make(class Bare {}); diff --git a/packages/react/src/runtime/create-component.ts b/packages/react/src/runtime/create-component.ts index a8b2cbe9..2f952cd1 100644 --- a/packages/react/src/runtime/create-component.ts +++ b/packages/react/src/runtime/create-component.ts @@ -59,25 +59,25 @@ export const mergeClassNames = ( return finalClassNames.join(' '); }; -// `useLayoutEffect` warns during server rendering and the reconciliation is -// client-only anyway, so fall back to `useEffect` on the server. -const useIsomorphicLayoutEffect = typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; +// Stencil attribute name for a prop, matching Stencil's `toDashCase`: `iconStart` -> `icon-start`, +// `line1` -> `line-1`. +const toAttributeName = (propName: string): string => + propName + .replace(/([A-Z0-9])/g, (match) => ` ${match[0]}`) + .trim() + .split(' ') + .join('-') + .toLowerCase(); -// Stencil attribute name for a prop: `iconStart` -> `icon-start`. -const toAttributeName = (propName: string): string => propName.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`); - -// Server render: emit Stencil props as attributes so the element upgrades in its final state. -// Objects and functions stay client-only, `false` is omitted, matching `ssr.tsx`. -const createServerComponent = ( - tagName: string, +// Tells Stencil props apart from pass-through props and maps them to their attribute names. +// Stencil defines prop accessors on the component's own prototype chain, above HTMLElement; +// anything inherited from the DOM (`id`, `title`, `slot`) is a pass-through prop. +const createPropResolver = ( elementClass: Options['elementClass'], - properties: Record | undefined, - displayName: string | undefined -): StencilReactComponent => { + properties: Record | undefined +) => { const observedAttributes: unknown = (elementClass as { observedAttributes?: unknown }).observedAttributes; const attributeNames = new Set(Array.isArray(observedAttributes) ? observedAttributes : []); - // Stencil defines prop accessors on the component's own prototype chain, above HTMLElement. - // Anything inherited from the DOM (`id`, `title`, `slot`) is a pass-through prop. const componentPrototypes: object[] = []; const domPrototype: object | null = typeof HTMLElement === 'function' ? HTMLElement.prototype : null; for ( @@ -96,6 +96,25 @@ const createServerComponent = { + if (value === null || value === undefined || value === false) return undefined; + if (typeof value === 'object' || typeof value === 'function') return undefined; + return value === true ? '' : String(value); +}; + +// Server render: emit Stencil props as attributes so the element upgrades in its final state. +// Objects and functions stay client-only, `false` is omitted, matching `ssr.tsx`. +const createServerComponent = ( + tagName: string, + elementClass: Options['elementClass'], + properties: Record | undefined, + displayName: string | undefined +): StencilReactComponent => { + const { isStencilProp, attributeNameFor } = createPropResolver(elementClass, properties); const ServerComponent = React.forwardRef>((props, _ref) => { const { @@ -121,8 +140,9 @@ const createServerComponent = ({ ...options, tagName: finalTagName }); + const { isStencilProp, attributeNameFor } = createPropResolver(options.elementClass, properties); /** * Withhold `className`/`class` from `@lit/react` so React never writes the @@ -186,7 +207,7 @@ export const createComponent = < const elementRef = React.useRef(null); const previousClassName = React.useRef(''); - useIsomorphicLayoutEffect(() => { + React.useLayoutEffect(() => { const element = elementRef.current; if (element === null || incomingClassName === previousClassName.current) { return; @@ -195,6 +216,24 @@ export const createComponent = < previousClassName.current = incomingClassName; }); + // Keep the server-rendered Stencil attributes in sync so none outlives its prop. `@lit/react` + // only sets properties, and Stencil does not reflect them back unless the prop opts in. + React.useLayoutEffect(() => { + const element = elementRef.current; + if (element === null) return; + for (const [key, value] of Object.entries(restProps)) { + if (!isStencilProp(key)) continue; + const attributeName = attributeNameFor(key); + if (!attributeName) continue; + const attributeValue = toAttributeValue(value); + if (attributeValue === undefined) { + if (element.hasAttribute(attributeName)) element.removeAttribute(attributeName); + } else if (element.getAttribute(attributeName) !== attributeValue) { + element.setAttribute(attributeName, attributeValue); + } + } + }); + const setRef = React.useCallback( (node: I | null) => { elementRef.current = node; @@ -207,7 +246,8 @@ export const createComponent = < [ref] ); - // Server HTML carries attributes this render does not set. + // Server HTML carries attributes this render does not set. `@lit/react` sets the same flag in + // its browser build only; its node build (jsdom, SSR layers) does not. return React.createElement(ReactComponent, { ...restProps, ref: setRef, suppressHydrationWarning: true } as any); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f8c15624..59a31963 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -872,6 +872,9 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.3(@types/react@19.2.17) + jsdom: + specifier: ^26.1.0 + version: 26.1.0 npm-run-all2: specifier: ^6.2.6 version: 6.2.6 From e59fa9a4264cd2f4f54cc851ee68c9285ced0423 Mon Sep 17 00:00:00 2001 From: Gyles Fohl Date: Mon, 12 Oct 2026 15:39:37 -0500 Subject: [PATCH 6/7] fix(react): only sync attributes the server rendered The client layout effect updated or added an attribute for every Stencil prop, so elements React created on the client gained host attributes they never had. Limit it to attributes already present: server-rendered hosts stay in sync with their props, client-rendered hosts stay property-only as before. --- .../runtime/create-component.client.test.tsx | 24 +++++++++++++++---- .../react/src/runtime/create-component.ts | 7 +++--- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/packages/react/src/runtime/create-component.client.test.tsx b/packages/react/src/runtime/create-component.client.test.tsx index e3d16365..83dfa2f5 100644 --- a/packages/react/src/runtime/create-component.client.test.tsx +++ b/packages/react/src/runtime/create-component.client.test.tsx @@ -1,9 +1,11 @@ // @vitest-environment jsdom import React, { act } from 'react'; -import { hydrateRoot } from 'react-dom/client'; +import { createRoot, hydrateRoot } from 'react-dom/client'; import { renderToString } from 'react-dom/server'; import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createComponent } from './create-component.js'; + (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; // Stencil-like element: prop accessors on the prototype, attributes not reflected back. @@ -36,12 +38,13 @@ const options = { // `createComponent` picks the server or client wrapper when it is called, so build the server // one while `window` is absent and the client one with the real jsdom window. +type Props = { children?: React.ReactNode; disabled?: boolean; variant?: string }; + async function serverAndClient() { - const { createComponent } = await import('./create-component'); vi.stubGlobal('window', undefined); - const Server = createComponent(options); + const Server = createComponent(options); vi.unstubAllGlobals(); - const Client = createComponent(options); + const Client = createComponent(options); return { Server, Client }; } @@ -73,4 +76,17 @@ describe('createComponent hydration', () => { expect(element.hasAttribute('disabled')).toBe(false); expect(element.getAttribute('variant')).toBe('solid'); }); + it('does not add attributes to client-rendered elements', async () => { + const { Client } = await serverAndClient(); + const container = document.createElement('div'); + document.body.appendChild(container); + await act(async () => { + createRoot(container).render(React.createElement(Client, { disabled: true, variant: 'outline' }, 'Save')); + }); + const element = container.querySelector('fake-element')!; + // Under the `node` export condition `@lit/react` ships its SSR build, which never assigns + // element properties, so only the attribute behaviour (ours) can be asserted here. + expect(element.hasAttribute('disabled')).toBe(false); + expect(element.hasAttribute('variant')).toBe(false); + }); }); diff --git a/packages/react/src/runtime/create-component.ts b/packages/react/src/runtime/create-component.ts index 2f952cd1..d0e04607 100644 --- a/packages/react/src/runtime/create-component.ts +++ b/packages/react/src/runtime/create-component.ts @@ -217,17 +217,18 @@ export const createComponent = < }); // Keep the server-rendered Stencil attributes in sync so none outlives its prop. `@lit/react` - // only sets properties, and Stencil does not reflect them back unless the prop opts in. + // only sets properties, and Stencil does not reflect them back unless the prop opts in. Only + // attributes already on the element are touched: client-rendered elements stay property-only. React.useLayoutEffect(() => { const element = elementRef.current; if (element === null) return; for (const [key, value] of Object.entries(restProps)) { if (!isStencilProp(key)) continue; const attributeName = attributeNameFor(key); - if (!attributeName) continue; + if (!attributeName || !element.hasAttribute(attributeName)) continue; const attributeValue = toAttributeValue(value); if (attributeValue === undefined) { - if (element.hasAttribute(attributeName)) element.removeAttribute(attributeName); + element.removeAttribute(attributeName); } else if (element.getAttribute(attributeName) !== attributeValue) { element.setAttribute(attributeName, attributeValue); } From b4271d91c8e1d97a3b9d004b62ea363e7d7e051e Mon Sep 17 00:00:00 2001 From: Gyles Fohl Date: Thu, 22 Oct 2026 12:16:50 -0500 Subject: [PATCH 7/7] chore(react): regenerate example wrappers after rebase onto main #847 rewrote the generator with string templates; the client wrapper keeps its properties map, placed after elementClass as before. --- .../component-library-react/src/components.ts | 268 +++++++++--------- .../src/create-stencil-react-components.ts | 2 +- 2 files changed, 140 insertions(+), 130 deletions(-) diff --git a/example-project/component-library-react/src/components.ts b/example-project/component-library-react/src/components.ts index fdcd13b7..06c8cf39 100644 --- a/example-project/component-library-react/src/components.ts +++ b/example-project/component-library-react/src/components.ts @@ -45,19 +45,19 @@ export const MyButton: StencilReactComponent; export const MyComplexProps: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-complex-props', elementClass: MyComplexPropsElement, - properties: { grault: 'grault' }, + properties: { + grault: 'grault' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComplexPropsEvents, @@ -153,7 +155,9 @@ export type MyComplexPropsScopedEvents = NonNullable; export const MyComplexPropsScoped: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-complex-props-scoped', elementClass: MyComplexPropsScopedElement, - properties: { grault: 'grault' }, + properties: { + grault: 'grault' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyComplexPropsScopedEvents, @@ -169,9 +173,9 @@ export const MyComponent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-counter', elementClass: MyCounterElement, - properties: { startValue: 'start-value' }, + properties: { + startValue: 'start-value' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: { @@ -244,32 +250,32 @@ export const MyInput: StencilReactComponent; export const MyToggleContent: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-toggle-content', elementClass: MyToggleContentElement, - properties: { visible: 'visible' }, + properties: { + visible: 'visible' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyToggleContentEvents, @@ -531,7 +539,9 @@ export type MyTransformTestEvents = NonNullable; export const MyTransformTest: StencilReactComponent = /*@__PURE__*/ createComponent({ tagName: 'my-transform-test', elementClass: MyTransformTestElement, - properties: { message: 'message' }, + properties: { + message: 'message' + }, // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project. react: React, events: {} as MyTransformTestEvents, diff --git a/packages/react/src/create-stencil-react-components.ts b/packages/react/src/create-stencil-react-components.ts index 8d43c8ee..9cbf6e3c 100644 --- a/packages/react/src/create-stencil-react-components.ts +++ b/packages/react/src/create-stencil-react-components.ts @@ -175,11 +175,11 @@ export const createStencilReactComponents = ({ `/*@__PURE__*/ createComponent<${componentElement}, ${componentEventNamesType}, Components.${reactTagName}${requiredGeneric}>({`, ` tagName: '${tagName}',`, ` elementClass: ${componentElement},`, + propertiesLine, ` // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.`, ` react: React,`, ` events: ${events.length ? `{\n ${events.map((e) => `${e.name}: '${e.originalName}'`).join(',\n ')}\n }` : '{}'} as ${componentEventNamesType},`, ` defineCustomElement: define${reactTagName},`, - propertiesLine, transformTag && ` transformTag,`, `})`, ]