diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js
index 1b25e372702..d930883efd2 100644
--- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js
+++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js
@@ -3210,6 +3210,30 @@ export function hydrateProperties(
break;
}
+ // Custom elements need their props (including event handlers) re-applied
+ // during hydration because the server markup cannot capture property-based
+ // listeners. Mirror the client mount path used in setInitialProperties.
+ if (isCustomElement(tag, props)) {
+ for (const propKey in props) {
+ if (!props.hasOwnProperty(propKey)) {
+ continue;
+ }
+ const propValue = props[propKey];
+ if (propValue === undefined) {
+ continue;
+ }
+ setPropOnCustomElement(
+ domElement,
+ tag,
+ propKey,
+ propValue,
+ props,
+ undefined,
+ );
+ }
+ // Don't return early - let it continue to text content validation below
+ }
+
const children = props.children;
// For text content children we compare against textContent. This
// might match additional HTML that is hidden when we read it using
diff --git a/packages/react-dom/src/__tests__/ReactDOMCustomElementHydration-test.js b/packages/react-dom/src/__tests__/ReactDOMCustomElementHydration-test.js
new file mode 100644
index 00000000000..f02428ef35f
--- /dev/null
+++ b/packages/react-dom/src/__tests__/ReactDOMCustomElementHydration-test.js
@@ -0,0 +1,363 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ * @emails react-core
+ */
+
+'use strict';
+
+describe('ReactDOMCustomElementHydration', () => {
+ let React;
+ let ReactDOM;
+ let ReactDOMClient;
+ let ReactDOMServer;
+ let act;
+
+ beforeEach(() => {
+ jest.resetModules();
+ React = require('react');
+ ReactDOM = require('react-dom');
+ ReactDOMClient = require('react-dom/client');
+ ReactDOMServer = require('react-dom/server');
+ act = require('internal-test-utils').act;
+ });
+
+ afterEach(() => {
+ jest.restoreAllMocks();
+ });
+
+ describe('custom element event listener hydration', () => {
+ it('should attach custom element event listeners during hydration', async () => {
+ const container = document.createElement('div');
+ const myEventHandler = jest.fn();
+
+ // Mock custom element class
+ class CustomElement extends HTMLElement {}
+ customElements.define('ce-event-test', CustomElement);
+
+ // Server-side render
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ // Inject markup
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector('ce-event-test');
+
+ // Try to dispatch custom event before hydration (should not fire)
+ customElement.dispatchEvent(new CustomEvent('my-event'));
+ expect(myEventHandler).not.toHaveBeenCalled();
+
+ // Hydrate with event handler
+ await act(async () => {
+ ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ // Dispatch event after hydration
+ customElement.dispatchEvent(new CustomEvent('my-event'));
+
+ // Event handler should be attached during hydration
+ expect(myEventHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('should attach multiple custom event listeners during hydration', async () => {
+ const container = document.createElement('div');
+ const moduleLoadedHandler = jest.fn();
+ const moduleErrorHandler = jest.fn();
+ const moduleUpdatedHandler = jest.fn();
+
+ class CustomElement extends HTMLElement {}
+ customElements.define('ce-multi-event', CustomElement);
+
+ // Server-side render
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector('ce-multi-event');
+
+ // Hydrate with event handlers
+ await act(async () => {
+ ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ customElement.dispatchEvent(new CustomEvent('module-loaded'));
+ customElement.dispatchEvent(new CustomEvent('module-error'));
+ customElement.dispatchEvent(new CustomEvent('module-updated'));
+
+ expect(moduleLoadedHandler).toHaveBeenCalledTimes(1);
+ expect(moduleErrorHandler).toHaveBeenCalledTimes(1);
+ expect(moduleUpdatedHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('should hydrate primitive prop types on custom elements', async () => {
+ const container = document.createElement('div');
+
+ class CustomElementWithProps extends HTMLElement {}
+ customElements.define('ce-primitive-props', CustomElementWithProps);
+
+ // Server-side render with primitive props
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector('ce-primitive-props');
+
+ // Hydrate
+ await act(async () => {
+ ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ // After hydration, primitive attributes should be present
+ expect(customElement.hasAttribute('stringValue')).toBe(true);
+ expect(customElement.getAttribute('stringValue')).toBe('test');
+ expect(customElement.hasAttribute('numberValue')).toBe(true);
+ expect(customElement.getAttribute('numberValue')).toBe('42');
+ expect(customElement.hasAttribute('trueProp')).toBe(true);
+ });
+
+ it('should not set non-primitive props as attributes during SSR', async () => {
+ // Server-side render with non-primitive props
+ const serverHTML = ReactDOMServer.renderToString(
+ {}}
+ falseProp={false}
+ />,
+ );
+
+ // Non-primitive values should not appear as attributes in server HTML
+ expect(serverHTML).not.toContain('objectProp');
+ expect(serverHTML).not.toContain('functionProp');
+ expect(serverHTML).not.toContain('falseProp');
+ });
+
+ it('should handle updating custom element event listeners after hydration', async () => {
+ const container = document.createElement('div');
+ const initialHandler = jest.fn();
+ const updatedHandler = jest.fn();
+
+ class CustomElementForUpdate extends HTMLElement {}
+ customElements.define('ce-update-test', CustomElementForUpdate);
+
+ // Server-side render with initial event handler
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector('ce-update-test');
+
+ // Hydrate
+ let root;
+ await act(async () => {
+ root = ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ customElement.dispatchEvent(new CustomEvent('myevent'));
+ expect(initialHandler).toHaveBeenCalledTimes(1);
+
+ // Update the event handler
+ await act(async () => {
+ root.render();
+ });
+
+ customElement.dispatchEvent(new CustomEvent('myevent'));
+
+ // The updated handler should be called, not the initial one
+ expect(updatedHandler).toHaveBeenCalledTimes(1);
+ expect(initialHandler).toHaveBeenCalledTimes(1); // Still only called once
+ });
+
+ it('should handle custom element registered after hydration', async () => {
+ const container = document.createElement('div');
+ const myEventHandler = jest.fn();
+
+ // Server-side render with event handler for element not yet registered
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector(
+ 'ce-registered-after-hydration',
+ );
+
+ // Hydrate - the element is not yet registered
+ // Event listeners should still be attached
+ await act(async () => {
+ ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ // Register the element after hydration
+ class CustomElementRegisteredAfterHydration extends HTMLElement {}
+ customElements.define(
+ 'ce-registered-after-hydration',
+ CustomElementRegisteredAfterHydration,
+ );
+
+ // Dispatch event after registration
+ customElement.dispatchEvent(new CustomEvent('my-event'));
+
+ // Event listener should work even if element wasn't registered during hydration
+ expect(myEventHandler).toHaveBeenCalledTimes(1);
+ });
+
+ it('should properly hydrate custom elements with mixed props', async () => {
+ const container = document.createElement('div');
+ const myEventHandler = jest.fn();
+
+ class MixedPropsElement extends HTMLElement {}
+ customElements.define('ce-mixed-props', MixedPropsElement);
+
+ // Server-side render with mixed prop types
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector('ce-mixed-props');
+
+ // Hydrate
+ await act(async () => {
+ ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ customElement.dispatchEvent(new CustomEvent('myevent'));
+
+ // Event should be fired
+ expect(myEventHandler).toHaveBeenCalledTimes(1);
+ // Attributes should be present
+ expect(customElement.hasAttribute('stringAttr')).toBe(true);
+ expect(customElement.getAttribute('stringAttr')).toBe('value');
+ expect(customElement.hasAttribute('class')).toBe(true);
+ expect(customElement.getAttribute('class')).toBe('custom-class');
+ });
+
+ it('should remove custom element event listeners when prop is removed', async () => {
+ const container = document.createElement('div');
+ const myEventHandler = jest.fn();
+
+ class CustomElementRemovalTest extends HTMLElement {}
+ customElements.define('ce-removal-test', CustomElementRemovalTest);
+
+ // Server-side render with event handler
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector('ce-removal-test');
+
+ // Hydrate
+ let root;
+ await act(async () => {
+ root = ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ // Remove the event handler
+ await act(async () => {
+ root.render();
+ });
+
+ customElement.dispatchEvent(new CustomEvent('myevent'));
+
+ // Event should not fire after handler removal
+ expect(myEventHandler).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('custom element property hydration', () => {
+ it('should handle custom properties during hydration when element is defined', async () => {
+ const container = document.createElement('div');
+
+ // Create and register a custom element with custom properties
+ class CustomElementWithProperty extends HTMLElement {
+ constructor() {
+ super();
+ this._internalValue = undefined;
+ }
+
+ set customProperty(value) {
+ this._internalValue = value;
+ }
+
+ get customProperty() {
+ return this._internalValue;
+ }
+ }
+ customElements.define('ce-with-property', CustomElementWithProperty);
+
+ // Server-side render
+ const serverHTML = ReactDOMServer.renderToString(
+ ,
+ );
+
+ container.innerHTML = serverHTML;
+ const customElement = container.querySelector('ce-with-property');
+
+ // Hydrate
+ await act(async () => {
+ ReactDOMClient.hydrateRoot(
+ container,
+ ,
+ );
+ });
+
+ // Verify the element is properly hydrated
+ expect(customElement.getAttribute('data-attr')).toBe('test');
+ });
+ });
+});