diff --git a/example-project/next-15-runtime-based/test/test.e2e.ts b/example-project/next-15-runtime-based/test/test.e2e.ts index 2accf7a2..e96e83f0 100644 --- a/example-project/next-15-runtime-based/test/test.e2e.ts +++ b/example-project/next-15-runtime-based/test/test.e2e.ts @@ -12,6 +12,8 @@ describe('Next 15 React 19 SSR Integration', () => { 'style-no-deduplication-scoped', // Known issue: aria-checked and label placement are undefined on hydration. 'checkbox-shadow', + // Uses my-checkbox, hits the same hydration issue as checkbox-shadow above. + 'classname-merge-shadow', /** * Known hydration mismatch errors */ diff --git a/example-project/next-app/test/test.e2e.ts b/example-project/next-app/test/test.e2e.ts index 58f5973b..2f419271 100644 --- a/example-project/next-app/test/test.e2e.ts +++ b/example-project/next-app/test/test.e2e.ts @@ -19,6 +19,11 @@ describe('Next.js 14 React 18 SSR Integration', () => { 'complex-props-scoped', 'complex-props-shadow', 'my-list-shadow', + /** + * Relies on a runtime re-render (button click updates className), which + * doesn't fire here for the same reason as input-scoped above. + */ + 'classname-merge-shadow', ], }); }); diff --git a/example-project/remix-app/tests/test.e2e.ts b/example-project/remix-app/tests/test.e2e.ts index adae273e..a4ff0ac4 100644 --- a/example-project/remix-app/tests/test.e2e.ts +++ b/example-project/remix-app/tests/test.e2e.ts @@ -9,7 +9,12 @@ describe('Remix SSR Integration', () => { /** * Known issue: updating a prop does not trigger a rerender. */ - 'prop-update-shadow' + 'prop-update-shadow', + /** + * Relies on a runtime re-render (button click updates className), which + * doesn't trigger here for the same reason as prop-update-shadow above. + */ + 'classname-merge-shadow', ], }); }); diff --git a/packages/react/src/runtime/create-component.test.ts b/packages/react/src/runtime/create-component.test.ts index 15094175..9101cbd6 100644 --- a/packages/react/src/runtime/create-component.test.ts +++ b/packages/react/src/runtime/create-component.test.ts @@ -1,7 +1,7 @@ import React from 'react'; import { vi, describe, it, expect } from 'vitest'; -import { createComponent } from './create-component'; +import { createComponent, mergeClassNames } from './create-component'; describe('createComponent', () => { it('should call defineCustomElement if it is defined', () => { @@ -19,3 +19,41 @@ describe('createComponent', () => { expect(defineCustomElement).toHaveBeenCalled(); }); }); + +describe('mergeClassNames', () => { + it('keeps runtime-managed classes and appends new app classes', () => { + const merged = mergeClassNames(['md', 'interactive', 'hydrated'], 'ion-invalid ion-touched', ''); + expect(merged).toBe('md interactive hydrated ion-invalid ion-touched'); + }); + + it('adds app classes on first render when there was no previous value', () => { + expect(mergeClassNames(['sc-my-input-h', 'hydrated'], 'ion-valid', undefined)).toBe( + 'sc-my-input-h hydrated ion-valid' + ); + }); + + it('removes only the app classes that were dropped since the previous render', () => { + const merged = mergeClassNames( + ['md', 'hydrated', 'ion-invalid', 'ion-touched'], + 'ion-invalid', + 'ion-invalid ion-touched' + ); + expect(merged).toBe('md hydrated ion-invalid'); + }); + + it('removes all app classes when className is cleared but keeps runtime classes', () => { + expect(mergeClassNames(['md', 'hydrated', 'ion-invalid'], '', 'ion-invalid')).toBe('md hydrated'); + }); + + it('does not duplicate a class the app sets that is already on the element', () => { + expect(mergeClassNames(['foo', 'hydrated'], 'foo', '')).toBe('foo hydrated'); + }); + + it('normalizes extra whitespace in the incoming class value', () => { + expect(mergeClassNames(['hydrated'], ' ion-invalid ion-touched ', '')).toBe('hydrated ion-invalid ion-touched'); + }); + + it('preserves runtime classes when the app provides no className', () => { + expect(mergeClassNames(['md', 'hydrated'], '', '')).toBe('md hydrated'); + }); +}); diff --git a/packages/react/src/runtime/create-component.ts b/packages/react/src/runtime/create-component.ts index c4f57683..d1b28e3f 100644 --- a/packages/react/src/runtime/create-component.ts +++ b/packages/react/src/runtime/create-component.ts @@ -1,5 +1,6 @@ import type { EventName, Options } from '@lit/react'; import { createComponent as createComponentWrapper } from '@lit/react'; +import React from 'react'; // A key value map matching React prop names to event names. type EventNames = Record; @@ -21,6 +22,47 @@ export type StencilReactComponent< R extends keyof C = never, > = React.FunctionComponent>; +const splitClassName = (className: string | undefined): string[] => + className ? className.split(/\s+/).filter(Boolean) : []; + +/** + * Merge the app's `className`/`class` with the classes the Stencil runtime + * manages on the host. `@lit/react` maps `className` to `class` and rewrites it + * wholesale on every render, which wipes runtime-added classes like `hydrated`, + * `sc-*` scope classes, and state classes a design system relies on. + */ +export const mergeClassNames = ( + currentClasses: Iterable, + newClassName: string | undefined, + oldClassName: string | undefined +): string => { + const incoming = new Set(splitClassName(newClassName)); + const previous = new Set(splitClassName(oldClassName)); + const finalClassNames: string[] = []; + + for (const className of currentClasses) { + if (incoming.has(className)) { + finalClassNames.push(className); + incoming.delete(className); + } else if (!previous.has(className)) { + // Runtime-managed class the app never set, so keep it. + finalClassNames.push(className); + } + // Anything else was set by the app last render and dropped now, so drop it. + } + + // Whatever's left in `incoming` isn't on the element yet. + for (const className of incoming) { + finalClassNames.push(className); + } + + 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; + /** * Defines a custom element and creates a React component. * @public @@ -43,10 +85,52 @@ export const createComponent = < defineCustomElement(); } const finalTagName = transformTag ? transformTag(tagName) : tagName; - return createComponentWrapper({ ...options, tagName: finalTagName }) as unknown as StencilReactComponent< - I, - E, - C, - R - >; + const ReactComponent = createComponentWrapper({ ...options, tagName: finalTagName }); + + /** + * Withhold `className`/`class` from `@lit/react` so React never writes the + * `class` attribute, then reconcile it against the live host in a layout + * effect. See `mergeClassNames`. + */ + const WrappedComponent = React.forwardRef>((props, ref) => { + const { + className, + class: classProp, + ...restProps + } = props as StencilProps & { + className?: string; + class?: string; + }; + const incomingClassName = className ?? classProp ?? ''; + + const elementRef = React.useRef(null); + const previousClassName = React.useRef(''); + + useIsomorphicLayoutEffect(() => { + const element = elementRef.current; + if (element === null || incomingClassName === previousClassName.current) { + return; + } + element.className = mergeClassNames(Array.from(element.classList), incomingClassName, previousClassName.current); + previousClassName.current = incomingClassName; + }); + + const setRef = React.useCallback( + (node: I | null) => { + elementRef.current = node; + if (typeof ref === 'function') { + ref(node); + } else if (ref !== null && ref !== undefined) { + (ref as React.MutableRefObject).current = node; + } + }, + [ref] + ); + + return React.createElement(ReactComponent, { ...restProps, ref: setRef } as any); + }); + + WrappedComponent.displayName = options.displayName ?? finalTagName; + + return WrappedComponent as unknown as StencilReactComponent; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4b97c75e..ec0896e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -170,6 +170,21 @@ importers: specifier: ^2.3.0 version: 2.8.1 + example-project/component-library-angular/projects/library/dist: + dependencies: + '@angular/common': + specifier: ^20.0.0 + version: 20.3.25(@angular/core@20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2) + '@angular/core': + specifier: ^20.0.0 + version: 20.3.25(@angular/compiler@20.3.25)(rxjs@7.8.2)(zone.js@0.15.1) + component-library: + specifier: workspace:* + version: link:../../../../component-library + tslib: + specifier: ^2.3.0 + version: 2.8.1 + example-project/component-library-react: dependencies: '@stencil/react-output-target': @@ -1233,6 +1248,7 @@ packages: '@angular/platform-browser-dynamic@20.3.25': resolution: {integrity: sha512-3Ku+IsN4tQPVBsw75SoLbLf7TsXAGL0rGPHSsyNYFhG2ZZeQuYNIAi8mc4cwz/qMDnuassHFrCxuLDgN6Yab5w==} engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0} + deprecated: '@angular/platform-browser-dynamic is deprecated. Use `@angular/platform-browser` instead.' peerDependencies: '@angular/common': 20.3.25 '@angular/compiler': 20.3.25 diff --git a/tests/react/src/ClassNameMerge.tsx b/tests/react/src/ClassNameMerge.tsx new file mode 100644 index 00000000..c36eca7f --- /dev/null +++ b/tests/react/src/ClassNameMerge.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { useState } from 'react'; + +import { MyCheckbox } from 'component-library-react'; + +/** + * Regression fixture: setting `className` on a wrapped Stencil component used to + * wipe the classes the runtime manages on the host (`hydrated`, mode, + * `interactive`). Form libraries drive Ionic's validation classes through + * `className`, so the app's classes have to coexist with the runtime's. + */ +export function ClassNameMerge() { + const [applied, setApplied] = useState(false); + return ( + <> + Checkbox Label + + + + ); +} diff --git a/tests/react/src/TestComponent.tsx b/tests/react/src/TestComponent.tsx index 59112ca1..59390a2a 100644 --- a/tests/react/src/TestComponent.tsx +++ b/tests/react/src/TestComponent.tsx @@ -19,6 +19,7 @@ import { MyRadio, } from 'component-library-react'; import { InputShadow, InputScoped } from './Input'; +import { ClassNameMerge } from './ClassNameMerge'; /** * A list of all test components that are available to be @@ -55,6 +56,8 @@ const testComponents = [ 'my-list-shadow', 'my-range-shadow', 'my-toggle-shadow', + // className merge regression test + 'classname-merge-shadow', ] as const; export type TestComponent = (typeof testComponents)[number]; export type ShadowComponents = Exclude, 'transform-scoped-to-shadow'>; @@ -219,6 +222,9 @@ const TestComponent = ({ name }: TestComponentProps) => { if (name === 'my-toggle-shadow') { return Toggle Content; } + if (name === 'classname-merge-shadow') { + return ; + } return (
diff --git a/tests/react/tests/scenarios/shadow.ts b/tests/react/tests/scenarios/shadow.ts index 008420bc..694f2dd4 100644 --- a/tests/react/tests/scenarios/shadow.ts +++ b/tests/react/tests/scenarios/shadow.ts @@ -164,4 +164,36 @@ export const testScenarios: Record void> = { await expect($('my-toggle')).toBePresent() }) }, + 'classname-merge-shadow': () => { + it('should merge app className with runtime-managed host classes', async () => { + await browser.url('/classname-merge-shadow') + const checkbox = $('my-checkbox') + await expect(checkbox).toBePresent() + + // The Stencil runtime manages these classes on the host. + await expect(checkbox).toHaveElementClass(expect.stringContaining('hydrated')) + await expect(checkbox).toHaveElementClass(expect.stringContaining('interactive')) + + // Apply the validation classes the way a form library would (via className). + await $('.apply-classes').click() + await browser.waitUntil(async () => ((await checkbox.getAttribute('class')) ?? '').includes('ion-invalid')) + + // App-supplied classes coexist with the runtime-managed classes. + const appliedClass = await checkbox.getAttribute('class') + expect(appliedClass).toContain('ion-invalid') + expect(appliedClass).toContain('ion-touched') + expect(appliedClass).toContain('hydrated') + expect(appliedClass).toContain('interactive') + + // Dropping the className removes only the app classes, not the runtime ones. + await $('.remove-classes').click() + await browser.waitUntil(async () => !((await checkbox.getAttribute('class')) ?? '').includes('ion-invalid')) + + const removedClass = await checkbox.getAttribute('class') + expect(removedClass).not.toContain('ion-invalid') + expect(removedClass).not.toContain('ion-touched') + expect(removedClass).toContain('hydrated') + expect(removedClass).toContain('interactive') + }) + }, }