⚠ This page is served via a proxy. Original site: https://github.com
This service does not collect credentials or authentication data.
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
24 changes: 24 additions & 0 deletions packages/react-dom-bindings/src/client/ReactDOMComponent.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment does not bring much additional value. I would still consider keeping #35474 (comment) thread open to hear what maintainers have to say - I only reported the original issue #35446 but I haven't dug deeper in React's inner functionality here.

}

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<ce-event-test onmy-event={myEventHandler} />,
);

// 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,
<ce-event-test onmy-event={myEventHandler} />,
);
});

// 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(
<ce-multi-event
onmodule-loaded={moduleLoadedHandler}
onmodule-error={moduleErrorHandler}
onmodule-updated={moduleUpdatedHandler}
/>,
);

container.innerHTML = serverHTML;
const customElement = container.querySelector('ce-multi-event');

// Hydrate with event handlers
await act(async () => {
ReactDOMClient.hydrateRoot(
container,
<ce-multi-event
onmodule-loaded={moduleLoadedHandler}
onmodule-error={moduleErrorHandler}
onmodule-updated={moduleUpdatedHandler}
/>,
);
});

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(
<ce-primitive-props
stringValue="test"
numberValue={42}
trueProp={true}
/>,
);

container.innerHTML = serverHTML;
const customElement = container.querySelector('ce-primitive-props');

// Hydrate
await act(async () => {
ReactDOMClient.hydrateRoot(
container,
<ce-primitive-props
stringValue="test"
numberValue={42}
trueProp={true}
/>,
);
});

// 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(
<ce-advanced-props
objectProp={{key: 'value'}}
functionProp={() => {}}
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(
<ce-update-test onmyevent={initialHandler} />,
);

container.innerHTML = serverHTML;
const customElement = container.querySelector('ce-update-test');

// Hydrate
let root;
await act(async () => {
root = ReactDOMClient.hydrateRoot(
container,
<ce-update-test onmyevent={initialHandler} />,
);
});

customElement.dispatchEvent(new CustomEvent('myevent'));
expect(initialHandler).toHaveBeenCalledTimes(1);

// Update the event handler
await act(async () => {
root.render(<ce-update-test onmyevent={updatedHandler} />);
});

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(
<ce-registered-after-hydration onmy-event={myEventHandler} />,
);

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,
<ce-registered-after-hydration onmy-event={myEventHandler} />,
);
});

// 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(
<ce-mixed-props
stringAttr="value"
numberAttr={123}
onmyevent={myEventHandler}
className="custom-class"
/>,
);

container.innerHTML = serverHTML;
const customElement = container.querySelector('ce-mixed-props');

// Hydrate
await act(async () => {
ReactDOMClient.hydrateRoot(
container,
<ce-mixed-props
stringAttr="value"
numberAttr={123}
onmyevent={myEventHandler}
className="custom-class"
/>,
);
});

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(
<ce-removal-test onmyevent={myEventHandler} />,
);

container.innerHTML = serverHTML;
const customElement = container.querySelector('ce-removal-test');

// Hydrate
let root;
await act(async () => {
root = ReactDOMClient.hydrateRoot(
container,
<ce-removal-test onmyevent={myEventHandler} />,
);
});

// Remove the event handler
await act(async () => {
root.render(<ce-removal-test />);
});

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() {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would appreciate if you can carefully go through all the AI generated code yourself before asking for review. For instance these are not used at all. What is the purpose for these?

return this._internalValue;
}
}
customElements.define('ce-with-property', CustomElementWithProperty);

// Server-side render
const serverHTML = ReactDOMServer.renderToString(
<ce-with-property data-attr="test" />,
);

container.innerHTML = serverHTML;
const customElement = container.querySelector('ce-with-property');

// Hydrate
await act(async () => {
ReactDOMClient.hydrateRoot(
container,
<ce-with-property data-attr="test" />,
);
});

// Verify the element is properly hydrated
expect(customElement.getAttribute('data-attr')).toBe('test');
});
});
});