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'); + }); + }); +});