diff --git a/.eslintrc.js b/.eslintrc.js index a155becfafb67..23d5ab76c32bc 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -108,6 +108,10 @@ module.exports = { // CUSTOM RULES // the second argument of warning/invariant should be a literal string 'react-internal/no-primitive-constructors': ERROR, + 'react-internal/safe-string-coercion': [ + ERROR, + {isProductionUserAppCode: true}, + ], 'react-internal/no-to-warn-dev-within-to-throw': ERROR, 'react-internal/invariant-args': ERROR, 'react-internal/warning-args': ERROR, @@ -168,10 +172,17 @@ module.exports = { 'packages/*/npm/**/*.js', 'packages/dom-event-testing-library/**/*.js', 'packages/react-devtools*/**/*.js', + 'dangerfile.js', + 'fixtures', + 'packages/react-dom/src/test-utils/*.js', ], rules: { 'react-internal/no-production-logging': OFF, 'react-internal/warning-args': OFF, + 'react-internal/safe-string-coercion': [ + ERROR, + {isProductionUserAppCode: false}, + ], // Disable accessibility checks 'jsx-a11y/aria-role': OFF, @@ -185,7 +196,7 @@ module.exports = { { files: [ 'scripts/eslint-rules/*.js', - 'packages/eslint-plugin-react-hooks/src/*.js' + 'packages/eslint-plugin-react-hooks/src/*.js', ], plugins: ['eslint-plugin'], rules: { diff --git a/dangerfile.js b/dangerfile.js index e6a6a82c87086..f612c80a7e545 100644 --- a/dangerfile.js +++ b/dangerfile.js @@ -102,8 +102,8 @@ function row(result) { let headSha; let baseSha; try { - headSha = (readFileSync(HEAD_DIR + '/COMMIT_SHA') + '').trim(); - baseSha = (readFileSync(BASE_DIR + '/COMMIT_SHA') + '').trim(); + headSha = String(readFileSync(HEAD_DIR + '/COMMIT_SHA')).trim(); + baseSha = String(readFileSync(BASE_DIR + '/COMMIT_SHA')).trim(); } catch { warn( "Failed to read build artifacts. It's possible a build configuration " + diff --git a/fixtures/dom/src/components/fixtures/error-handling/index.js b/fixtures/dom/src/components/fixtures/error-handling/index.js index 5bbeec7de447e..904bef4788c56 100644 --- a/fixtures/dom/src/components/fixtures/error-handling/index.js +++ b/fixtures/dom/src/components/fixtures/error-handling/index.js @@ -41,7 +41,7 @@ class ErrorBoundary extends React.Component { if (this.state.error) { return

Captured an error: {this.state.error.message}

; } else { - return

Captured an error: {'' + this.state.error}

; + return

Captured an error: {String(this.state.error)}

; } } if (this.state.shouldThrow) { diff --git a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js index b13bec22d213c..013caecb11ba0 100644 --- a/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js +++ b/packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js @@ -615,7 +615,7 @@ describe('ReactHooksInspectionIntegration', () => { expect(tree[0].id).toEqual(0); expect(tree[0].isStateEditable).toEqual(false); expect(tree[0].name).toEqual('OpaqueIdentifier'); - expect((tree[0].value + '').startsWith('c_')).toBe(true); + expect(String(tree[0].value).startsWith('c_')).toBe(true); expect(tree[1]).toEqual({ id: 1, @@ -646,7 +646,7 @@ describe('ReactHooksInspectionIntegration', () => { expect(tree[0].id).toEqual(0); expect(tree[0].isStateEditable).toEqual(false); expect(tree[0].name).toEqual('OpaqueIdentifier'); - expect((tree[0].value + '').startsWith('c_')).toBe(true); + expect(String(tree[0].value).startsWith('c_')).toBe(true); expect(tree[1]).toEqual({ id: 1, diff --git a/packages/react-devtools-extensions/src/main.js b/packages/react-devtools-extensions/src/main.js index 9d5b4da02e409..eb093fc51b785 100644 --- a/packages/react-devtools-extensions/src/main.js +++ b/packages/react-devtools-extensions/src/main.js @@ -94,7 +94,7 @@ function createPanelIfReactLoaded() { function initBridgeAndStore() { const port = chrome.runtime.connect({ - name: '' + tabId, + name: String(tabId), }); // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation, // so it makes no sense to handle it here. diff --git a/packages/react-devtools-shared/src/backend/legacy/renderer.js b/packages/react-devtools-shared/src/backend/legacy/renderer.js index 24f30b87bc78a..1f0aed404087a 100644 --- a/packages/react-devtools-shared/src/backend/legacy/renderer.js +++ b/packages/react-devtools-shared/src/backend/legacy/renderer.js @@ -63,7 +63,7 @@ function getData(internalInstance: InternalInstance) { // != used deliberately here to catch undefined and null if (internalInstance._currentElement != null) { if (internalInstance._currentElement.key) { - key = '' + internalInstance._currentElement.key; + key = String(internalInstance._currentElement.key); } const elementType = internalInstance._currentElement.type; diff --git a/packages/react-devtools-shared/src/backend/renderer.js b/packages/react-devtools-shared/src/backend/renderer.js index 78953e72979a4..24a00583e7e68 100644 --- a/packages/react-devtools-shared/src/backend/renderer.js +++ b/packages/react-devtools-shared/src/backend/renderer.js @@ -1848,7 +1848,7 @@ export function attach( // This check is a guard to handle a React element that has been modified // in such a way as to bypass the default stringification of the "key" property. - const keyString = key === null ? null : '' + key; + const keyString = key === null ? null : String(key); const keyStringID = getStringID(keyString); pushOperation(TREE_OPERATION_ADD); diff --git a/packages/react-devtools-shared/src/backend/utils.js b/packages/react-devtools-shared/src/backend/utils.js index 4b40645595d1c..09f821f4e1877 100644 --- a/packages/react-devtools-shared/src/backend/utils.js +++ b/packages/react-devtools-shared/src/backend/utils.js @@ -166,11 +166,7 @@ export function format( ): string { const args = inputArgs.slice(); - // Symbols cannot be concatenated with Strings. - let formatted: string = - typeof maybeMessage === 'symbol' - ? maybeMessage.toString() - : '' + maybeMessage; + let formatted: string = String(maybeMessage); // If the first argument is a string, check for substitutions. if (typeof maybeMessage === 'string') { @@ -203,17 +199,14 @@ export function format( // Arguments that remain after formatting. if (args.length) { for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - // Symbols cannot be concatenated with Strings. - formatted += ' ' + (typeof arg === 'symbol' ? arg.toString() : arg); + formatted += ' ' + String(args[i]); } } // Update escaped %% values. formatted = formatted.replace(/%{2,2}/g, '%'); - return '' + formatted; + return String(formatted); } export function isSynchronousXHRSupported(): boolean { diff --git a/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js b/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js index c7aa20dcd5902..ea4f54b1a9b8b 100644 --- a/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js +++ b/packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js @@ -46,7 +46,7 @@ export default class ErrorBoundary extends Component { error !== null && error.hasOwnProperty('message') ? error.message - : '' + error; + : String(error); const callStack = typeof error === 'object' && diff --git a/packages/react-devtools-shared/src/devtools/views/utils.js b/packages/react-devtools-shared/src/devtools/views/utils.js index b326996daa05a..035d9f83a3bc5 100644 --- a/packages/react-devtools-shared/src/devtools/views/utils.js +++ b/packages/react-devtools-shared/src/devtools/views/utils.js @@ -19,8 +19,8 @@ export function alphaSortEntries( ): number { const a = entryA[0]; const b = entryB[0]; - if ('' + +a === a) { - if ('' + +b !== b) { + if (String(+a) === a) { + if (String(+b) !== b) { return -1; } return +a < +b ? -1 : 1; diff --git a/packages/react-devtools-shared/src/hook.js b/packages/react-devtools-shared/src/hook.js index 152b1dce8f8e4..6f34f86132ab6 100644 --- a/packages/react-devtools-shared/src/hook.js +++ b/packages/react-devtools-shared/src/hook.js @@ -180,10 +180,7 @@ export function installHook(target: any): DevToolsHook | null { const args = inputArgs.slice(); // Symbols cannot be concatenated with Strings. - let formatted: string = - typeof maybeMessage === 'symbol' - ? maybeMessage.toString() - : '' + maybeMessage; + let formatted = String(maybeMessage); // If the first argument is a string, check for substitutions. if (typeof maybeMessage === 'string') { @@ -216,17 +213,14 @@ export function installHook(target: any): DevToolsHook | null { // Arguments that remain after formatting. if (args.length) { for (let i = 0; i < args.length; i++) { - const arg = args[i]; - - // Symbols cannot be concatenated with Strings. - formatted += ' ' + (typeof arg === 'symbol' ? arg.toString() : arg); + formatted += ' ' + String(args[i]); } } // Update escaped %% values. formatted = formatted.replace(/%{2,2}/g, '%'); - return '' + formatted; + return String(formatted); } let unpatchFn = null; diff --git a/packages/react-devtools-shared/src/hooks/SourceMapMetadataConsumer.js b/packages/react-devtools-shared/src/hooks/SourceMapMetadataConsumer.js index 250192c7fa4b3..4882afba9a9fb 100644 --- a/packages/react-devtools-shared/src/hooks/SourceMapMetadataConsumer.js +++ b/packages/react-devtools-shared/src/hooks/SourceMapMetadataConsumer.js @@ -38,7 +38,6 @@ function normalizeSourcePath( const {sourceRoot} = map; let source = sourceInput; - // eslint-disable-next-line react-internal/no-primitive-constructors source = String(source); // Some source maps produce relative source paths like "./foo.js" instead of // "foo.js". Normalize these first so that future comparisons will succeed. diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 7c9e2fbd25f8a..9ec19e3afde41 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -834,7 +834,7 @@ export function formatDataForPreview( return data; default: try { - return truncateForDisplay('' + data); + return truncateForDisplay(String(data)); } catch (error) { return 'unserializable'; } diff --git a/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js b/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js index aa91857b48009..e240708667f21 100644 --- a/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMAttribute-test.js @@ -89,6 +89,27 @@ describe('ReactDOM unknown attribute', () => { testUnknownAttributeAssignment(lol, 'lol'); }); + it('throws with Temporal-like objects', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + const test = () => + testUnknownAttributeAssignment(new TemporalLike(), null); + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Warning: The provided `unknown` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + it('removes symbols and warns', () => { expect(() => testUnknownAttributeRemoval(Symbol('foo'))).toErrorDev( 'Warning: Invalid value for prop `unknown` on
tag. Either remove it ' + diff --git a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js index 2911099e8e932..fb7d7cf41ada3 100644 --- a/packages/react-dom/src/__tests__/ReactDOMComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMComponent-test.js @@ -254,6 +254,28 @@ describe('ReactDOMComponent', () => { ReactDOM.render(, div); }); + it('throws with Temporal-like objects as style values', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + const style = {fontSize: new TemporalLike()}; + const div = document.createElement('div'); + const test = () => ReactDOM.render(, div); + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Warning: The provided `fontSize` CSS property is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + it('should update styles if initially null', () => { let styles = null; const container = document.createElement('div'); @@ -1130,7 +1152,7 @@ describe('ReactDOMComponent', () => { describe('createOpenTagMarkup', () => { function quoteRegexp(str) { - return (str + '').replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1'); + return String(str).replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1'); } function expectToHaveAttribute(actual, expected) { @@ -1164,7 +1186,7 @@ describe('ReactDOMComponent', () => { describe('createContentMarkup', () => { function quoteRegexp(str) { - return (str + '').replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1'); + return String(str).replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1'); } function genMarkup(props) { @@ -2412,6 +2434,28 @@ describe('ReactDOMComponent', () => { expect(el.getAttribute('whatever')).toBe('[object Object]'); }); + it('allows Temporal-like objects as HTML (they are not coerced to strings first)', function() { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + + // `dangerouslySetInnerHTML` is never coerced to a string, so won't throw + // even with a Temporal-like object. + const container = document.createElement('div'); + ReactDOM.render( +
, + container, + ); + expect(container.firstChild.innerHTML).toEqual('2020-01-01'); + }); + it('allows cased data attributes', function() { let el; expect(() => { diff --git a/packages/react-dom/src/__tests__/ReactDOMInput-test.js b/packages/react-dom/src/__tests__/ReactDOMInput-test.js index 018dd14697f08..fb2c4bbc380c5 100644 --- a/packages/react-dom/src/__tests__/ReactDOMInput-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMInput-test.js @@ -544,6 +544,102 @@ describe('ReactDOMInput', () => { expect(node.value).toBe('foobar'); }); + it('should throw for date inputs if `defaultValue` is an object where valueOf() throws', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + const test = () => + ReactDOM.render( + , + container, + ); + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' + + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.', + ); + }); + + it('should throw for text inputs if `defaultValue` is an object where valueOf() throws', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + const test = () => + ReactDOM.render( + , + container, + ); + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' + + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.', + ); + }); + + it('should throw for date inputs if `value` is an object where valueOf() throws', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + const test = () => + ReactDOM.render( + {}} />, + container, + ); + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' + + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.', + ); + }); + + it('should throw for text inputs if `value` is an object where valueOf() throws', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + const test = () => + ReactDOM.render( + {}} />, + container, + ); + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' + + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.', + ); + }); + it('should display `value` of number 0', () => { const stub = ; const node = ReactDOM.render(stub, container); @@ -1575,7 +1671,7 @@ describe('ReactDOMInput', () => { return value; }, set: function(val) { - value = '' + val; + value = String(val); log.push('set property value'); }, }); diff --git a/packages/react-dom/src/__tests__/ReactDOMSelect-test.js b/packages/react-dom/src/__tests__/ReactDOMSelect-test.js index 3368f09f585ac..08353e11da70c 100644 --- a/packages/react-dom/src/__tests__/ReactDOMSelect-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMSelect-test.js @@ -1019,4 +1019,268 @@ describe('ReactDOMSelect', () => { expect(node.value).toBe(''); }); }); + + describe('When given a Temporal.PlainDate-like value', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + + it('throws when given a Temporal.PlainDate-like value (select)', () => { + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props)' + + ' must be strings, not TemporalLike. ' + + 'This value must be coerced to a string before before using it here.', + ); + }); + + it('throws when given a Temporal.PlainDate-like value (option)', () => { + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'The provided `value` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + + it('throws when given a Temporal.PlainDate-like value (both)', () => { + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'The provided `value` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + + it('throws with updated Temporal.PlainDate-like value (select)', () => { + ReactTestUtils.renderIntoDocument( + , + ); + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props)' + + ' must be strings, not TemporalLike. ' + + 'This value must be coerced to a string before before using it here.', + ); + }); + + it('throws with updated Temporal.PlainDate-like value (option)', () => { + ReactTestUtils.renderIntoDocument( + , + ); + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'The provided `value` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + + it('throws with updated Temporal.PlainDate-like value (both)', () => { + ReactTestUtils.renderIntoDocument( + , + ); + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'The provided `value` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + + it('throws when given a Temporal.PlainDate-like defaultValue (select)', () => { + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props)' + + ' must be strings, not TemporalLike. ' + + 'This value must be coerced to a string before before using it here.', + ); + }); + + it('throws when given a Temporal.PlainDate-like defaultValue (option)', () => { + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'The provided `value` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + + it('throws when given a Temporal.PlainDate-like value (both)', () => { + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'The provided `value` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + + it('throws with updated Temporal.PlainDate-like defaultValue (select)', () => { + ReactTestUtils.renderIntoDocument( + , + ); + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'Form field values (value, checked, defaultValue, or defaultChecked props)' + + ' must be strings, not TemporalLike. ' + + 'This value must be coerced to a string before before using it here.', + ); + }); + + it('throws with updated Temporal.PlainDate-like defaultValue (both)', () => { + ReactTestUtils.renderIntoDocument( + , + ); + const test = () => { + ReactTestUtils.renderIntoDocument( + , + ); + }; + expect(() => + expect(test).toThrowError(new TypeError('prod message')), + ).toErrorDev( + 'The provided `value` attribute is an unsupported type TemporalLike.' + + ' This value must be coerced to a string before before using it here.', + ); + }); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js index 53a34b25ffd55..a27954785f7ea 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js @@ -1745,7 +1745,7 @@ describe('ReactDOMServerHooks', () => { it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => { function Child({appId}) { - return
; + return
; } function App() { const id = useOpaqueIdentifier(); @@ -1769,7 +1769,7 @@ describe('ReactDOMServerHooks', () => { it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => { function Child({appId}) { - return
; + return
; } function App() { const id = useOpaqueIdentifier(); @@ -1793,7 +1793,7 @@ describe('ReactDOMServerHooks', () => { it('useOpaqueIdentifier warns if you try to use the result as a string in a child component', async () => { function Child({appId}) { - return
; + return
; } function App() { const id = useOpaqueIdentifier(); @@ -1817,7 +1817,7 @@ describe('ReactDOMServerHooks', () => { it('useOpaqueIdentifier warns if you try to use the result as a string', async () => { function App() { const id = useOpaqueIdentifier(); - return
; + return
; } const container = document.createElement('div'); @@ -1836,7 +1836,7 @@ describe('ReactDOMServerHooks', () => { it('useOpaqueIdentifier warns if you try to use the result as a string in a child component wrapped in a Suspense', async () => { function Child({appId}) { - return
; + return
; } function App() { const id = useOpaqueIdentifier(); diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js index 9fb48b3bce24c..72291c9959ee0 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js @@ -242,6 +242,11 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', ( // consistency but the code structure makes that hard right now. expectedToStringCalls = 2; } + if (__DEV__) { + // Checking for string coercion problems results in double the + // toString calls in DEV + expectedToStringCalls *= 2; + } let toStringCalls = 0; const firstIsSafe = { diff --git a/packages/react-dom/src/__tests__/ReactDOMTextComponent-test.js b/packages/react-dom/src/__tests__/ReactDOMTextComponent-test.js index ddc3a979597e3..24ff139085d96 100644 --- a/packages/react-dom/src/__tests__/ReactDOMTextComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMTextComponent-test.js @@ -284,4 +284,26 @@ describe('ReactDOMTextComponent', () => { ReactDOM.render(
, el); expect(el.innerHTML).toBe('
'); }); + + it('throws for Temporal-like text nodes', () => { + const el = document.createElement('div'); + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + expect(() => + ReactDOM.render(
{new TemporalLike()}
, el), + ).toThrowError( + new Error( + 'Objects are not valid as a React child (found: object with keys {}).' + + ' If you meant to render a collection of children, use an array instead.', + ), + ); + }); }); diff --git a/packages/react-dom/src/__tests__/ReactDOMTextarea-test.js b/packages/react-dom/src/__tests__/ReactDOMTextarea-test.js index 4d56f53d9e40c..f8bec6c4ba97d 100644 --- a/packages/react-dom/src/__tests__/ReactDOMTextarea-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMTextarea-test.js @@ -142,7 +142,7 @@ describe('ReactDOMTextarea', () => { return value; }, set: function(val) { - value = '' + val; + value = String(val); counter++; }, }); @@ -219,6 +219,36 @@ describe('ReactDOMTextarea', () => { expect(node.value).toEqual('foo'); }); + it('should throw when value is set to a Temporal-like object', () => { + class TemporalLike { + valueOf() { + // Throwing here is the behavior of ECMAScript "Temporal" date/time API. + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf + throw new TypeError('prod message'); + } + toString() { + return '2020-01-01'; + } + } + const container = document.createElement('div'); + const stub =