From b05d2dbed84fb886be7b09959844079cc663ffac Mon Sep 17 00:00:00 2001 From: Varun Chawla Date: Sun, 15 Feb 2026 02:12:45 -0800 Subject: [PATCH] [DOM] Treat hidden as an overloaded boolean to support hidden="until-found" The hidden attribute was treated as a plain boolean, which meant any truthy value was coerced to an empty string. This prevented using the hidden="until-found" value that is now supported in all major browsers (Chrome 102+, Firefox 139+, Safari 26.2+). Move hidden from the boolean attribute list to the overloaded boolean list (alongside capture and download), so that: - hidden={true} renders as hidden="" - hidden={false} removes the attribute - hidden="until-found" renders as hidden="until-found" Fixes #24740 --- .../src/client/ReactDOMComponent.js | 4 +- .../src/client/ReactFiberConfigDOM.js | 2 +- .../src/server/ReactFizzConfigDOM.js | 24 ++++-- .../src/shared/ReactDOMUnknownPropertyHook.js | 1 - .../src/__tests__/ReactDOMComponent-test.js | 78 +++++++++++++++++-- ...eactDOMServerIntegrationAttributes-test.js | 61 ++++++++------- 6 files changed, 128 insertions(+), 42 deletions(-) diff --git a/packages/react-dom-bindings/src/client/ReactDOMComponent.js b/packages/react-dom-bindings/src/client/ReactDOMComponent.js index 1b25e3727023..1f7d44821347 100644 --- a/packages/react-dom-bindings/src/client/ReactDOMComponent.js +++ b/packages/react-dom-bindings/src/client/ReactDOMComponent.js @@ -762,7 +762,6 @@ function setProp( case 'disablePictureInPicture': case 'disableRemotePlayback': case 'formNoValidate': - case 'hidden': case 'loop': case 'noModule': case 'noValidate': @@ -782,6 +781,7 @@ function setProp( break; } // Overloaded Boolean + case 'hidden': case 'capture': case 'download': { // An attribute that can be used as a flag as well as with a value. @@ -2855,7 +2855,6 @@ function diffHydratedGenericElement( case 'disablePictureInPicture': case 'disableRemotePlayback': case 'formNoValidate': - case 'hidden': case 'loop': case 'noModule': case 'noValidate': @@ -2878,6 +2877,7 @@ function diffHydratedGenericElement( ); continue; } + case 'hidden': case 'capture': case 'download': { hydrateOverloadedBooleanAttribute( diff --git a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js index 94d37cfc902d..31ee89013156 100644 --- a/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js +++ b/packages/react-dom-bindings/src/client/ReactFiberConfigDOM.js @@ -156,7 +156,7 @@ export type Props = { autoFocus?: boolean, children?: mixed, disabled?: boolean, - hidden?: boolean, + hidden?: boolean | string, suppressHydrationWarning?: boolean, dangerouslySetInnerHTML?: mixed, style?: { diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index a93c32a947f1..b5f73cfefe62 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -1701,7 +1701,6 @@ function pushAttribute( case 'disablePictureInPicture': case 'disableRemotePlayback': case 'formNoValidate': - case 'hidden': case 'loop': case 'noModule': case 'noValidate': @@ -1723,6 +1722,7 @@ function pushAttribute( } return; } + case 'hidden': case 'capture': case 'download': { // Overloaded Boolean @@ -5848,12 +5848,19 @@ function writeStyleResourceAttributeInJS( attributeValue = '' + (value: any); break; } - // Booleans + // Overloaded Booleans case 'hidden': { if (value === false) { return; } - attributeValue = ''; + if (value === true) { + attributeValue = ''; + } else { + if (__DEV__) { + checkAttributeStringCoercion(value, attributeName); + } + attributeValue = '' + (value: any); + } break; } // Santized URLs @@ -6043,12 +6050,19 @@ function writeStyleResourceAttributeInAttr( break; } - // Booleans + // Overloaded Booleans case 'hidden': { if (value === false) { return; } - attributeValue = ''; + if (value === true) { + attributeValue = ''; + } else { + if (__DEV__) { + checkAttributeStringCoercion(value, attributeName); + } + attributeValue = '' + (value: any); + } break; } diff --git a/packages/react-dom-bindings/src/shared/ReactDOMUnknownPropertyHook.js b/packages/react-dom-bindings/src/shared/ReactDOMUnknownPropertyHook.js index f45e477d876f..6a3f1106be2a 100644 --- a/packages/react-dom-bindings/src/shared/ReactDOMUnknownPropertyHook.js +++ b/packages/react-dom-bindings/src/shared/ReactDOMUnknownPropertyHook.js @@ -293,7 +293,6 @@ function validateProperty(tagName, name, value, eventRegistry) { case 'disablePictureInPicture': case 'disableRemotePlayback': case 'formNoValidate': - case 'hidden': case 'loop': case 'noModule': case 'noValidate': diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index 0f0986dde8e3..6581b887ef42 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -3614,16 +3614,18 @@ describe('ReactDOMComponent', () => { const root = ReactDOMClient.createRoot(container); await act(() => { - root.render(