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
99 changes: 99 additions & 0 deletions packages/react/src/runtime/create-component.jsdom.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// @vitest-environment jsdom

import React, { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { afterEach, beforeAll, describe, expect, it } from 'vitest';

import { createComponent } from './create-component';

const roots: Root[] = [];

beforeAll(() => {
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
});

afterEach(async () => {
for (const root of roots.splice(0)) {
await act(async () => {
root.unmount();
});
}
document.body.innerHTML = '';
});

describe('createComponent (jsdom)', () => {
it('forwards native props such as id to the rendered custom element', async () => {
const tagName = 'x-runtime-id-forwarding-test';

class RuntimeIdForwardingElement extends HTMLElement {}

const Wrapped = createComponent<RuntimeIdForwardingElement>({
defineCustomElement: () => {
if (!customElements.get(tagName)) {
customElements.define(tagName, RuntimeIdForwardingElement);
}
},
tagName,
elementClass: RuntimeIdForwardingElement,
react: React,
events: {},
displayName: 'RuntimeIdForwardingElement',
});

const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
roots.push(root);

await act(async () => {
root.render(React.createElement(Wrapped, { id: 'hello-button' }));
});

const element = container.querySelector(tagName);
expect(element).not.toBeNull();
expect((element as HTMLElement).id).toBe('hello-button');
});

it('forwards custom element prototype properties including object values', async () => {
const tagName = 'x-runtime-prop-forwarding-test';
const payload = { nested: { value: 7 } };

class RuntimePropForwardingElement extends HTMLElement {
private _payload: unknown;

public get payload() {
return this._payload;
}

public set payload(value: unknown) {
this._payload = value;
}
}

const Wrapped = createComponent<RuntimePropForwardingElement>({
defineCustomElement: () => {
if (!customElements.get(tagName)) {
customElements.define(tagName, RuntimePropForwardingElement);
}
},
tagName,
elementClass: RuntimePropForwardingElement,
react: React,
events: {},
displayName: 'RuntimePropForwardingElement',
});

const container = document.createElement('div');
document.body.append(container);
const root = createRoot(container);
roots.push(root);

await act(async () => {
root.render(React.createElement(Wrapped, { payload } as any));
});

const element = container.querySelector(tagName) as RuntimePropForwardingElement | null;
expect(element).not.toBeNull();
expect(element?.payload).toBe(payload);
});
});
154 changes: 152 additions & 2 deletions packages/react/src/runtime/create-component.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import type { EventName, Options } from '@lit/react';
import { createComponent as createComponentWrapper } from '@lit/react';

// A key value map matching React prop names to event names.
type EventNames = Record<string, EventName | string>;
Expand All @@ -19,6 +18,157 @@ export type StencilReactComponent<
Props = {},
> = React.FunctionComponent<StencilProps<Element, Events, Props>>;

// Props derived from custom element class.
type ElementProps<I> = Partial<Omit<I, keyof HTMLElement>>;

// Event listener props for mapped custom events.
type EventListeners<R extends EventNames> = {
[K in keyof R]?: R[K] extends EventName<infer T> ? (e: T) => void : (e: Event) => void;
};

// Runtime props accepted by the generated wrapper component.
type ComponentProps<I, E extends EventNames = {}> = Omit<React.HTMLAttributes<I>, keyof E | keyof ElementProps<I>> &
EventListeners<E> &
ElementProps<I>;

const reservedReactProperties = new Set(['children', 'localName', 'ref', 'style', 'className']);
const listenedEvents = new WeakMap<Element, Map<string, EventListenerObject>>();

const addOrUpdateEventListener = (node: Element, event: string, listener: (event?: Event) => void) => {
let events = listenedEvents.get(node);
if (events === undefined) {
listenedEvents.set(node, (events = new Map()));
}

let handler = events.get(event);
if (listener !== undefined) {
if (handler === undefined) {
events.set(event, (handler = { handleEvent: listener }));
node.addEventListener(event, handler);
} else {
handler.handleEvent = listener;
}
} else if (handler !== undefined) {
events.delete(event);
node.removeEventListener(event, handler);
}
};

const setProperty = <E extends Element>(node: E, name: string, value: unknown, old: unknown, events?: EventNames) => {
const event = events?.[name];
if (event !== undefined) {
if (value !== old) {
addOrUpdateEventListener(node, event, value as (e?: Event) => void);
}
return;
}

node[name as keyof E] = value as E[keyof E];

if ((value === undefined || value === null) && name in HTMLElement.prototype) {
node.removeAttribute(name);
}
};

/**
* Temporary compatibility shim.
*
* Why this exists:
* - In Vitest/jsdom, package export resolution can select @lit/react's `node` entry.
* - That entry intentionally skips browser-side element prop application.
* - Stencil React wrappers then drop element/native props (e.g. `id`) in tests.
*
* What this is:
* - A local copy of @lit/react browser create-component behavior (verified against v1.0.8)
* so runtime behavior is deterministic for consumers.
*
* Removal criteria:
* - Remove this shim and delegate to @lit/react once @lit/react provides a stable path that
* preserves browser prop forwarding in Vitest/jsdom-like environments.
*
* Tracking:
* - stenciljs/output-targets#791
*/
const createComponentCompatibilityShim = <I extends HTMLElement, E extends EventNames = {}>({
react: React,
tagName,
elementClass,
events,
displayName,
}: Options<I, E>) => {
const eventProps = new Set(Object.keys(events ?? {}));

const ReactComponent = React.forwardRef<I, ComponentProps<I, E>>((props, ref) => {
const prevElemPropsRef = React.useRef(new Map<string, unknown>());
const elementRef = React.useRef<I | null>(null);

const reactProps: Record<string, unknown> = {};
const elementProps: Record<string, unknown> = {};

for (const [key, value] of Object.entries(props)) {
if (reservedReactProperties.has(key)) {
reactProps[key === 'className' ? 'class' : key] = value;
continue;
}

if (eventProps.has(key) || key in elementClass.prototype) {
elementProps[key] = value;
continue;
}

reactProps[key] = value;
}

React.useLayoutEffect(() => {
if (elementRef.current === null) {
return;
}

const newElemProps = new Map<string, unknown>();
for (const key in elementProps) {
setProperty(
elementRef.current,
key,
(props as unknown as Record<string, unknown>)[key],
prevElemPropsRef.current.get(key),
events
);
prevElemPropsRef.current.delete(key);
newElemProps.set(key, (props as unknown as Record<string, unknown>)[key]);
}

for (const [key, value] of prevElemPropsRef.current) {
setProperty(elementRef.current, key, undefined, value, events);
}
prevElemPropsRef.current = newElemProps;
});

React.useLayoutEffect(() => {
elementRef.current?.removeAttribute('defer-hydration');
}, []);

reactProps['suppressHydrationWarning'] = true;

return React.createElement(tagName, {
...reactProps,
ref: React.useCallback(
(node: I | null) => {
elementRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref !== null) {
(ref as React.MutableRefObject<I | null>).current = node;
}
},
[ref]
),
});
});

ReactComponent.displayName = displayName ?? elementClass.name;
return ReactComponent;
};

/**
* Defines a custom element and creates a React component.
* @public
Expand All @@ -36,7 +186,7 @@ export const createComponent = <Element extends HTMLElement, Events extends Even
defineCustomElement();
}
const finalTagName = transformTag ? transformTag(tagName) : tagName;
return createComponentWrapper<Element, Events>({
return createComponentCompatibilityShim<Element, Events>({
...options,
tagName: finalTagName,
}) as unknown as StencilReactComponent<Element, Events, Props>;
Expand Down
Loading