From e69108328955b14209b73b392828e76af6706357 Mon Sep 17 00:00:00 2001 From: Maxwell Cohen Date: Tue, 19 May 2026 17:06:18 -0500 Subject: [PATCH 1/2] React DOM: Add support for Invoker Commands API --- .../AttributeTableSnapshot.md | 50 +++++++++++++++++++ fixtures/attribute-behavior/src/attributes.js | 15 ++++++ flow-typed/environments/dom.js | 1 + flow-typed/environments/html.js | 17 +++++++ .../src/client/ReactDOMComponent.js | 39 +++++++++++++++ .../src/events/DOMEventNames.js | 1 + .../src/events/DOMEventProperties.js | 1 + .../src/events/DOMPluginEventSystem.js | 1 + .../src/events/ReactDOMEventListener.js | 1 + .../src/events/SyntheticEvent.js | 13 +++++ .../src/events/plugins/SimpleEventPlugin.js | 8 ++- .../src/shared/possibleStandardNames.js | 2 + .../__tests__/DOMPropertyOperations-test.js | 28 +++++++++++ .../__tests__/ReactDOMEventListener-test.js | 27 ++++++++++ .../ReactDOMEventPropagation-test.js | 32 ++++++++++++ .../__tests__/SimpleEventPlugin-test.js | 45 +++++++++++++++++ 16 files changed, 280 insertions(+), 1 deletion(-) diff --git a/fixtures/attribute-behavior/AttributeTableSnapshot.md b/fixtures/attribute-behavior/AttributeTableSnapshot.md index 004a35db8d4..3f210a026ca 100644 --- a/fixtures/attribute-behavior/AttributeTableSnapshot.md +++ b/fixtures/attribute-behavior/AttributeTableSnapshot.md @@ -2023,6 +2023,56 @@ | `colSpan=(null)`| (initial, ssr error, ssr mismatch)| `` | | `colSpan=(undefined)`| (initial, ssr error, ssr mismatch)| `` | +## `commandFor` (on `, + ); + }); + + assertConsoleErrorDev([ + 'The `commandFor` prop expects the ID of an Element as a string. Received HTMLDivElement {} instead.\n' + + ' in button (at **)', + ]); + + // Dedupe warning + await act(() => { + root.render( + , + ); + }); + }); }); describe('deleteValueForProperty', () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js index f6447642bce..70e97fffa70 100644 --- a/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMEventListener-test.js @@ -730,6 +730,33 @@ describe('ReactDOMEventListener', () => { } }); + it('should not emulate bubbling of command events', async () => { + const container = document.createElement('div'); + const ref = React.createRef(); + const onCommand = jest.fn(); + document.body.appendChild(container); + try { + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( +
+ +
, + ); + }); + await act(() => { + ref.current.dispatchEvent( + new Event('command', { + bubbles: false, + }), + ); + }); + expect(onCommand).toHaveBeenCalledTimes(1); + } finally { + document.body.removeChild(container); + } + }); + it('should bubble non-native bubbling cancel/close events', async () => { const container = document.createElement('div'); const ref = React.createRef(); diff --git a/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js b/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js index 752c8bba961..5c1eb739bba 100644 --- a/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMEventPropagation-test.js @@ -1434,6 +1434,38 @@ describe('ReactDOMEventListener', () => { }, }); }); + + it('onCommand', async () => { + await testNonBubblingEvent({ + type: 'img', + reactEvent: 'onCommand', + reactEventType: 'command', + nativeEvent: 'command', + dispatch(node) { + const e = new Event('command', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); + + it('onCommand Invoker Commands API', async () => { + await testNonBubblingEvent({ + type: 'div', + reactEvent: 'onCommand', + reactEventType: 'command', + nativeEvent: 'command', + dispatch(node) { + const e = new Event('command', { + bubbles: false, + cancelable: true, + }); + node.dispatchEvent(e); + }, + }); + }); }); // The tests for these events are currently very limited diff --git a/packages/react-dom/src/events/plugins/__tests__/SimpleEventPlugin-test.js b/packages/react-dom/src/events/plugins/__tests__/SimpleEventPlugin-test.js index f14fcab168b..909b1f251b8 100644 --- a/packages/react-dom/src/events/plugins/__tests__/SimpleEventPlugin-test.js +++ b/packages/react-dom/src/events/plugins/__tests__/SimpleEventPlugin-test.js @@ -18,6 +18,14 @@ class ToggleEvent extends Event { } } +class CommandEvent extends Event { + constructor(type, eventInit) { + super(type, eventInit); + this.command = eventInit.command; + this.source = eventInit.source; + } +} + describe('SimpleEventPlugin', function () { let React; let ReactDOMClient; @@ -590,6 +598,43 @@ describe('SimpleEventPlugin', function () { }), ); }); + + it('dispatches synthetic command events when the Invoker Commands API is used', async () => { + container = document.createElement('div'); + + const onCommand = jest.fn(); + const root = ReactDOMClient.createRoot(container); + await act(() => { + root.render( + <> + + + , + ); + }); + + const target = container.querySelector('#target'); + const source = container.querySelector('button'); + target.dispatchEvent( + new CommandEvent('command', { + bubbles: false, + cancelable: true, + command: '--rotate-left', + source: source, + }), + ); + + expect(onCommand).toHaveBeenCalledTimes(1); + const event = onCommand.mock.calls[0][0]; + expect(event).toEqual( + expect.objectContaining({ + command: '--rotate-left', + source: source, + }), + ); + }); }); it('includes the submitter in submit events', async function () { From ed96b81b9931c795f5ce5f0f6d685bc16589b2b0 Mon Sep 17 00:00:00 2001 From: Maxwell Cohen Date: Wed, 20 May 2026 16:53:03 -0500 Subject: [PATCH 2/2] Enhance DOMPropertyOperations tests for commandFor attribute on button This commit adds a new test case to verify that the commandFor attribute is correctly set on button elements. It ensures that the attribute is rendered as expected in the DOM. Additionally, it modifies the handling of the commandFor property in the ReactDOMComponent to allow for better error handling in development mode. --- .../src/client/ReactDOMComponent.js | 4 ++-- .../src/__tests__/DOMPropertyOperations-test.js | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index b09ab716803..6566d415539 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -965,10 +965,11 @@ function setProp( ); } } - break; + // Fall through case 'commandFor': if (__DEV__) { if ( + key === 'commandFor' && !didWarnCommandForObject && value != null && typeof value === 'object' @@ -980,7 +981,6 @@ function setProp( ); } } - break; // Fall through default: { if ( diff --git a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js index 6ecbdbbb27c..af59553e62c 100644 --- a/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js +++ b/packages/react-dom/src/__tests__/DOMPropertyOperations-test.js @@ -1375,6 +1375,23 @@ describe('DOMPropertyOperations', () => { }); }); + it('sets commandFor attribute on button', async () => { + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + + await act(() => { + root.render( + , + ); + }); + + const button = container.querySelector('button'); + expect(button.getAttribute('commandfor')).toBe('demo-popover'); + expect(button.getAttribute('command')).toBe('show-popover'); + }); + it('warns when using commandFor={HTMLElement}', async () => { const commandTarget = document.createElement('div'); const container = document.createElement('div');