diff --git a/fixtures/dom/src/components/Header.js b/fixtures/dom/src/components/Header.js index fe9709af0031..9e0c24e41e17 100644 --- a/fixtures/dom/src/components/Header.js +++ b/fixtures/dom/src/components/Header.js @@ -89,6 +89,9 @@ class Header extends React.Component { Selection Events Suspense Form State + + Attribute Stringification + diff --git a/fixtures/dom/src/components/fixtures/attribute-stringification/AttributeStringificationTestCase.js b/fixtures/dom/src/components/fixtures/attribute-stringification/AttributeStringificationTestCase.js new file mode 100644 index 000000000000..a0bffe9864bc --- /dev/null +++ b/fixtures/dom/src/components/fixtures/attribute-stringification/AttributeStringificationTestCase.js @@ -0,0 +1,36 @@ +import Fixture from '../../Fixture'; + +const React = window.React; + +class AttributeStringificationTestCase extends React.Component { + state = { + title: { + prop: 'if you see this, the test failed', + toString: () => 'stringified', + }, + }; + constructor(props) { + super(props); + this.input = React.createRef(); + } + componentDidMount() { + this.setState( + Object.assign(this.state, { + titleRead: this.input.current.getAttribute('title'), + }) + ); + } + + render() { + return ( + + + + Attribute Value: {JSON.stringify(this.state.titleRead)} + + + ); + } +} + +export default AttributeStringificationTestCase; diff --git a/fixtures/dom/src/components/fixtures/attribute-stringification/index.js b/fixtures/dom/src/components/fixtures/attribute-stringification/index.js new file mode 100644 index 000000000000..2a5e5de97834 --- /dev/null +++ b/fixtures/dom/src/components/fixtures/attribute-stringification/index.js @@ -0,0 +1,28 @@ +import FixtureSet from '../../FixtureSet'; +import TestCase from '../../TestCase'; +import AttributeStringificationTestCase from './AttributeStringificationTestCase'; + +const React = window.React; + +function AttributeStringification() { + return ( + + + + The Attribute value displayed below the input field is "stringified". + The value is not "[object]". + + + + + + ); +} + +export default AttributeStringification; diff --git a/fixtures/dom/src/polyfills.js b/fixtures/dom/src/polyfills.js index ed6e08f59beb..52146913f924 100644 --- a/fixtures/dom/src/polyfills.js +++ b/fixtures/dom/src/polyfills.js @@ -2,6 +2,7 @@ import 'core-js/es6/symbol'; import 'core-js/es6/promise'; import 'core-js/es6/set'; import 'core-js/es6/map'; +import 'core-js/features/array/fill'; // http://paulirish.com/2011/requestanimationframe-for-smart-animating/ // http://my.opera.com/emoller/blog/2011/12/20/requestanimationframe-for-smart-er-animating diff --git a/packages/react-dom/src/client/DOMPropertyOperations.js b/packages/react-dom/src/client/DOMPropertyOperations.js index 2dcb1ace8ab9..cafd72e0c3b8 100644 --- a/packages/react-dom/src/client/DOMPropertyOperations.js +++ b/packages/react-dom/src/client/DOMPropertyOperations.js @@ -16,14 +16,32 @@ import { OVERLOADED_BOOLEAN, } from '../shared/DOMProperty'; import sanitizeURL from '../shared/sanitizeURL'; -import { - disableJavaScriptURLs, - enableTrustedTypesIntegration, -} from 'shared/ReactFeatureFlags'; +import {disableJavaScriptURLs} from 'shared/ReactFeatureFlags'; import {isOpaqueHydratingObject} from './ReactDOMHostConfig'; import type {PropertyInfo} from '../shared/DOMProperty'; +/** + * Cached result of detectStringification() function. + * Should become true in all environments but IE<=9. + */ +let setAttributeCanStringify = undefined; + +/** + * Detect if Element.setAttribute stringifies attribute values. + * Should return true for all environments but IE <= 9. + * @param {DOMElement} node + */ +function detectStringification(node: Element) { + const obj: any = { + toString: () => 'foo', + }; + const attrName = 'reacttest'; + const el = node.ownerDocument.createElement('p'); + el.setAttribute(attrName, obj); + return el.getAttribute(attrName) === 'foo'; +} + /** * Get the value for a property on a node. Only used in DEV for SSR validation. * The "expected" argument is used as a hint of what the expected value is. @@ -146,6 +164,9 @@ export function setValueForProperty( if (shouldRemoveAttribute(name, value, propertyInfo, isCustomComponentTag)) { value = null; } + if (setAttributeCanStringify === undefined) { + setAttributeCanStringify = detectStringification(node); + } // If the prop isn't in the special list, treat it as a simple attribute. if (isCustomComponentTag || propertyInfo === null) { if (isAttributeNameSafe(name)) { @@ -155,7 +176,7 @@ export function setValueForProperty( } else { node.setAttribute( attributeName, - enableTrustedTypesIntegration ? (value: any) : '' + (value: any), + setAttributeCanStringify ? (value: any) : '' + (value: any), ); } } @@ -186,15 +207,15 @@ export function setValueForProperty( // and we won't require Trusted Type here. attributeValue = ''; } else { - // `setAttribute` with objects becomes only `[object]` in IE8/9, - // ('' + value) makes it output the correct toString()-value. - if (enableTrustedTypesIntegration) { + if (setAttributeCanStringify) { attributeValue = (value: any); } else { + // As `setAttribute` does not stringify the value itself. + // ('' + value) makes it output the correct toString()-value. attributeValue = '' + (value: any); } if (propertyInfo.sanitizeURL) { - sanitizeURL(attributeValue.toString()); + attributeValue = sanitizeURL(attributeValue); } } if (attributeNamespace) { diff --git a/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js b/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js index f74186f87dc6..aeb7e5fac372 100644 --- a/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js +++ b/packages/react-dom/src/client/__tests__/trustedTypes-test.internal.js @@ -47,6 +47,9 @@ describe('when Trusted Types are available in global object', () => { }; fakeTTObjects.add(ttObject1); fakeTTObjects.add(ttObject2); + // Run setAttributeCanStringify detection first to simplify counting + // setAttribute calls later. + ReactDOM.render(, container); }); afterEach(() => { diff --git a/packages/react-dom/src/shared/sanitizeURL.js b/packages/react-dom/src/shared/sanitizeURL.js index 81d54478a9d7..faa9d9e55e0e 100644 --- a/packages/react-dom/src/shared/sanitizeURL.js +++ b/packages/react-dom/src/shared/sanitizeURL.js @@ -8,7 +8,10 @@ */ import invariant from 'shared/invariant'; -import {disableJavaScriptURLs} from 'shared/ReactFeatureFlags'; +import { + disableJavaScriptURLs, + enableTrustedTypesIntegration, +} from 'shared/ReactFeatureFlags'; // A javascript: URL can contain leading C0 control or \u0020 SPACE, // and any newline or tab are filtered out as if they're not part of the URL. @@ -24,7 +27,15 @@ const isJavaScriptProtocol = /^[\u0000-\u001F ]*j[\r\n\t]*a[\r\n\t]*v[\r\n\t]*a[ let didWarn = false; -function sanitizeURL(url: string) { +function sanitizeURL(url: any): any { + if ( + !enableTrustedTypesIntegration || + typeof trustedTypes === 'undefined' || + !trustedTypes.isScriptURL(url) + ) { + // Coerce to a string, unless we know it's an immutable TrustedScriptURL object. + url = '' + url; + } if (disableJavaScriptURLs) { invariant( !isJavaScriptProtocol.test(url), @@ -41,6 +52,7 @@ function sanitizeURL(url: string) { ); } } + return url; } export default sanitizeURL;
Attribute Value: {JSON.stringify(this.state.titleRead)}