Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions example-project/next-15-runtime-based/test/test.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
5 changes: 5 additions & 0 deletions example-project/next-app/test/test.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
});
});
7 changes: 6 additions & 1 deletion example-project/remix-app/tests/test.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
],
});
});
40 changes: 39 additions & 1 deletion packages/react/src/runtime/create-component.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand All @@ -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');
});
});
96 changes: 90 additions & 6 deletions packages/react/src/runtime/create-component.ts
Original file line number Diff line number Diff line change
@@ -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<string, EventName | string>;
Expand All @@ -21,6 +22,47 @@ export type StencilReactComponent<
R extends keyof C = never,
> = React.FunctionComponent<StencilProps<I, E, C, R>>;

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<string>,
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
Expand All @@ -43,10 +85,52 @@ export const createComponent = <
defineCustomElement();
}
const finalTagName = transformTag ? transformTag(tagName) : tagName;
return createComponentWrapper<I, E>({ ...options, tagName: finalTagName }) as unknown as StencilReactComponent<
I,
E,
C,
R
>;
const ReactComponent = createComponentWrapper<I, E>({ ...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<I, StencilProps<I, E, C, R>>((props, ref) => {
const {
className,
class: classProp,
...restProps
} = props as StencilProps<I, E, C, R> & {
className?: string;
class?: string;
};
const incomingClassName = className ?? classProp ?? '';

const elementRef = React.useRef<I | null>(null);
const previousClassName = React.useRef<string>('');

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<I | null>).current = node;
}
},
[ref]
);

return React.createElement(ReactComponent, { ...restProps, ref: setRef } as any);
});

WrappedComponent.displayName = options.displayName ?? finalTagName;

return WrappedComponent as unknown as StencilReactComponent<I, E, C, R>;
};
16 changes: 16 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

26 changes: 26 additions & 0 deletions tests/react/src/ClassNameMerge.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<MyCheckbox className={applied ? 'ion-invalid ion-touched' : ''}>Checkbox Label</MyCheckbox>
<button className="apply-classes" onClick={() => setApplied(true)}>
Apply validation classes
</button>
<button className="remove-classes" onClick={() => setApplied(false)}>
Remove validation classes
</button>
</>
);
}
6 changes: 6 additions & 0 deletions tests/react/src/TestComponent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<Extract<TestComponent, `${string}shadow`>, 'transform-scoped-to-shadow'>;
Expand Down Expand Up @@ -219,6 +222,9 @@ const TestComponent = ({ name }: TestComponentProps) => {
if (name === 'my-toggle-shadow') {
return <MyToggle>Toggle Content</MyToggle>;
}
if (name === 'classname-merge-shadow') {
return <ClassNameMerge />;
}

return (
<div>
Expand Down
32 changes: 32 additions & 0 deletions tests/react/tests/scenarios/shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,4 +164,36 @@ export const testScenarios: Record<ShadowComponents, () => 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')
})
},
}
Loading