@samitouri / QOS-React-2 / commits / c88fb49d37

Improve DEV errors if string coercion throws (Temporal.*, Symbol, etc.) (#22064)

* Revise ESLint rules for string coercion Currently, react uses `'' + value` to coerce mixed values to strings. This code will throw for Temporal objects or symbols. To make string-coercion safer and to improve user-facing error messages, This commit adds a new ESLint rule called `safe-string-coercion`. This rule has two modes: a production mode and a non-production mode. * If the `isProductionUserAppCode` option is true, then `'' + value` coercions are allowed (because they're faster, although they may throw) and `String(value)` coercions are disallowed. Exception: when building error messages or running DEV-only code in prod files, `String()` should be used because it won't throw. * If the `isProductionUserAppCode` option is false, then `'' + value` coercions are disallowed (because they may throw, and in non-prod code it's not worth the risk) and `String(value)` are allowed. Production mode is used for all files which will be bundled with developers' userland apps. Non-prod mode is used for all other React code: tests, DEV blocks, devtools extension, etc. In production mode, in addiiton to flagging `String(value)` calls, the rule will also flag `'' + value` or `value + ''` coercions that may throw. The rule is smart enough to silence itself in the following "will never throw" cases: * When the coercion is wrapped in a `typeof` test that restricts to safe (non-symbol, non-object) types. Example: if (typeof value === 'string' || typeof value === 'number') { thisWontReport('' + value); } * When what's being coerced is a unary function result, because unary functions never return an object or a symbol. * When the coerced value is a commonly-used numeric identifier: `i`, `idx`, or `lineNumber`. * When the statement immeidately before the coercion is a DEV-only call to a function from shared/CheckStringCoercion.js. This call is a no-op in production, but in DEV it will show a console error explaining the problem, then will throw right after a long explanatory code comment so that debugger users will have an idea what's going on. The check function call must be in the following format: if (__DEV__) { checkXxxxxStringCoercion(value); }; Manually disabling the rule is usually not necessary because almost all prod use of the `'' + value` pattern falls into one of the categories above. But in the rare cases where the rule isn't smart enough to detect safe usage (e.g. when a coercion is inside a nested ternary operator), manually disabling the rule will be needed. The rule should also be manually disabled in prod error handling code where `String(value)` should be used for coercions, because it'd be bad to throw while building an error message or stack trace! The prod and non-prod modes have differentiated error messages to explain how to do a proper coercion in that mode. If a production check call is needed but is missing or incorrect (e.g. not in a DEV block or not immediately before the coercion), then a context-sensitive error message will be reported so that developers can figure out what's wrong and how to fix the problem. Because string coercions are now handled by the `safe-string-coercion` rule, the `no-primitive-constructor` rule no longer flags `String()` usage. It still flags `new String(value)` because that usage is almost always a bug. * Add DEV-only string coercion check functions This commit adds DEV-only functions to check whether coercing values to strings using the `'' + value` pattern will throw. If it will throw, these functions will: 1. Display a console error with a friendly error message describing the problem and the developer can fix it. 2. Perform the coercion, which will throw. Right before the line where the throwing happens, there's a long code comment that will help debugger users (or others looking at the exception call stack) figure out what happened and how to fix the problem. One of these check functions should be called before all string coercion of user-provided values, except when the the coercion is guaranteed not to throw, e.g. * if inside a typeof check like `if (typeof value === 'string')` * if coercing the result of a unary function like `+value` or `value++` * if coercing a variable named in a whitelist of numeric identifiers: `i`, `idx`, or `lineNumber`. The new `safe-string-coercion` internal ESLint rule enforces that these check functions are called when they are required. Only use these check functions in production code that will be bundled with user apps. For non-prod code (and for production error-handling code), use `String(value)` instead which may be a little slower but will never throw. * Add failing tests for string coercion Added failing tests to verify: * That input, select, and textarea elements with value and defaultValue set to Temporal-like objects which will throw when coerced to string using the `'' + value` pattern. * That text elements will throw for Temporal-like objects * That dangerouslySetInnerHTML will *not* throw for Temporal-like objects because this value is not cast to a string before passing to the DOM. * That keys that are Temporal-like objects will throw All tests above validate the friendly error messages thrown. * Use `String(value)` for coercion in non-prod files This commit switches non-production code from `'' + value` (which throws for Temporal objects and symbols) to instead use `String(value)` which won't throw for these or other future plus-phobic types. "Non-produciton code" includes anything not bundled into user apps: * Tests and test utilities. Note that I didn't change legacy React test fixtures because I assumed it was good for those files to act just like old React, including coercion behavior. * Build scripts * Dev tools package - In addition to switching to `String`, I also removed special-case code for coercing symbols which is now unnecessary. * Add DEV-only string coercion checks to prod files This commit adds DEV-only function calls to to check if string coercion using `'' + value` will throw, which it will if the value is a Temporal object or a symbol because those types can't be added with `+`. If it will throw, then in DEV these checks will show a console error to help the user undertsand what went wrong and how to fix the problem. After emitting the console error, the check functions will retry the coercion which will throw with a call stack that's easy (or at least easier!) to troubleshoot because the exception happens right after a long comment explaining the issue. So whether the user is in a debugger, looking at the browser console, or viewing the in-browser DEV call stack, it should be easy to understand and fix the problem. In most cases, the safe-string-coercion ESLint rule is smart enough to detect when a coercion is safe. But in rare cases (e.g. when a coercion is inside a ternary) this rule will have to be manually disabled. This commit also switches error-handling code to use `String(value)` for coercion, because it's bad to crash when you're trying to build an error message or a call stack! Because `String()` is usually disallowed by the `safe-string-coercion` ESLint rule in production code, the rule must be disabled when `String()` is used.

Justin Grant committed Sep 27, 2021 at 10:05 UTC c88fb49d37fd01024e0a254a37b7810d107bdd1d
58 files changed +1561 -85
.eslintrc.js
+12 -1
@@ -108,6 +108,10 @@ module.exports = {
108 // CUSTOM RULES
109 // the second argument of warning/invariant should be a literal string
110 'react-internal/no-primitive-constructors': ERROR,
111 + 'react-internal/safe-string-coercion': [
112 + ERROR,
113 + {isProductionUserAppCode: true},
114 + ],
115 'react-internal/no-to-warn-dev-within-to-throw': ERROR,
116 'react-internal/invariant-args': ERROR,
117 'react-internal/warning-args': ERROR,
@@ -168,10 +172,17 @@ module.exports = {
172 'packages/*/npm/**/*.js',
173 'packages/dom-event-testing-library/**/*.js',
174 'packages/react-devtools*/**/*.js',
175 + 'dangerfile.js',
176 + 'fixtures',
177 + 'packages/react-dom/src/test-utils/*.js',
178 ],
179 rules: {
180 'react-internal/no-production-logging': OFF,
181 'react-internal/warning-args': OFF,
182 + 'react-internal/safe-string-coercion': [
183 + ERROR,
184 + {isProductionUserAppCode: false},
185 + ],
186
187 // Disable accessibility checks
188 'jsx-a11y/aria-role': OFF,
@@ -185,7 +196,7 @@ module.exports = {
196 {
197 files: [
198 'scripts/eslint-rules/*.js',
188 - 'packages/eslint-plugin-react-hooks/src/*.js'
199 + 'packages/eslint-plugin-react-hooks/src/*.js',
200 ],
201 plugins: ['eslint-plugin'],
202 rules: {
dangerfile.js
+2 -2
@@ -102,8 +102,8 @@ function row(result) {
102 let headSha;
103 let baseSha;
104 try {
105 - headSha = (readFileSync(HEAD_DIR + '/COMMIT_SHA') + '').trim();
106 - baseSha = (readFileSync(BASE_DIR + '/COMMIT_SHA') + '').trim();
105 + headSha = String(readFileSync(HEAD_DIR + '/COMMIT_SHA')).trim();
106 + baseSha = String(readFileSync(BASE_DIR + '/COMMIT_SHA')).trim();
107 } catch {
108 warn(
109 "Failed to read build artifacts. It's possible a build configuration " +
fixtures/dom/src/components/fixtures/error-handling/index.js
+1 -1
@@ -41,7 +41,7 @@ class ErrorBoundary extends React.Component {
41 if (this.state.error) {
42 return <p>Captured an error: {this.state.error.message}</p>;
43 } else {
44 - return <p>Captured an error: {'' + this.state.error}</p>;
44 + return <p>Captured an error: {String(this.state.error)}</p>;
45 }
46 }
47 if (this.state.shouldThrow) {
packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js
+2 -2
@@ -615,7 +615,7 @@ describe('ReactHooksInspectionIntegration', () => {
615 expect(tree[0].id).toEqual(0);
616 expect(tree[0].isStateEditable).toEqual(false);
617 expect(tree[0].name).toEqual('OpaqueIdentifier');
618 - expect((tree[0].value + '').startsWith('c_')).toBe(true);
618 + expect(String(tree[0].value).startsWith('c_')).toBe(true);
619
620 expect(tree[1]).toEqual({
621 id: 1,
@@ -646,7 +646,7 @@ describe('ReactHooksInspectionIntegration', () => {
646 expect(tree[0].id).toEqual(0);
647 expect(tree[0].isStateEditable).toEqual(false);
648 expect(tree[0].name).toEqual('OpaqueIdentifier');
649 - expect((tree[0].value + '').startsWith('c_')).toBe(true);
649 + expect(String(tree[0].value).startsWith('c_')).toBe(true);
650
651 expect(tree[1]).toEqual({
652 id: 1,
packages/react-devtools-extensions/src/main.js
+1 -1
@@ -94,7 +94,7 @@ function createPanelIfReactLoaded() {
94
95 function initBridgeAndStore() {
96 const port = chrome.runtime.connect({
97 - name: '' + tabId,
97 + name: String(tabId),
98 });
99 // Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation,
100 // so it makes no sense to handle it here.
packages/react-devtools-shared/src/backend/legacy/renderer.js
+1 -1
@@ -63,7 +63,7 @@ function getData(internalInstance: InternalInstance) {
63 // != used deliberately here to catch undefined and null
64 if (internalInstance._currentElement != null) {
65 if (internalInstance._currentElement.key) {
66 - key = '' + internalInstance._currentElement.key;
66 + key = String(internalInstance._currentElement.key);
67 }
68
69 const elementType = internalInstance._currentElement.type;
packages/react-devtools-shared/src/backend/renderer.js
+1 -1
@@ -1848,7 +1848,7 @@ export function attach(
1848
1849 // This check is a guard to handle a React element that has been modified
1850 // in such a way as to bypass the default stringification of the "key" property.
1851 - const keyString = key === null ? null : '' + key;
1851 + const keyString = key === null ? null : String(key);
1852 const keyStringID = getStringID(keyString);
1853
1854 pushOperation(TREE_OPERATION_ADD);
packages/react-devtools-shared/src/backend/utils.js
+3 -10
@@ -166,11 +166,7 @@ export function format(
166 ): string {
167 const args = inputArgs.slice();
168
169 - // Symbols cannot be concatenated with Strings.
170 - let formatted: string =
171 - typeof maybeMessage === 'symbol'
172 - ? maybeMessage.toString()
173 - : '' + maybeMessage;
169 + let formatted: string = String(maybeMessage);
170
171 // If the first argument is a string, check for substitutions.
172 if (typeof maybeMessage === 'string') {
@@ -203,17 +199,14 @@ export function format(
199 // Arguments that remain after formatting.
200 if (args.length) {
201 for (let i = 0; i < args.length; i++) {
206 - const arg = args[i];
207 -
208 - // Symbols cannot be concatenated with Strings.
209 - formatted += ' ' + (typeof arg === 'symbol' ? arg.toString() : arg);
202 + formatted += ' ' + String(args[i]);
203 }
204 }
205
206 // Update escaped %% values.
207 formatted = formatted.replace(/%{2,2}/g, '%');
208
216 - return '' + formatted;
209 + return String(formatted);
210 }
211
212 export function isSynchronousXHRSupported(): boolean {
packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js
+1 -1
@@ -46,7 +46,7 @@ export default class ErrorBoundary extends Component<Props, State> {
46 error !== null &&
47 error.hasOwnProperty('message')
48 ? error.message
49 - : '' + error;
49 + : String(error);
50
51 const callStack =
52 typeof error === 'object' &&
packages/react-devtools-shared/src/devtools/views/utils.js
+2 -2
@@ -19,8 +19,8 @@ export function alphaSortEntries(
19 ): number {
20 const a = entryA[0];
21 const b = entryB[0];
22 - if ('' + +a === a) {
23 - if ('' + +b !== b) {
22 + if (String(+a) === a) {
23 + if (String(+b) !== b) {
24 return -1;
25 }
26 return +a < +b ? -1 : 1;
packages/react-devtools-shared/src/hook.js
+3 -9
@@ -180,10 +180,7 @@ export function installHook(target: any): DevToolsHook | null {
180 const args = inputArgs.slice();
181
182 // Symbols cannot be concatenated with Strings.
183 - let formatted: string =
184 - typeof maybeMessage === 'symbol'
185 - ? maybeMessage.toString()
186 - : '' + maybeMessage;
183 + let formatted = String(maybeMessage);
184
185 // If the first argument is a string, check for substitutions.
186 if (typeof maybeMessage === 'string') {
@@ -216,17 +213,14 @@ export function installHook(target: any): DevToolsHook | null {
213 // Arguments that remain after formatting.
214 if (args.length) {
215 for (let i = 0; i < args.length; i++) {
219 - const arg = args[i];
220 -
221 - // Symbols cannot be concatenated with Strings.
222 - formatted += ' ' + (typeof arg === 'symbol' ? arg.toString() : arg);
216 + formatted += ' ' + String(args[i]);
217 }
218 }
219
220 // Update escaped %% values.
221 formatted = formatted.replace(/%{2,2}/g, '%');
222
229 - return '' + formatted;
223 + return String(formatted);
224 }
225
226 let unpatchFn = null;
packages/react-devtools-shared/src/hooks/SourceMapMetadataConsumer.js
-1
@@ -38,7 +38,6 @@ function normalizeSourcePath(
38 const {sourceRoot} = map;
39 let source = sourceInput;
40
41 - // eslint-disable-next-line react-internal/no-primitive-constructors
41 source = String(source);
42 // Some source maps produce relative source paths like "./foo.js" instead of
43 // "foo.js". Normalize these first so that future comparisons will succeed.
packages/react-devtools-shared/src/utils.js
+1 -1
@@ -834,7 +834,7 @@ export function formatDataForPreview(
834 return data;
835 default:
836 try {
837 - return truncateForDisplay('' + data);
837 + return truncateForDisplay(String(data));
838 } catch (error) {
839 return 'unserializable';
840 }
packages/react-dom/src/__tests__/ReactDOMAttribute-test.js
+21
@@ -89,6 +89,27 @@ describe('ReactDOM unknown attribute', () => {
89 testUnknownAttributeAssignment(lol, 'lol');
90 });
91
92 + it('throws with Temporal-like objects', () => {
93 + class TemporalLike {
94 + valueOf() {
95 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
96 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
97 + throw new TypeError('prod message');
98 + }
99 + toString() {
100 + return '2020-01-01';
101 + }
102 + }
103 + const test = () =>
104 + testUnknownAttributeAssignment(new TemporalLike(), null);
105 + expect(() =>
106 + expect(test).toThrowError(new TypeError('prod message')),
107 + ).toErrorDev(
108 + 'Warning: The provided `unknown` attribute is an unsupported type TemporalLike.' +
109 + ' This value must be coerced to a string before before using it here.',
110 + );
111 + });
112 +
113 it('removes symbols and warns', () => {
114 expect(() => testUnknownAttributeRemoval(Symbol('foo'))).toErrorDev(
115 'Warning: Invalid value for prop `unknown` on <div> tag. Either remove it ' +
packages/react-dom/src/__tests__/ReactDOMComponent-test.js
+46 -2
@@ -254,6 +254,28 @@ describe('ReactDOMComponent', () => {
254 ReactDOM.render(<span style={style} />, div);
255 });
256
257 + it('throws with Temporal-like objects as style values', () => {
258 + class TemporalLike {
259 + valueOf() {
260 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
261 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
262 + throw new TypeError('prod message');
263 + }
264 + toString() {
265 + return '2020-01-01';
266 + }
267 + }
268 + const style = {fontSize: new TemporalLike()};
269 + const div = document.createElement('div');
270 + const test = () => ReactDOM.render(<span style={style} />, div);
271 + expect(() =>
272 + expect(test).toThrowError(new TypeError('prod message')),
273 + ).toErrorDev(
274 + 'Warning: The provided `fontSize` CSS property is an unsupported type TemporalLike.' +
275 + ' This value must be coerced to a string before before using it here.',
276 + );
277 + });
278 +
279 it('should update styles if initially null', () => {
280 let styles = null;
281 const container = document.createElement('div');
@@ -1130,7 +1152,7 @@ describe('ReactDOMComponent', () => {
1152
1153 describe('createOpenTagMarkup', () => {
1154 function quoteRegexp(str) {
1133 - return (str + '').replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
1155 + return String(str).replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
1156 }
1157
1158 function expectToHaveAttribute(actual, expected) {
@@ -1164,7 +1186,7 @@ describe('ReactDOMComponent', () => {
1186
1187 describe('createContentMarkup', () => {
1188 function quoteRegexp(str) {
1167 - return (str + '').replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
1189 + return String(str).replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
1190 }
1191
1192 function genMarkup(props) {
@@ -2412,6 +2434,28 @@ describe('ReactDOMComponent', () => {
2434 expect(el.getAttribute('whatever')).toBe('[object Object]');
2435 });
2436
2437 + it('allows Temporal-like objects as HTML (they are not coerced to strings first)', function() {
2438 + class TemporalLike {
2439 + valueOf() {
2440 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
2441 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
2442 + throw new TypeError('prod message');
2443 + }
2444 + toString() {
2445 + return '2020-01-01';
2446 + }
2447 + }
2448 +
2449 + // `dangerouslySetInnerHTML` is never coerced to a string, so won't throw
2450 + // even with a Temporal-like object.
2451 + const container = document.createElement('div');
2452 + ReactDOM.render(
2453 + <div dangerouslySetInnerHTML={{__html: new TemporalLike()}} />,
2454 + container,
2455 + );
2456 + expect(container.firstChild.innerHTML).toEqual('2020-01-01');
2457 + });
2458 +
2459 it('allows cased data attributes', function() {
2460 let el;
2461 expect(() => {
packages/react-dom/src/__tests__/ReactDOMInput-test.js
+97 -1
@@ -544,6 +544,102 @@ describe('ReactDOMInput', () => {
544 expect(node.value).toBe('foobar');
545 });
546
547 + it('should throw for date inputs if `defaultValue` is an object where valueOf() throws', () => {
548 + class TemporalLike {
549 + valueOf() {
550 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
551 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
552 + throw new TypeError('prod message');
553 + }
554 + toString() {
555 + return '2020-01-01';
556 + }
557 + }
558 + const test = () =>
559 + ReactDOM.render(
560 + <input defaultValue={new TemporalLike()} type="date" />,
561 + container,
562 + );
563 + expect(() =>
564 + expect(test).toThrowError(new TypeError('prod message')),
565 + ).toErrorDev(
566 + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' +
567 + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.',
568 + );
569 + });
570 +
571 + it('should throw for text inputs if `defaultValue` is an object where valueOf() throws', () => {
572 + class TemporalLike {
573 + valueOf() {
574 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
575 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
576 + throw new TypeError('prod message');
577 + }
578 + toString() {
579 + return '2020-01-01';
580 + }
581 + }
582 + const test = () =>
583 + ReactDOM.render(
584 + <input defaultValue={new TemporalLike()} type="text" />,
585 + container,
586 + );
587 + expect(() =>
588 + expect(test).toThrowError(new TypeError('prod message')),
589 + ).toErrorDev(
590 + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' +
591 + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.',
592 + );
593 + });
594 +
595 + it('should throw for date inputs if `value` is an object where valueOf() throws', () => {
596 + class TemporalLike {
597 + valueOf() {
598 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
599 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
600 + throw new TypeError('prod message');
601 + }
602 + toString() {
603 + return '2020-01-01';
604 + }
605 + }
606 + const test = () =>
607 + ReactDOM.render(
608 + <input value={new TemporalLike()} type="date" onChange={() => {}} />,
609 + container,
610 + );
611 + expect(() =>
612 + expect(test).toThrowError(new TypeError('prod message')),
613 + ).toErrorDev(
614 + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' +
615 + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.',
616 + );
617 + });
618 +
619 + it('should throw for text inputs if `value` is an object where valueOf() throws', () => {
620 + class TemporalLike {
621 + valueOf() {
622 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
623 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
624 + throw new TypeError('prod message');
625 + }
626 + toString() {
627 + return '2020-01-01';
628 + }
629 + }
630 + const test = () =>
631 + ReactDOM.render(
632 + <input value={new TemporalLike()} type="text" onChange={() => {}} />,
633 + container,
634 + );
635 + expect(() =>
636 + expect(test).toThrowError(new TypeError('prod message')),
637 + ).toErrorDev(
638 + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' +
639 + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.',
640 + );
641 + });
642 +
643 it('should display `value` of number 0', () => {
644 const stub = <input type="text" value={0} onChange={emptyFunction} />;
645 const node = ReactDOM.render(stub, container);
@@ -1575,7 +1671,7 @@ describe('ReactDOMInput', () => {
1671 return value;
1672 },
1673 set: function(val) {
1578 - value = '' + val;
1674 + value = String(val);
1675 log.push('set property value');
1676 },
1677 });
packages/react-dom/src/__tests__/ReactDOMSelect-test.js
+264
@@ -1019,4 +1019,268 @@ describe('ReactDOMSelect', () => {
1019 expect(node.value).toBe('');
1020 });
1021 });
1022 +
1023 + describe('When given a Temporal.PlainDate-like value', () => {
1024 + class TemporalLike {
1025 + valueOf() {
1026 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
1027 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
1028 + throw new TypeError('prod message');
1029 + }
1030 + toString() {
1031 + return '2020-01-01';
1032 + }
1033 + }
1034 +
1035 + it('throws when given a Temporal.PlainDate-like value (select)', () => {
1036 + const test = () => {
1037 + ReactTestUtils.renderIntoDocument(
1038 + <select onChange={noop} value={new TemporalLike()}>
1039 + <option value="2020-01-01">like a Temporal.PlainDate</option>
1040 + <option value="monkey">A monkey!</option>
1041 + <option value="giraffe">A giraffe!</option>
1042 + </select>,
1043 + );
1044 + };
1045 + expect(() =>
1046 + expect(test).toThrowError(new TypeError('prod message')),
1047 + ).toErrorDev(
1048 + 'Form field values (value, checked, defaultValue, or defaultChecked props)' +
1049 + ' must be strings, not TemporalLike. ' +
1050 + 'This value must be coerced to a string before before using it here.',
1051 + );
1052 + });
1053 +
1054 + it('throws when given a Temporal.PlainDate-like value (option)', () => {
1055 + const test = () => {
1056 + ReactTestUtils.renderIntoDocument(
1057 + <select onChange={noop} value="2020-01-01">
1058 + <option value={new TemporalLike()}>
1059 + like a Temporal.PlainDate
1060 + </option>
1061 + <option value="monkey">A monkey!</option>
1062 + <option value="giraffe">A giraffe!</option>
1063 + </select>,
1064 + );
1065 + };
1066 + expect(() =>
1067 + expect(test).toThrowError(new TypeError('prod message')),
1068 + ).toErrorDev(
1069 + 'The provided `value` attribute is an unsupported type TemporalLike.' +
1070 + ' This value must be coerced to a string before before using it here.',
1071 + );
1072 + });
1073 +
1074 + it('throws when given a Temporal.PlainDate-like value (both)', () => {
1075 + const test = () => {
1076 + ReactTestUtils.renderIntoDocument(
1077 + <select onChange={noop} value={new TemporalLike()}>
1078 + <option value={new TemporalLike()}>
1079 + like a Temporal.PlainDate
1080 + </option>
1081 + <option value="monkey">A monkey!</option>
1082 + <option value="giraffe">A giraffe!</option>
1083 + </select>,
1084 + );
1085 + };
1086 + expect(() =>
1087 + expect(test).toThrowError(new TypeError('prod message')),
1088 + ).toErrorDev(
1089 + 'The provided `value` attribute is an unsupported type TemporalLike.' +
1090 + ' This value must be coerced to a string before before using it here.',
1091 + );
1092 + });
1093 +
1094 + it('throws with updated Temporal.PlainDate-like value (select)', () => {
1095 + ReactTestUtils.renderIntoDocument(
1096 + <select onChange={noop} value="monkey">
1097 + <option value="2020-01-01">like a Temporal.PlainDate</option>
1098 + <option value="monkey">A monkey!</option>
1099 + <option value="giraffe">A giraffe!</option>
1100 + </select>,
1101 + );
1102 + const test = () => {
1103 + ReactTestUtils.renderIntoDocument(
1104 + <select onChange={noop} value={new TemporalLike()}>
1105 + <option value="2020-01-01">like a Temporal.PlainDate</option>
1106 + <option value="monkey">A monkey!</option>
1107 + <option value="giraffe">A giraffe!</option>
1108 + </select>,
1109 + );
1110 + };
1111 + expect(() =>
1112 + expect(test).toThrowError(new TypeError('prod message')),
1113 + ).toErrorDev(
1114 + 'Form field values (value, checked, defaultValue, or defaultChecked props)' +
1115 + ' must be strings, not TemporalLike. ' +
1116 + 'This value must be coerced to a string before before using it here.',
1117 + );
1118 + });
1119 +
1120 + it('throws with updated Temporal.PlainDate-like value (option)', () => {
1121 + ReactTestUtils.renderIntoDocument(
1122 + <select onChange={noop} value="2020-01-01">
1123 + <option value="donkey">like a Temporal.PlainDate</option>
1124 + <option value="monkey">A monkey!</option>
1125 + <option value="giraffe">A giraffe!</option>
1126 + </select>,
1127 + );
1128 + const test = () => {
1129 + ReactTestUtils.renderIntoDocument(
1130 + <select onChange={noop} value="2020-01-01">
1131 + <option value={new TemporalLike()}>
1132 + like a Temporal.PlainDate
1133 + </option>
1134 + <option value="monkey">A monkey!</option>
1135 + <option value="giraffe">A giraffe!</option>
1136 + </select>,
1137 + );
1138 + };
1139 + expect(() =>
1140 + expect(test).toThrowError(new TypeError('prod message')),
1141 + ).toErrorDev(
1142 + 'The provided `value` attribute is an unsupported type TemporalLike.' +
1143 + ' This value must be coerced to a string before before using it here.',
1144 + );
1145 + });
1146 +
1147 + it('throws with updated Temporal.PlainDate-like value (both)', () => {
1148 + ReactTestUtils.renderIntoDocument(
1149 + <select onChange={noop} value="donkey">
1150 + <option value="donkey">like a Temporal.PlainDate</option>
1151 + <option value="monkey">A monkey!</option>
1152 + <option value="giraffe">A giraffe!</option>
1153 + </select>,
1154 + );
1155 + const test = () => {
1156 + ReactTestUtils.renderIntoDocument(
1157 + <select onChange={noop} value={new TemporalLike()}>
1158 + <option value={new TemporalLike()}>
1159 + like a Temporal.PlainDate
1160 + </option>
1161 + <option value="monkey">A monkey!</option>
1162 + <option value="giraffe">A giraffe!</option>
1163 + </select>,
1164 + );
1165 + };
1166 + expect(() =>
1167 + expect(test).toThrowError(new TypeError('prod message')),
1168 + ).toErrorDev(
1169 + 'The provided `value` attribute is an unsupported type TemporalLike.' +
1170 + ' This value must be coerced to a string before before using it here.',
1171 + );
1172 + });
1173 +
1174 + it('throws when given a Temporal.PlainDate-like defaultValue (select)', () => {
1175 + const test = () => {
1176 + ReactTestUtils.renderIntoDocument(
1177 + <select onChange={noop} defaultValue={new TemporalLike()}>
1178 + <option value="2020-01-01">like a Temporal.PlainDate</option>
1179 + <option value="monkey">A monkey!</option>
1180 + <option value="giraffe">A giraffe!</option>
1181 + </select>,
1182 + );
1183 + };
1184 + expect(() =>
1185 + expect(test).toThrowError(new TypeError('prod message')),
1186 + ).toErrorDev(
1187 + 'Form field values (value, checked, defaultValue, or defaultChecked props)' +
1188 + ' must be strings, not TemporalLike. ' +
1189 + 'This value must be coerced to a string before before using it here.',
1190 + );
1191 + });
1192 +
1193 + it('throws when given a Temporal.PlainDate-like defaultValue (option)', () => {
1194 + const test = () => {
1195 + ReactTestUtils.renderIntoDocument(
1196 + <select onChange={noop} defaultValue="2020-01-01">
1197 + <option value={new TemporalLike()}>
1198 + like a Temporal.PlainDate
1199 + </option>
1200 + <option value="monkey">A monkey!</option>
1201 + <option value="giraffe">A giraffe!</option>
1202 + </select>,
1203 + );
1204 + };
1205 + expect(() =>
1206 + expect(test).toThrowError(new TypeError('prod message')),
1207 + ).toErrorDev(
1208 + 'The provided `value` attribute is an unsupported type TemporalLike.' +
1209 + ' This value must be coerced to a string before before using it here.',
1210 + );
1211 + });
1212 +
1213 + it('throws when given a Temporal.PlainDate-like value (both)', () => {
1214 + const test = () => {
1215 + ReactTestUtils.renderIntoDocument(
1216 + <select onChange={noop} defaultValue={new TemporalLike()}>
1217 + <option value={new TemporalLike()}>
1218 + like a Temporal.PlainDate
1219 + </option>
1220 + <option value="monkey">A monkey!</option>
1221 + <option value="giraffe">A giraffe!</option>
1222 + </select>,
1223 + );
1224 + };
1225 + expect(() =>
1226 + expect(test).toThrowError(new TypeError('prod message')),
1227 + ).toErrorDev(
1228 + 'The provided `value` attribute is an unsupported type TemporalLike.' +
1229 + ' This value must be coerced to a string before before using it here.',
1230 + );
1231 + });
1232 +
1233 + it('throws with updated Temporal.PlainDate-like defaultValue (select)', () => {
1234 + ReactTestUtils.renderIntoDocument(
1235 + <select onChange={noop} defaultValue="monkey">
1236 + <option value="2020-01-01">like a Temporal.PlainDate</option>
1237 + <option value="monkey">A monkey!</option>
1238 + <option value="giraffe">A giraffe!</option>
1239 + </select>,
1240 + );
1241 + const test = () => {
1242 + ReactTestUtils.renderIntoDocument(
1243 + <select onChange={noop} defaultValue={new TemporalLike()}>
1244 + <option value="2020-01-01">like a Temporal.PlainDate</option>
1245 + <option value="monkey">A monkey!</option>
1246 + <option value="giraffe">A giraffe!</option>
1247 + </select>,
1248 + );
1249 + };
1250 + expect(() =>
1251 + expect(test).toThrowError(new TypeError('prod message')),
1252 + ).toErrorDev(
1253 + 'Form field values (value, checked, defaultValue, or defaultChecked props)' +
1254 + ' must be strings, not TemporalLike. ' +
1255 + 'This value must be coerced to a string before before using it here.',
1256 + );
1257 + });
1258 +
1259 + it('throws with updated Temporal.PlainDate-like defaultValue (both)', () => {
1260 + ReactTestUtils.renderIntoDocument(
1261 + <select onChange={noop} defaultValue="monkey">
1262 + <option value="donkey">like a Temporal.PlainDate</option>
1263 + <option value="monkey">A monkey!</option>
1264 + <option value="giraffe">A giraffe!</option>
1265 + </select>,
1266 + );
1267 + const test = () => {
1268 + ReactTestUtils.renderIntoDocument(
1269 + <select onChange={noop} value={new TemporalLike()}>
1270 + <option value={new TemporalLike()}>
1271 + like a Temporal.PlainDate
1272 + </option>
1273 + <option value="monkey">A monkey!</option>
1274 + <option value="giraffe">A giraffe!</option>
1275 + </select>,
1276 + );
1277 + };
1278 + expect(() =>
1279 + expect(test).toThrowError(new TypeError('prod message')),
1280 + ).toErrorDev(
1281 + 'The provided `value` attribute is an unsupported type TemporalLike.' +
1282 + ' This value must be coerced to a string before before using it here.',
1283 + );
1284 + });
1285 + });
1286 });
packages/react-dom/src/__tests__/ReactDOMServerIntegrationHooks-test.js
+5 -5
@@ -1745,7 +1745,7 @@ describe('ReactDOMServerHooks', () => {
1745
1746 it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => {
1747 function Child({appId}) {
1748 - return <div aria-labelledby={appId + ''} />;
1748 + return <div aria-labelledby={String(appId)} />;
1749 }
1750 function App() {
1751 const id = useOpaqueIdentifier();
@@ -1769,7 +1769,7 @@ describe('ReactDOMServerHooks', () => {
1769
1770 it('useOpaqueIdentifier warns when there is a hydration error and we are using ID as a string', async () => {
1771 function Child({appId}) {
1772 - return <div aria-labelledby={appId + ''} />;
1772 + return <div aria-labelledby={String(appId)} />;
1773 }
1774 function App() {
1775 const id = useOpaqueIdentifier();
@@ -1793,7 +1793,7 @@ describe('ReactDOMServerHooks', () => {
1793
1794 it('useOpaqueIdentifier warns if you try to use the result as a string in a child component', async () => {
1795 function Child({appId}) {
1796 - return <div aria-labelledby={appId + ''} />;
1796 + return <div aria-labelledby={String(appId)} />;
1797 }
1798 function App() {
1799 const id = useOpaqueIdentifier();
@@ -1817,7 +1817,7 @@ describe('ReactDOMServerHooks', () => {
1817 it('useOpaqueIdentifier warns if you try to use the result as a string', async () => {
1818 function App() {
1819 const id = useOpaqueIdentifier();
1820 - return <div aria-labelledby={id + ''} />;
1820 + return <div aria-labelledby={String(id)} />;
1821 }
1822
1823 const container = document.createElement('div');
@@ -1836,7 +1836,7 @@ describe('ReactDOMServerHooks', () => {
1836
1837 it('useOpaqueIdentifier warns if you try to use the result as a string in a child component wrapped in a Suspense', async () => {
1838 function Child({appId}) {
1839 - return <div aria-labelledby={appId + ''} />;
1839 + return <div aria-labelledby={String(appId)} />;
1840 }
1841 function App() {
1842 const id = useOpaqueIdentifier();
packages/react-dom/src/__tests__/ReactDOMServerIntegrationUntrustedURL-test.internal.js
+5
@@ -242,6 +242,11 @@ describe('ReactDOMServerIntegration - Untrusted URLs - disableJavaScriptURLs', (
242 // consistency but the code structure makes that hard right now.
243 expectedToStringCalls = 2;
244 }
245 + if (__DEV__) {
246 + // Checking for string coercion problems results in double the
247 + // toString calls in DEV
248 + expectedToStringCalls *= 2;
249 + }
250
251 let toStringCalls = 0;
252 const firstIsSafe = {
packages/react-dom/src/__tests__/ReactDOMTextComponent-test.js
+22
@@ -284,4 +284,26 @@ describe('ReactDOMTextComponent', () => {
284 ReactDOM.render(<div />, el);
285 expect(el.innerHTML).toBe('<div></div>');
286 });
287 +
288 + it('throws for Temporal-like text nodes', () => {
289 + const el = document.createElement('div');
290 + class TemporalLike {
291 + valueOf() {
292 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
293 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
294 + throw new TypeError('prod message');
295 + }
296 + toString() {
297 + return '2020-01-01';
298 + }
299 + }
300 + expect(() =>
301 + ReactDOM.render(<div>{new TemporalLike()}</div>, el),
302 + ).toThrowError(
303 + new Error(
304 + 'Objects are not valid as a React child (found: object with keys {}).' +
305 + ' If you meant to render a collection of children, use an array instead.',
306 + ),
307 + );
308 + });
309 });
packages/react-dom/src/__tests__/ReactDOMTextarea-test.js
+31 -1
@@ -142,7 +142,7 @@ describe('ReactDOMTextarea', () => {
142 return value;
143 },
144 set: function(val) {
145 - value = '' + val;
145 + value = String(val);
146 counter++;
147 },
148 });
@@ -219,6 +219,36 @@ describe('ReactDOMTextarea', () => {
219 expect(node.value).toEqual('foo');
220 });
221
222 + it('should throw when value is set to a Temporal-like object', () => {
223 + class TemporalLike {
224 + valueOf() {
225 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
226 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
227 + throw new TypeError('prod message');
228 + }
229 + toString() {
230 + return '2020-01-01';
231 + }
232 + }
233 + const container = document.createElement('div');
234 + const stub = <textarea value="giraffe" onChange={emptyFunction} />;
235 + const node = renderTextarea(stub, container);
236 +
237 + expect(node.value).toBe('giraffe');
238 +
239 + const test = () =>
240 + ReactDOM.render(
241 + <textarea value={new TemporalLike()} onChange={emptyFunction} />,
242 + container,
243 + );
244 + expect(() =>
245 + expect(test).toThrowError(new TypeError('prod message')),
246 + ).toErrorDev(
247 + 'Form field values (value, checked, defaultValue, or defaultChecked props) must be ' +
248 + 'strings, not TemporalLike. This value must be coerced to a string before before using it here.',
249 + );
250 + });
251 +
252 it('should take updates to `defaultValue` for uncontrolled textarea', () => {
253 const container = document.createElement('div');
254
packages/react-dom/src/__tests__/ReactIdentity-test.js
+29
@@ -262,4 +262,33 @@ describe('ReactIdentity', () => {
262 ReactTestUtils.renderIntoDocument(component);
263 }).not.toThrow();
264 });
265 +
266 + it('should throw if key is a Temporal-like object', () => {
267 + class TemporalLike {
268 + valueOf() {
269 + // Throwing here is the behavior of ECMAScript "Temporal" date/time API.
270 + // See https://tc39.es/proposal-temporal/docs/plaindate.html#valueOf
271 + throw new TypeError('prod message');
272 + }
273 + toString() {
274 + return '2020-01-01';
275 + }
276 + }
277 +
278 + const el = document.createElement('div');
279 + const test = () =>
280 + ReactDOM.render(
281 + <div>
282 + <span key={new TemporalLike()} />
283 + </div>,
284 + el,
285 + );
286 + expect(() =>
287 + expect(test).toThrowError(new TypeError('prod message')),
288 + ).toErrorDev(
289 + 'The provided key is an unsupported type TemporalLike.' +
290 + ' This value must be coerced to a string before before using it here.',
291 + {withoutStack: true},
292 + );
293 + });
294 });
packages/react-dom/src/__tests__/ReactMultiChildText-test.js
+2 -2
@@ -44,7 +44,7 @@ const expectChildren = function(container, children) {
44 } else {
45 expect(textNode != null).toBe(true);
46 expect(textNode.nodeType).toBe(3);
47 - expect(textNode.data).toBe('' + children);
47 + expect(textNode.data).toBe(String(children));
48 }
49 } else {
50 let mountIndex = 0;
@@ -55,7 +55,7 @@ const expectChildren = function(container, children) {
55 if (typeof child === 'string') {
56 textNode = outerNode.childNodes[mountIndex];
57 expect(textNode.nodeType).toBe(3);
58 - expect(textNode.data).toBe('' + child);
58 + expect(textNode.data).toBe(child);
59 mountIndex++;
60 } else {
61 const elementDOMNode = outerNode.childNodes[mountIndex];
packages/react-dom/src/client/DOMPropertyOperations.js
+20
@@ -20,6 +20,7 @@ import {
20 disableJavaScriptURLs,
21 enableTrustedTypesIntegration,
22 } from 'shared/ReactFeatureFlags';
23 +import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
24 import {isOpaqueHydratingObject} from './ReactDOMHostConfig';
25
26 import type {PropertyInfo} from '../shared/DOMProperty';
@@ -40,10 +41,18 @@ export function getValueForProperty(
41 const {propertyName} = propertyInfo;
42 return (node: any)[propertyName];
43 } else {
44 + // This check protects multiple uses of `expected`, which is why the
45 + // react-internal/safe-string-coercion rule is disabled in several spots
46 + // below.
47 + if (__DEV__) {
48 + checkAttributeStringCoercion(expected, name);
49 + }
50 +
51 if (!disableJavaScriptURLs && propertyInfo.sanitizeURL) {
52 // If we haven't fully disabled javascript: URLs, and if
53 // the hydration is successful of a javascript: URL, we
54 // still want to warn on the client.
55 + // eslint-disable-next-line react-internal/safe-string-coercion
56 sanitizeURL('' + (expected: any));
57 }
58
@@ -60,6 +69,7 @@ export function getValueForProperty(
69 if (shouldRemoveAttribute(name, expected, propertyInfo, false)) {
70 return value;
71 }
72 + // eslint-disable-next-line react-internal/safe-string-coercion
73 if (value === '' + (expected: any)) {
74 return expected;
75 }
@@ -85,6 +95,7 @@ export function getValueForProperty(
95
96 if (shouldRemoveAttribute(name, expected, propertyInfo, false)) {
97 return stringValue === null ? expected : stringValue;
98 + // eslint-disable-next-line react-internal/safe-string-coercion
99 } else if (stringValue === '' + (expected: any)) {
100 return expected;
101 } else {
@@ -119,6 +130,9 @@ export function getValueForAttribute(
130 return expected === undefined ? undefined : null;
131 }
132 const value = node.getAttribute(name);
133 + if (__DEV__) {
134 + checkAttributeStringCoercion(expected, name);
135 + }
136 if (value === '' + (expected: any)) {
137 return expected;
138 }
@@ -153,6 +167,9 @@ export function setValueForProperty(
167 if (value === null) {
168 node.removeAttribute(attributeName);
169 } else {
170 + if (__DEV__) {
171 + checkAttributeStringCoercion(value, name);
172 + }
173 node.setAttribute(
174 attributeName,
175 enableTrustedTypesIntegration ? (value: any) : '' + (value: any),
@@ -191,6 +208,9 @@ export function setValueForProperty(
208 if (enableTrustedTypesIntegration) {
209 attributeValue = (value: any);
210 } else {
211 + if (__DEV__) {
212 + checkAttributeStringCoercion(value, attributeName);
213 + }
214 attributeValue = '' + (value: any);
215 }
216 if (propertyInfo.sanitizeURL) {
packages/react-dom/src/client/ReactDOMComponent.js
+4
@@ -14,6 +14,7 @@ import {
14
15 import {canUseDOM} from 'shared/ExecutionEnvironment';
16 import hasOwnProperty from 'shared/hasOwnProperty';
17 +import {checkHtmlStringCoercion} from 'shared/CheckStringCoercion';
18
19 import {
20 getValueForAttribute,
@@ -139,6 +140,9 @@ if (__DEV__) {
140 const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g;
141
142 normalizeMarkupForTextOrAttribute = function(markup: mixed): string {
143 + if (__DEV__) {
144 + checkHtmlStringCoercion(markup);
145 + }
146 const markupString =
147 typeof markup === 'string' ? markup : '' + (markup: any);
148 return markupString
packages/react-dom/src/client/ReactDOMInput.js
+4
@@ -18,6 +18,7 @@ import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes
18 import {updateValueIfChanged} from './inputValueTracking';
19 import getActiveElement from './getActiveElement';
20 import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
21 +import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
22
23 import type {ToStringValue} from './ToStringValue';
24
@@ -365,6 +366,9 @@ function updateNamedCousins(rootNode, props) {
366 // the input might not even be in a form. It might not even be in the
367 // document. Let's just use the local `querySelectorAll` to ensure we don't
368 // miss anything.
369 + if (__DEV__) {
370 + checkAttributeStringCoercion(name, 'name');
371 + }
372 const group = queryRoot.querySelectorAll(
373 'input[name=' + JSON.stringify('' + name) + '][type="radio"]',
374 );
packages/react-dom/src/client/ToStringValue.js
+9 -1
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import {checkFormFieldValueStringCoercion} from 'shared/CheckStringCoercion';
11 +
12 export opaque type ToStringValue =
13 | boolean
14 | number
@@ -19,6 +21,8 @@ export opaque type ToStringValue =
21 // around this limitation, we use an opaque type that can only be obtained by
22 // passing the value through getToStringValue first.
23 export function toString(value: ToStringValue): string {
24 + // The coercion safety check is performed in getToStringValue().
25 + // eslint-disable-next-line react-internal/safe-string-coercion
26 return '' + (value: any);
27 }
28
@@ -26,10 +30,14 @@ export function getToStringValue(value: mixed): ToStringValue {
30 switch (typeof value) {
31 case 'boolean':
32 case 'number':
29 - case 'object':
33 case 'string':
34 case 'undefined':
35 return value;
36 + case 'object':
37 + if (__DEV__) {
38 + checkFormFieldValueStringCoercion(value);
39 + }
40 + return value;
41 default:
42 // function, symbol are assigned as empty strings
43 return '';
packages/react-dom/src/client/inputValueTracking.js
+11
@@ -7,6 +7,8 @@
7 * @flow
8 */
9
10 +import {checkFormFieldValueStringCoercion} from 'shared/CheckStringCoercion';
11 +
12 type ValueTracker = {|
13 getValue(): string,
14 setValue(value: string): void,
@@ -55,6 +57,9 @@ function trackValueOnNode(node: any): ?ValueTracker {
57 valueField,
58 );
59
60 + if (__DEV__) {
61 + checkFormFieldValueStringCoercion(node[valueField]);
62 + }
63 let currentValue = '' + node[valueField];
64
65 // if someone has already defined a value or Safari, then bail
@@ -76,6 +81,9 @@ function trackValueOnNode(node: any): ?ValueTracker {
81 return get.call(this);
82 },
83 set: function(value) {
84 + if (__DEV__) {
85 + checkFormFieldValueStringCoercion(value);
86 + }
87 currentValue = '' + value;
88 set.call(this, value);
89 },
@@ -93,6 +101,9 @@ function trackValueOnNode(node: any): ?ValueTracker {
101 return currentValue;
102 },
103 setValue(value) {
104 + if (__DEV__) {
105 + checkFormFieldValueStringCoercion(value);
106 + }
107 currentValue = '' + value;
108 },
109 stopTracking() {
packages/react-dom/src/server/DOMMarkupOperations.js
+4
@@ -16,6 +16,7 @@ import {
16 shouldRemoveAttribute,
17 } from '../shared/DOMProperty';
18 import sanitizeURL from '../shared/sanitizeURL';
19 +import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
20 import quoteAttributeValueForBrowser from './quoteAttributeValueForBrowser';
21
22 /**
@@ -44,6 +45,9 @@ export function createMarkupForProperty(name: string, value: mixed): string {
45 return attributeName + '=""';
46 } else {
47 if (propertyInfo.sanitizeURL) {
48 + if (__DEV__) {
49 + checkAttributeStringCoercion(value, attributeName);
50 + }
51 value = '' + (value: any);
52 sanitizeURL(value);
53 }
packages/react-dom/src/server/ReactDOMServerFormatConfig.js
+35
@@ -9,6 +9,12 @@
9
10 import type {ReactNodeList} from 'shared/ReactTypes';
11
12 +import {
13 + checkHtmlStringCoercion,
14 + checkCSSPropertyStringCoercion,
15 + checkAttributeStringCoercion,
16 +} from 'shared/CheckStringCoercion';
17 +
18 import {Children} from 'react';
19
20 import {enableFilterEmptyStringAttributesDOM} from 'shared/ReactFeatureFlags';
@@ -272,6 +278,9 @@ function pushStyle(
278 const isCustomProperty = styleName.indexOf('--') === 0;
279 if (isCustomProperty) {
280 nameChunk = stringToChunk(escapeTextForBrowser(styleName));
281 + if (__DEV__) {
282 + checkCSSPropertyStringCoercion(styleValue, styleName);
283 + }
284 valueChunk = stringToChunk(
285 escapeTextForBrowser(('' + styleValue).trim()),
286 );
@@ -291,6 +300,9 @@ function pushStyle(
300 valueChunk = stringToChunk('' + styleValue);
301 }
302 } else {
303 + if (__DEV__) {
304 + checkCSSPropertyStringCoercion(styleValue, styleName);
305 + }
306 valueChunk = stringToChunk(
307 escapeTextForBrowser(('' + styleValue).trim()),
308 );
@@ -439,6 +451,9 @@ function pushAttribute(
451 break;
452 default:
453 if (propertyInfo.sanitizeURL) {
454 + if (__DEV__) {
455 + checkAttributeStringCoercion(value, attributeName);
456 + }
457 value = '' + (value: any);
458 sanitizeURL(value);
459 }
@@ -496,6 +511,9 @@ function pushInnerHTML(
511 );
512 const html = innerHTML.__html;
513 if (html !== null && html !== undefined) {
514 + if (__DEV__) {
515 + checkHtmlStringCoercion(html);
516 + }
517 target.push(stringToChunk('' + html));
518 }
519 }
@@ -679,6 +697,9 @@ function pushStartOption(
697 if (selectedValue !== null) {
698 let stringValue;
699 if (value !== null) {
700 + if (__DEV__) {
701 + checkAttributeStringCoercion(value, 'value');
702 + }
703 stringValue = '' + value;
704 } else {
705 if (__DEV__) {
@@ -697,6 +718,9 @@ function pushStartOption(
718 if (isArray(selectedValue)) {
719 // multiple
720 for (let i = 0; i < selectedValue.length; i++) {
721 + if (__DEV__) {
722 + checkAttributeStringCoercion(selectedValue[i], 'value');
723 + }
724 const v = '' + selectedValue[i];
725 if (v === stringValue) {
726 target.push(selectedMarkerAttribute);
@@ -895,8 +919,16 @@ function pushStartTextArea(
919 children.length <= 1,
920 '<textarea> can only have at most one child.',
921 );
922 + // TODO: remove the coercion and the DEV check below because it will
923 + // always be overwritten by the coercion several lines below it. #22309
924 + if (__DEV__) {
925 + checkHtmlStringCoercion(children[0]);
926 + }
927 value = '' + children[0];
928 }
929 + if (__DEV__) {
930 + checkHtmlStringCoercion(children);
931 + }
932 value = '' + children;
933 }
934
@@ -1142,6 +1174,9 @@ function pushStartPreformattedElement(
1174 if (typeof html === 'string' && html.length > 0 && html[0] === '\n') {
1175 target.push(leadingNewline, stringToChunk(html));
1176 } else {
1177 + if (__DEV__) {
1178 + checkHtmlStringCoercion(html);
1179 + }
1180 target.push(stringToChunk('' + html));
1181 }
1182 }
packages/react-dom/src/server/ReactPartialRenderer.js
+19
@@ -25,6 +25,10 @@ import {
25 enableSuspenseServerRenderer,
26 enableScopeAPI,
27 } from 'shared/ReactFeatureFlags';
28 +import {
29 + checkPropStringCoercion,
30 + checkFormFieldValueStringCoercion,
31 +} from 'shared/CheckStringCoercion';
32
33 import {
34 REACT_DEBUG_TRACING_MODE_TYPE,
@@ -1472,6 +1476,9 @@ class ReactDOMServerRenderer {
1476 textareaChildren = textareaChildren[0];
1477 }
1478
1479 + if (__DEV__) {
1480 + checkPropStringCoercion(textareaChildren, 'children');
1481 + }
1482 defaultValue = '' + textareaChildren;
1483 }
1484 if (defaultValue == null) {
@@ -1480,6 +1487,9 @@ class ReactDOMServerRenderer {
1487 initialValue = defaultValue;
1488 }
1489
1490 + if (__DEV__) {
1491 + checkFormFieldValueStringCoercion(initialValue);
1492 + }
1493 props = Object.assign({}, props, {
1494 value: undefined,
1495 children: '' + initialValue,
@@ -1535,6 +1545,9 @@ class ReactDOMServerRenderer {
1545 if (selectValue != null) {
1546 let value;
1547 if (props.value != null) {
1548 + if (__DEV__) {
1549 + checkFormFieldValueStringCoercion(props.value);
1550 + }
1551 value = props.value + '';
1552 } else {
1553 if (__DEV__) {
@@ -1554,12 +1567,18 @@ class ReactDOMServerRenderer {
1567 if (isArray(selectValue)) {
1568 // multiple
1569 for (let j = 0; j < selectValue.length; j++) {
1570 + if (__DEV__) {
1571 + checkFormFieldValueStringCoercion(selectValue[j]);
1572 + }
1573 if ('' + selectValue[j] === value) {
1574 selected = true;
1575 break;
1576 }
1577 }
1578 } else {
1579 + if (__DEV__) {
1580 + checkFormFieldValueStringCoercion(selectValue);
1581 + }
1582 selected = '' + selectValue === value;
1583 }
1584
packages/react-dom/src/server/escapeTextForBrowser.js
+5
@@ -36,6 +36,8 @@
36 * @private
37 */
38
39 +import {checkHtmlStringCoercion} from 'shared/CheckStringCoercion';
40 +
41 const matchHtmlRegExp = /["'&<>]/;
42
43 /**
@@ -47,6 +49,9 @@ const matchHtmlRegExp = /["'&<>]/;
49 */
50
51 function escapeHtml(string) {
52 + if (__DEV__) {
53 + checkHtmlStringCoercion(string);
54 + }
55 const str = '' + string;
56 const match = matchHtmlRegExp.exec(str);
57
packages/react-dom/src/shared/dangerousStyleValue.js
+4
@@ -6,6 +6,7 @@
6 */
7
8 import {isUnitlessNumber} from './CSSProperty';
9 +import {checkCSSPropertyStringCoercion} from 'shared/CheckStringCoercion';
10
11 /**
12 * Convert a value into the proper css writable value. The style name `name`
@@ -41,6 +42,9 @@ function dangerousStyleValue(name, value, isCustomProperty) {
42 return value + 'px'; // Presumes implicit 'px' suffix for unitless numbers
43 }
44
45 + if (__DEV__) {
46 + checkCSSPropertyStringCoercion(value, name);
47 + }
48 return ('' + value).trim();
49 }
50
packages/react-dom/src/test-utils/ReactTestUtils.js
+1 -1
@@ -95,7 +95,7 @@ function validateClassInstance(inst, methodName) {
95 return;
96 }
97 let received;
98 - const stringified = '' + inst;
98 + const stringified = String(inst);
99 if (isArray(inst)) {
100 received = 'an array';
101 } else if (inst && inst.nodeType === ELEMENT_NODE && inst.tagName) {
packages/react-noop-renderer/src/createReactNoop.js
+16 -1
@@ -22,6 +22,7 @@ import type {RootTag} from 'react-reconciler/src/ReactRootTags';
22 import * as Scheduler from 'scheduler/unstable_mock';
23 import {REACT_FRAGMENT_TYPE, REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
24 import isArray from 'shared/isArray';
25 +import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
26 import {
27 DefaultEventPriority,
28 IdleEventPriority,
@@ -215,6 +216,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
216 keepChildren: boolean,
217 recyclableInstance: null | Instance,
218 ): Instance {
219 + if (__DEV__) {
220 + checkPropStringCoercion(newProps.children, 'children');
221 + }
222 const clone = {
223 id: instance.id,
224 type: type,
@@ -293,13 +297,21 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
297 if (type === 'errorInCompletePhase') {
298 throw new Error('Error in host config.');
299 }
300 + if (__DEV__) {
301 + // The `if` statement here prevents auto-disabling of the safe coercion
302 + // ESLint rule, so we must manually disable it below.
303 + if (shouldSetTextContent(type, props)) {
304 + checkPropStringCoercion(props.children, 'children');
305 + }
306 + }
307 const inst = {
308 id: instanceCounter++,
309 type: type,
310 children: [],
311 parent: -1,
312 text: shouldSetTextContent(type, props)
302 - ? computeText((props.children: any) + '', hostContext)
313 + ? // eslint-disable-next-line react-internal/safe-string-coercion
314 + computeText((props.children: any) + '', hostContext)
315 : null,
316 prop: props.prop,
317 hidden: !!props.hidden,
@@ -481,6 +493,9 @@ function createReactNoop(reconciler: Function, useMutation: boolean) {
493 instance.prop = newProps.prop;
494 instance.hidden = !!newProps.hidden;
495 if (shouldSetTextContent(type, newProps)) {
496 + if (__DEV__) {
497 + checkPropStringCoercion(newProps.children, 'children');
498 + }
499 instance.text = computeText(
500 (newProps.children: any) + '',
501 instance.context,
packages/react-reconciler/src/ReactChildFiber.new.js
+4
@@ -28,6 +28,7 @@ import {
28 warnAboutStringRefs,
29 enableLazyElements,
30 } from 'shared/ReactFeatureFlags';
31 +import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
32
33 import {
34 createWorkInProgress,
@@ -163,6 +164,9 @@ function coerceRef(
164 'bug in React. Please file an issue.',
165 mixedRef,
166 );
167 + if (__DEV__) {
168 + checkPropStringCoercion(mixedRef, 'ref');
169 + }
170 const stringRef = '' + mixedRef;
171 // Check if previous string ref matches new string ref
172 if (
packages/react-reconciler/src/ReactChildFiber.old.js
+4
@@ -28,6 +28,7 @@ import {
28 warnAboutStringRefs,
29 enableLazyElements,
30 } from 'shared/ReactFeatureFlags';
31 +import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
32
33 import {
34 createWorkInProgress,
@@ -163,6 +164,9 @@ function coerceRef(
164 'bug in React. Please file an issue.',
165 mixedRef,
166 );
167 + if (__DEV__) {
168 + checkPropStringCoercion(mixedRef, 'ref');
169 + }
170 const stringRef = '' + mixedRef;
171 // Check if previous string ref matches new string ref
172 if (
packages/react-reconciler/src/ReactPortal.js
+4
@@ -8,6 +8,7 @@
8 */
9
10 import {REACT_PORTAL_TYPE} from 'shared/ReactSymbols';
11 +import {checkKeyStringCoercion} from 'shared/CheckStringCoercion';
12
13 import type {ReactNodeList, ReactPortal} from 'shared/ReactTypes';
14
@@ -18,6 +19,9 @@ export function createPortal(
19 implementation: any,
20 key: ?string = null,
21 ): ReactPortal {
22 + if (__DEV__) {
23 + checkKeyStringCoercion(key);
24 + }
25 return {
26 // This tag allow us to uniquely identify this as a React Portal
27 $$typeof: REACT_PORTAL_TYPE,
packages/react-reconciler/src/__tests__/ReactCache-test.js
+1 -1
@@ -875,7 +875,7 @@ describe('ReactCache', () => {
875 function Unrelated() {
876 const [count, _updateUnrelated] = useState(0);
877 updateUnrelated = _updateUnrelated;
878 - return <Text text={count + ''} />;
878 + return <Text text={String(count)} />;
879 }
880
881 const root = ReactNoop.createRoot();
packages/react-reconciler/src/__tests__/ReactHooksWithNoopRenderer-test.js
+8 -8
@@ -2754,7 +2754,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2754 Scheduler.unstable_yieldValue(
2755 `Create insertion [current: ${committedText}]`,
2756 );
2757 - committedText = props.count + '';
2757 + committedText = String(props.count);
2758 return () => {
2759 Scheduler.unstable_yieldValue(
2760 `Destroy insertion [current: ${committedText}]`,
@@ -2817,7 +2817,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2817 Scheduler.unstable_yieldValue(
2818 `Create insertion [current: ${committedText}]`,
2819 );
2820 - committedText = props.count + '';
2820 + committedText = String(props.count);
2821 return () => {
2822 Scheduler.unstable_yieldValue(
2823 `Destroy insertion [current: ${committedText}]`,
@@ -2828,7 +2828,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2828 Scheduler.unstable_yieldValue(
2829 `Create layout [current: ${committedText}]`,
2830 );
2831 - committedText = props.count + '';
2831 + committedText = String(props.count);
2832 return () => {
2833 Scheduler.unstable_yieldValue(
2834 `Destroy layout [current: ${committedText}]`,
@@ -2886,7 +2886,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2886 Scheduler.unstable_yieldValue(
2887 `Create Insertion 1 for Component A [A: ${committedA}, B: ${committedB}]`,
2888 );
2889 - committedA = props.count + '';
2889 + committedA = String(props.count);
2890 return () => {
2891 Scheduler.unstable_yieldValue(
2892 `Destroy Insertion 1 for Component A [A: ${committedA}, B: ${committedB}]`,
@@ -2897,7 +2897,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2897 Scheduler.unstable_yieldValue(
2898 `Create Insertion 2 for Component A [A: ${committedA}, B: ${committedB}]`,
2899 );
2900 - committedA = props.count + '';
2900 + committedA = String(props.count);
2901 return () => {
2902 Scheduler.unstable_yieldValue(
2903 `Destroy Insertion 2 for Component A [A: ${committedA}, B: ${committedB}]`,
@@ -2934,7 +2934,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2934 Scheduler.unstable_yieldValue(
2935 `Create Insertion 1 for Component B [A: ${committedA}, B: ${committedB}]`,
2936 );
2937 - committedB = props.count + '';
2937 + committedB = String(props.count);
2938 return () => {
2939 Scheduler.unstable_yieldValue(
2940 `Destroy Insertion 1 for Component B [A: ${committedA}, B: ${committedB}]`,
@@ -2945,7 +2945,7 @@ describe('ReactHooksWithNoopRenderer', () => {
2945 Scheduler.unstable_yieldValue(
2946 `Create Insertion 2 for Component B [A: ${committedA}, B: ${committedB}]`,
2947 );
2948 - committedB = props.count + '';
2948 + committedB = String(props.count);
2949 return () => {
2950 Scheduler.unstable_yieldValue(
2951 `Destroy Insertion 2 for Component B [A: ${committedA}, B: ${committedB}]`,
@@ -3140,7 +3140,7 @@ describe('ReactHooksWithNoopRenderer', () => {
3140 useLayoutEffect(() => {
3141 // Normally this would go in a mutation effect, but this test
3142 // intentionally omits a mutation effect.
3143 - committedText = props.count + '';
3143 + committedText = String(props.count);
3144
3145 Scheduler.unstable_yieldValue(
3146 `Mount layout [current: ${committedText}]`,
packages/react-reconciler/src/__tests__/ReactIncremental-test.js
+2 -2
@@ -1110,7 +1110,7 @@ describe('ReactIncremental', () => {
1110 }
1111 render() {
1112 Scheduler.unstable_yieldValue('Bar:' + this.props.x);
1113 - return <span prop={'' + (this.props.x === this.state.y)} />;
1113 + return <span prop={String(this.props.x === this.state.y)} />;
1114 }
1115 }
1116
@@ -1159,7 +1159,7 @@ describe('ReactIncremental', () => {
1159 Scheduler.unstable_yieldValue(
1160 'Bar:' + this.props.x + '-' + this.props.step,
1161 );
1162 - return <span prop={'' + (this.props.x === this.state.y)} />;
1162 + return <span prop={String(this.props.x === this.state.y)} />;
1163 }
1164 }
1165
packages/react-reconciler/src/__tests__/ReactMemo-test.js
+1 -1
@@ -223,7 +223,7 @@ describe('memo', () => {
223 class CounterInner extends React.Component {
224 static defaultProps = {suffix: '!'};
225 render() {
226 - return <Text text={this.props.count + '' + this.props.suffix} />;
226 + return <Text text={this.props.count + String(this.props.suffix)} />;
227 }
228 }
229 const Counter = memo(CounterInner);
packages/react-reconciler/src/__tests__/ReactPersistent-test.js
+1 -1
@@ -27,7 +27,7 @@ describe('ReactPersistent', () => {
27 function createPortal(children, containerInfo, implementation, key) {
28 return {
29 $$typeof: Symbol.for('react.portal'),
30 - key: key == null ? null : '' + key,
30 + key: key == null ? null : String(key),
31 children,
32 containerInfo,
33 implementation,
packages/react-server/src/ReactFlightServer.js
+5 -3
@@ -296,7 +296,7 @@ function describeValueForErrorMessage(value: ReactModel): string {
296 case 'function':
297 return 'function';
298 default:
299 - // eslint-disable-next-line
299 + // eslint-disable-next-line react-internal/safe-string-coercion
300 return String(value);
301 }
302 }
@@ -615,8 +615,10 @@ function emitErrorChunk(request: Request, id: number, error: mixed): void {
615 let stack = '';
616 try {
617 if (error instanceof Error) {
618 - message = '' + error.message;
619 - stack = '' + error.stack;
618 + // eslint-disable-next-line react-internal/safe-string-coercion
619 + message = String(error.message);
620 + // eslint-disable-next-line react-internal/safe-string-coercion
621 + stack = String(error.stack);
622 } else {
623 message = 'Error: ' + (error: any);
624 }
packages/react-test-renderer/src/ReactTestRenderer.js
+4
@@ -44,6 +44,7 @@ import invariant from 'shared/invariant';
44 import isArray from 'shared/isArray';
45 import getComponentNameFromType from 'shared/getComponentNameFromType';
46 import ReactVersion from 'shared/ReactVersion';
47 +import {checkPropStringCoercion} from 'shared/CheckStringCoercion';
48
49 import {getPublicInstance} from './ReactTestHostConfig';
50 import {ConcurrentRoot, LegacyRoot} from 'react-reconciler/src/ReactRootTags';
@@ -251,6 +252,9 @@ function getChildren(parent: Fiber) {
252 if (validWrapperTypes.has(node.tag)) {
253 children.push(wrapFiber(node));
254 } else if (node.tag === HostText) {
255 + if (__DEV__) {
256 + checkPropStringCoercion(node.memoizedProps, 'memoizedProps');
257 + }
258 children.push('' + node.memoizedProps);
259 } else {
260 descend = true;
packages/react/src/ReactChildren.js
+15 -1
@@ -16,6 +16,7 @@ import {
16 REACT_ELEMENT_TYPE,
17 REACT_PORTAL_TYPE,
18 } from 'shared/ReactSymbols';
19 +import {checkKeyStringCoercion} from 'shared/CheckStringCoercion';
20
21 import {isValidElement, cloneAndReplaceKey} from './ReactElement';
22
@@ -65,6 +66,9 @@ function getElementKey(element: any, index: number): string {
66 // that we don't block potential future ES APIs.
67 if (typeof element === 'object' && element !== null && element.key != null) {
68 // Explicit key
69 + if (__DEV__) {
70 + checkKeyStringCoercion(element.key);
71 + }
72 return escape('' + element.key);
73 }
74 // Implicit key determined by the index in the set
@@ -119,6 +123,14 @@ function mapIntoArray(
123 mapIntoArray(mappedChild, array, escapedChildKey, '', c => c);
124 } else if (mappedChild != null) {
125 if (isValidElement(mappedChild)) {
126 + if (__DEV__) {
127 + // The `if` statement here prevents auto-disabling of the safe
128 + // coercion ESLint rule, so we must manually disable it below.
129 + // $FlowFixMe Flow incorrectly thinks React.Portal doesn't have a key
130 + if (mappedChild.key && (!child || child.key !== mappedChild.key)) {
131 + checkKeyStringCoercion(mappedChild.key);
132 + }
133 + }
134 mappedChild = cloneAndReplaceKey(
135 mappedChild,
136 // Keep both the (mapped) and old keys if they differ, just as
@@ -127,6 +139,7 @@ function mapIntoArray(
139 // $FlowFixMe Flow incorrectly thinks React.Portal doesn't have a key
140 (mappedChild.key && (!child || child.key !== mappedChild.key)
141 ? // $FlowFixMe Flow incorrectly thinks existing element's key can be a number
142 + // eslint-disable-next-line react-internal/safe-string-coercion
143 escapeUserProvidedKey('' + mappedChild.key) + '/'
144 : '') +
145 childKey,
@@ -190,7 +203,8 @@ function mapIntoArray(
203 );
204 }
205 } else if (type === 'object') {
193 - const childrenString = '' + (children: any);
206 + // eslint-disable-next-line react-internal/safe-string-coercion
207 + const childrenString = String((children: any));
208 invariant(
209 false,
210 'Objects are not valid as a React child (found: %s). ' +
packages/react/src/ReactElement.js
+19
@@ -9,6 +9,7 @@ import getComponentNameFromType from 'shared/getComponentNameFromType';
9 import invariant from 'shared/invariant';
10 import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
11 import hasOwnProperty from 'shared/hasOwnProperty';
12 +import {checkKeyStringCoercion} from 'shared/CheckStringCoercion';
13
14 import ReactCurrentOwner from './ReactCurrentOwner';
15
@@ -222,10 +223,16 @@ export function jsx(type, config, maybeKey) {
223 // <div {...props} key="Hi" />, because we aren't currently able to tell if
224 // key is explicitly declared to be undefined or not.
225 if (maybeKey !== undefined) {
226 + if (__DEV__) {
227 + checkKeyStringCoercion(maybeKey);
228 + }
229 key = '' + maybeKey;
230 }
231
232 if (hasValidKey(config)) {
233 + if (__DEV__) {
234 + checkKeyStringCoercion(config.key);
235 + }
236 key = '' + config.key;
237 }
238
@@ -286,10 +293,16 @@ export function jsxDEV(type, config, maybeKey, source, self) {
293 // <div {...props} key="Hi" />, because we aren't currently able to tell if
294 // key is explicitly declared to be undefined or not.
295 if (maybeKey !== undefined) {
296 + if (__DEV__) {
297 + checkKeyStringCoercion(maybeKey);
298 + }
299 key = '' + maybeKey;
300 }
301
302 if (hasValidKey(config)) {
303 + if (__DEV__) {
304 + checkKeyStringCoercion(config.key);
305 + }
306 key = '' + config.key;
307 }
308
@@ -366,6 +379,9 @@ export function createElement(type, config, children) {
379 }
380 }
381 if (hasValidKey(config)) {
382 + if (__DEV__) {
383 + checkKeyStringCoercion(config.key);
384 + }
385 key = '' + config.key;
386 }
387
@@ -499,6 +515,9 @@ export function cloneElement(element, config, children) {
515 owner = ReactCurrentOwner.current;
516 }
517 if (hasValidKey(config)) {
518 + if (__DEV__) {
519 + checkKeyStringCoercion(config.key);
520 + }
521 key = '' + config.key;
522 }
523
packages/react/src/jsx/ReactJSXElement.js
+13
@@ -9,6 +9,7 @@ import getComponentNameFromType from 'shared/getComponentNameFromType';
9 import ReactSharedInternals from 'shared/ReactSharedInternals';
10 import hasOwnProperty from 'shared/hasOwnProperty';
11 import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
12 +import {checkKeyStringCoercion} from 'shared/CheckStringCoercion';
13
14 const ReactCurrentOwner = ReactSharedInternals.ReactCurrentOwner;
15
@@ -222,10 +223,16 @@ export function jsx(type, config, maybeKey) {
223 // <div {...props} key="Hi" />, because we aren't currently able to tell if
224 // key is explicitly declared to be undefined or not.
225 if (maybeKey !== undefined) {
226 + if (__DEV__) {
227 + checkKeyStringCoercion(maybeKey);
228 + }
229 key = '' + maybeKey;
230 }
231
232 if (hasValidKey(config)) {
233 + if (__DEV__) {
234 + checkKeyStringCoercion(config.key);
235 + }
236 key = '' + config.key;
237 }
238
@@ -287,10 +294,16 @@ export function jsxDEV(type, config, maybeKey, source, self) {
294 // <div {...props} key="Hi" />, because we aren't currently able to tell if
295 // key is explicitly declared to be undefined or not.
296 if (maybeKey !== undefined) {
297 + if (__DEV__) {
298 + checkKeyStringCoercion(maybeKey);
299 + }
300 key = '' + maybeKey;
301 }
302
303 if (hasValidKey(config)) {
304 + if (__DEV__) {
305 + checkKeyStringCoercion(config.key);
306 + }
307 key = '' + config.key;
308 }
309
packages/shared/CheckStringCoercion.js new
+167
@@ -0,0 +1,167 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @flow
8 + */
9 +
10 +import {REACT_OPAQUE_ID_TYPE} from 'shared/ReactSymbols';
11 +
12 +/*
13 + * The `'' + value` pattern (used in in perf-sensitive code) throws for Symbol
14 + * and Temporal.* types. See https://github.com/facebook/react/pull/22064.
15 + *
16 + * The functions in this module will throw an easier-to-understand,
17 + * easier-to-debug exception with a clear errors message message explaining the
18 + * problem. (Instead of a confusing exception thrown inside the implementation
19 + * of the `value` object).
20 + */
21 +
22 +// $FlowFixMe only called in DEV, so void return is not possible.
23 +function typeName(value: mixed): string {
24 + if (__DEV__) {
25 + // toStringTag is needed for namespaced types like Temporal.Instant
26 + const hasToStringTag = typeof Symbol === 'function' && Symbol.toStringTag;
27 + const type =
28 + (hasToStringTag && (value: any)[Symbol.toStringTag]) ||
29 + (value: any).constructor.name ||
30 + 'Object';
31 + return type;
32 + }
33 +}
34 +
35 +// $FlowFixMe only called in DEV, so void return is not possible.
36 +function willCoercionThrow(value: mixed): boolean {
37 + if (__DEV__) {
38 + if (
39 + value !== null &&
40 + typeof value === 'object' &&
41 + value.$$typeof === REACT_OPAQUE_ID_TYPE
42 + ) {
43 + // OpaqueID type is expected to throw, so React will handle it. Not sure if
44 + // it's expected that string coercion will throw, but we'll assume it's OK.
45 + // See https://github.com/facebook/react/issues/20127.
46 + return;
47 + }
48 + try {
49 + testStringCoercion(value);
50 + return false;
51 + } catch (e) {
52 + return true;
53 + }
54 + }
55 +}
56 +
57 +function testStringCoercion(value: mixed) {
58 + // If you ended up here by following an exception call stack, here's what's
59 + // happened: you supplied an object or symbol value to React (as a prop, key,
60 + // DOM attribute, CSS property, string ref, etc.) and when React tried to
61 + // coerce it to a string using `'' + value`, an exception was thrown.
62 + //
63 + // The most common types that will cause this exception are `Symbol` instances
64 + // and Temporal objects like `Temporal.Instant`. But any object that has a
65 + // `valueOf` or `[Symbol.toPrimitive]` method that throws will also cause this
66 + // exception. (Library authors do this to prevent users from using built-in
67 + // numeric operators like `+` or comparison operators like `>=` because custom
68 + // methods are needed to perform accurate arithmetic or comparison.)
69 + //
70 + // To fix the problem, coerce this object or symbol value to a string before
71 + // passing it to React. The most reliable way is usually `String(value)`.
72 + //
73 + // To find which value is throwing, check the browser or debugger console.
74 + // Before this exception was thrown, there should be `console.error` output
75 + // that shows the type (Symbol, Temporal.PlainDate, etc.) that caused the
76 + // problem and how that type was used: key, atrribute, input value prop, etc.
77 + // In most cases, this console output also shows the component and its
78 + // ancestor components where the exception happened.
79 + //
80 + // eslint-disable-next-line react-internal/safe-string-coercion
81 + return '' + (value: any);
82 +}
83 +
84 +export function checkAttributeStringCoercion(
85 + value: mixed,
86 + attributeName: string,
87 +) {
88 + if (__DEV__) {
89 + if (willCoercionThrow(value)) {
90 + console.error(
91 + 'The provided `%s` attribute is an unsupported type %s.' +
92 + ' This value must be coerced to a string before before using it here.',
93 + attributeName,
94 + typeName(value),
95 + );
96 + return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
97 + }
98 + }
99 +}
100 +
101 +export function checkKeyStringCoercion(value: mixed) {
102 + if (__DEV__) {
103 + if (willCoercionThrow(value)) {
104 + console.error(
105 + 'The provided key is an unsupported type %s.' +
106 + ' This value must be coerced to a string before before using it here.',
107 + typeName(value),
108 + );
109 + return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
110 + }
111 + }
112 +}
113 +
114 +export function checkPropStringCoercion(value: mixed, propName: string) {
115 + if (__DEV__) {
116 + if (willCoercionThrow(value)) {
117 + console.error(
118 + 'The provided `%s` prop is an unsupported type %s.' +
119 + ' This value must be coerced to a string before before using it here.',
120 + propName,
121 + typeName(value),
122 + );
123 + return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
124 + }
125 + }
126 +}
127 +
128 +export function checkCSSPropertyStringCoercion(value: mixed, propName: string) {
129 + if (__DEV__) {
130 + if (willCoercionThrow(value)) {
131 + console.error(
132 + 'The provided `%s` CSS property is an unsupported type %s.' +
133 + ' This value must be coerced to a string before before using it here.',
134 + propName,
135 + typeName(value),
136 + );
137 + return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
138 + }
139 + }
140 +}
141 +
142 +export function checkHtmlStringCoercion(value: mixed) {
143 + if (__DEV__) {
144 + if (willCoercionThrow(value)) {
145 + console.error(
146 + 'The provided HTML markup uses a value of unsupported type %s.' +
147 + ' This value must be coerced to a string before before using it here.',
148 + typeName(value),
149 + );
150 + return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
151 + }
152 + }
153 +}
154 +
155 +export function checkFormFieldValueStringCoercion(value: mixed) {
156 + if (__DEV__) {
157 + if (willCoercionThrow(value)) {
158 + console.error(
159 + 'Form field values (value, checked, defaultValue, or defaultChecked props)' +
160 + ' must be strings, not %s.' +
161 + ' This value must be coerced to a string before before using it here.',
162 + typeName(value),
163 + );
164 + return testStringCoercion(value); // throw (to help callers find troubleshooting comments)
165 + }
166 + }
167 +}
packages/shared/consoleWithStackDev.js
+2 -1
@@ -47,7 +47,8 @@ function printWarning(level, format, args) {
47 args = args.concat([stack]);
48 }
49
50 - const argsWithFormat = args.map(item => '' + item);
50 + // eslint-disable-next-line react-internal/safe-string-coercion
51 + const argsWithFormat = args.map(item => String(item));
52 // Careful: RN currently depends on this prefix
53 argsWithFormat.unshift('Warning: ' + format);
54 // We intentionally don't use spread (or .apply) directly because it
scripts/eslint-rules/__tests__/no-primitive-constructors-test.internal.js
+3 -5
@@ -14,7 +14,7 @@ const {RuleTester} = require('eslint');
14 const ruleTester = new RuleTester();
15
16 ruleTester.run('eslint-rules/no-primitive-constructors', rule, {
17 - valid: ['!!obj', "'' + obj", '+string'],
17 + valid: ['!!obj', '+string'],
18 invalid: [
19 {
20 code: 'Boolean(obj)',
@@ -26,13 +26,11 @@ ruleTester.run('eslint-rules/no-primitive-constructors', rule, {
26 ],
27 },
28 {
29 - code: 'String(obj)',
29 + code: 'new String(obj)',
30 errors: [
31 {
32 message:
33 - 'Do not use the String constructor. ' +
34 - 'To cast a value to a string, concat it with the empty string ' +
35 - "(unless it's a symbol, which has different semantics): '' + value",
33 + "Do not use `new String()`. Use String() without new (or '' + value for perf-sensitive code).",
34 },
35 ],
36 },
scripts/eslint-rules/__tests__/safe-string-coercion-test.internal.js new
+265
@@ -0,0 +1,265 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +const rule = require('../safe-string-coercion');
13 +const {RuleTester} = require('eslint');
14 +const ruleTester = new RuleTester();
15 +
16 +const missingDevCheckMessage =
17 + 'Missing DEV check before this string coercion.' +
18 + ' Check should be in this format:\n' +
19 + ' if (__DEV__) {\n' +
20 + ' checkXxxxxStringCoercion(value);\n' +
21 + ' }';
22 +const prevStatementNotDevCheckMessage =
23 + 'The statement before this coercion must be a DEV check in this format:\n' +
24 + ' if (__DEV__) {\n' +
25 + ' checkXxxxxStringCoercion(value);\n' +
26 + ' }';
27 +const message =
28 + "Using `'' + value` or `value + ''` is fast to coerce strings, but may throw." +
29 + ' For prod code, add a DEV check from shared/CheckStringCoercion immediately' +
30 + ' before this coercion.' +
31 + ' For non-prod code and prod error handling, use `String(value)` instead.';
32 +
33 +ruleTester.run('eslint-rules/safe-string-coercion', rule, {
34 + valid: [
35 + {
36 + code: 'String(obj)',
37 + options: [{isProductionUserAppCode: false}],
38 + },
39 + 'String(obj)',
40 + "'a' + obj",
41 + `
42 + function getValueForAttribute(
43 + node,
44 + name,
45 + expected
46 + ) {
47 + if (__DEV__) {
48 + var value = node.getAttribute(name);
49 + if (__DEV__) {
50 + checkAttributeStringCoercion(expected, name);
51 + }
52 + if (value === '' + expected) {
53 + return expected;
54 + }
55 + return value;
56 + }
57 + }
58 + `,
59 + `
60 + if (__DEV__) { checkFormFieldValueStringCoercion (obj) }
61 + '' + obj;
62 + `,
63 + `
64 + function f(a, index) {
65 + if (typeof a === 'object' && a !== null && a.key != null) {
66 + if (__DEV__) {
67 + checkKeyStringCoercion(a.key);
68 + }
69 + return f('' + a.key);
70 + }
71 + return a;
72 + }
73 + `,
74 + "'' + i++",
75 + "'' + +i",
76 + "'' + +i",
77 + "+i + ''",
78 + "if (typeof obj === 'string') { '' + obj }",
79 + "if (typeof obj === 'string' || typeof obj === 'number') { '' + obj }",
80 + "if (typeof obj === 'string' && somethingElse) { '' + obj }",
81 + "if (typeof obj === 'number' && somethingElse) { '' + obj }",
82 + "if (typeof obj === 'bigint' && somethingElse) { '' + obj }",
83 + "if (typeof obj === 'undefined' && somethingElse) { '' + obj }",
84 + "if (typeof nextProp === 'number') { setTextContent(domElement, '' + nextProp); }",
85 + // These twe below are sneaky. The inner `if` is unsafe, but the outer `if`
86 + // ensures that the unsafe code will never be run. It's bad code, but
87 + // doesn't violate this rule.
88 + "if (typeof obj === 'string') { if (typeof obj === 'string' && obj.length) {} else {'' + obj} }",
89 + "if (typeof obj === 'string') if (typeof obj === 'string' && obj.length) {} else {'' + obj}",
90 + ],
91 + invalid: [
92 + {
93 + code: "'' + obj",
94 + errors: [
95 + {
96 + message: missingDevCheckMessage + '\n' + message,
97 + },
98 + ],
99 + },
100 + {
101 + code: "obj + ''",
102 + errors: [
103 + {
104 + message: missingDevCheckMessage + '\n' + message,
105 + },
106 + ],
107 + },
108 + {
109 + code: 'String(obj)',
110 + options: [{isProductionUserAppCode: true}],
111 + errors: [
112 + {
113 + message:
114 + "For perf-sensitive coercion, avoid `String(value)`. Instead, use `'' + value`." +
115 + ' Precede it with a DEV check from shared/CheckStringCoercion' +
116 + ' unless Symbol and Temporal.* values are impossible.' +
117 + ' For non-prod code and prod error handling, use `String(value)` and disable this rule.',
118 + },
119 + ],
120 + },
121 + {
122 + code: "if (typeof obj === 'object') { '' + obj }",
123 + errors: [
124 + {
125 + message: missingDevCheckMessage + '\n' + message,
126 + },
127 + ],
128 + },
129 + {
130 + code:
131 + "if (typeof obj === 'string') { } else if (typeof obj === 'object') {'' + obj}",
132 + errors: [
133 + {
134 + message: missingDevCheckMessage + '\n' + message,
135 + },
136 + ],
137 + },
138 + {
139 + code: "if (typeof obj === 'string' && obj.length) {} else {'' + obj}",
140 + errors: [
141 + {
142 + message: missingDevCheckMessage + '\n' + message,
143 + },
144 + ],
145 + },
146 + {
147 + code: `
148 + if (__D__) { checkFormFieldValueStringCoercion (obj) }
149 + '' + obj;
150 + `,
151 + errors: [
152 + {
153 + message: prevStatementNotDevCheckMessage + '\n' + message,
154 + },
155 + ],
156 + },
157 + {
158 + code: `
159 + if (__DEV__) { checkFormFieldValueStringCoercion (obj) }
160 + '' + notobjj;
161 + `,
162 + errors: [
163 + {
164 + message:
165 + 'Value passed to the check function before this coercion must match the value being coerced.' +
166 + '\n' +
167 + message,
168 + },
169 + ],
170 + },
171 + {
172 + code: `
173 + if (__DEV__) { checkFormFieldValueStringCoercion (obj) }
174 + // must be right before the check call
175 + someOtherCode();
176 + '' + objj;
177 + `,
178 + errors: [
179 + {
180 + message: prevStatementNotDevCheckMessage + '\n' + message,
181 + },
182 + ],
183 + },
184 + {
185 + code: `
186 + if (__DEV__) { chexxxxBadNameCoercion (obj) }
187 + '' + objj;
188 + `,
189 + errors: [
190 + {
191 + message:
192 + 'Missing or invalid check function call before this coercion.' +
193 + ' Expected: call of a function like checkXXXStringCoercion. ' +
194 + prevStatementNotDevCheckMessage +
195 + '\n' +
196 + message,
197 + },
198 + ],
199 + },
200 + {
201 + code: `
202 + if (__DEV__) { }
203 + '' + objj;
204 + `,
205 + errors: [
206 + {
207 + message: prevStatementNotDevCheckMessage + '\n' + message,
208 + },
209 + ],
210 + },
211 + {
212 + code: `
213 + if (__DEV__) { if (x) {} }
214 + '' + objj;
215 + `,
216 + errors: [
217 + {
218 + message:
219 + 'The DEV block before this coercion must only contain an expression. ' +
220 + prevStatementNotDevCheckMessage +
221 + '\n' +
222 + message,
223 + },
224 + ],
225 + },
226 + {
227 + code: `
228 + if (a) {
229 + if (__DEV__) {
230 + // can't have additional code before the check call
231 + if (b) {
232 + checkKeyStringCoercion(obj);
233 + }
234 + }
235 + g = f( c, d + (b ? '' + obj : '') + e);
236 + }
237 + `,
238 + errors: [
239 + {
240 + message:
241 + 'The DEV block before this coercion must only contain an expression. ' +
242 + prevStatementNotDevCheckMessage +
243 + '\n' +
244 + message,
245 + },
246 + ],
247 + },
248 + {
249 + code: `
250 + if (__DEV__) {
251 + checkAttributeStringCoercion(expected, name);
252 + }
253 + // DEV check should be inside the if block
254 + if (a && b) {
255 + f('' + expected);
256 + }
257 + `,
258 + errors: [
259 + {
260 + message: missingDevCheckMessage + '\n' + message,
261 + },
262 + ],
263 + },
264 + ],
265 +});
scripts/eslint-rules/index.js
+1
@@ -9,5 +9,6 @@ module.exports = {
9 'no-production-logging': require('./no-production-logging'),
10 'no-cross-fork-imports': require('./no-cross-fork-imports'),
11 'no-cross-fork-types': require('./no-cross-fork-types'),
12 + 'safe-string-coercion': require('./safe-string-coercion'),
13 },
14 };
scripts/eslint-rules/no-primitive-constructors.js
+6 -7
@@ -29,13 +29,12 @@ module.exports = {
29 );
30 break;
31 case 'String':
32 - report(
33 - node,
34 - name,
35 - 'To cast a value to a string, concat it with the empty string ' +
36 - "(unless it's a symbol, which has different semantics): " +
37 - "'' + value"
38 - );
32 + if (node.type === 'NewExpression') {
33 + context.report(
34 + node,
35 + "Do not use `new String()`. Use String() without new (or '' + value for perf-sensitive code)."
36 + );
37 + }
38 break;
39 case 'Number':
40 report(
scripts/eslint-rules/safe-string-coercion.js new
+344
@@ -0,0 +1,344 @@
1 +/**
2 + * Copyright (c) Facebook, Inc. and its affiliates.
3 + *
4 + * This source code is licensed under the MIT license found in the
5 + * LICENSE file in the root directory of this source tree.
6 + *
7 + * @emails react-core
8 + */
9 +
10 +'use strict';
11 +
12 +function isEmptyLiteral(node) {
13 + return (
14 + node.type === 'Literal' &&
15 + typeof node.value === 'string' &&
16 + node.value === ''
17 + );
18 +}
19 +
20 +// Symbols and Temporal.* objects will throw when using `'' + value`, but that
21 +// pattern can be faster than `String(value)` because JS engines can optimize
22 +// `+` better in some cases. Therefore, in perf-sensitive production codepaths
23 +// we require using `'' + value` for string coercion. The only exception is prod
24 +// error handling code, because it's bad to crash while assembling an error
25 +// message or call stack! Also, error-handling code isn't usually perf-critical.
26 +//
27 +// Non-production codepaths (tests, devtools extension, build tools, etc.)
28 +// should use `String(value)` because it will never crash and the (small) perf
29 +// difference doesn't matter enough for non-prod use cases.
30 +//
31 +// This rule assists enforcing these guidelines:
32 +// * `'' + value` is flagged with a message to remind developers to add a DEV
33 +// check from shared/CheckStringCoercion.js to make sure that the user gets a
34 +// clear error message in DEV is the coercion will throw. These checks are not
35 +// needed if throwing is not possible, e.g. if the value is already known to
36 +// be a string or number.
37 +// * `String(value)` is flagged only if the `isProductionUserAppCode` option
38 +// is set. Set this option for prod code files, and don't set it for non-prod
39 +// files.
40 +
41 +const ignoreKeys = [
42 + 'range',
43 + 'raw',
44 + 'parent',
45 + 'loc',
46 + 'start',
47 + 'end',
48 + '_babelType',
49 + 'leadingComments',
50 + 'trailingComments',
51 +];
52 +function astReplacer(key, value) {
53 + return ignoreKeys.includes(key) ? undefined : value;
54 +}
55 +
56 +/**
57 + * Simplistic comparison between AST node. Only the following patterns are
58 + * supported because that's almost all (all?) usage in React:
59 + * - Identifiers, e.g. `foo`
60 + * - Member access, e.g. `foo.bar`
61 + * - Array access with numeric literal, e.g. `foo[0]`
62 + */
63 +function isEquivalentCode(node1, node2) {
64 + return (
65 + JSON.stringify(node1, astReplacer) === JSON.stringify(node2, astReplacer)
66 + );
67 +}
68 +
69 +function isDescendant(node, maybeParentNode) {
70 + let parent = node.parent;
71 + while (parent) {
72 + if (!parent) {
73 + return false;
74 + }
75 + if (parent === maybeParentNode) {
76 + return true;
77 + }
78 + parent = parent.parent;
79 + }
80 + return false;
81 +}
82 +
83 +function isSafeTypeofExpression(originalValueNode, node) {
84 + if (node.type === 'BinaryExpression') {
85 + // Example: typeof foo === 'string'
86 + if (node.operator !== '===') {
87 + return false;
88 + }
89 + const {left, right} = node;
90 +
91 + // left must be `typeof original`
92 + if (left.type !== 'UnaryExpression' || left.operator !== 'typeof') {
93 + return false;
94 + }
95 + if (!isEquivalentCode(left.argument, originalValueNode)) {
96 + return false;
97 + }
98 + // right must be a literal value of a safe type
99 + const safeTypes = ['string', 'number', 'boolean', 'undefined', 'bigint'];
100 + if (right.type !== 'Literal' || !safeTypes.includes(right.value)) {
101 + return false;
102 + }
103 + return true;
104 + } else if (node.type === 'LogicalExpression') {
105 + // Examples:
106 + // * typeof foo === 'string' && typeof foo === 'number
107 + // * typeof foo === 'string' && someOtherTest
108 + if (node.operator === '&&') {
109 + return (
110 + isSafeTypeofExpression(originalValueNode, node.left) ||
111 + isSafeTypeofExpression(originalValueNode, node.right)
112 + );
113 + } else if (node.operator === '||') {
114 + return (
115 + isSafeTypeofExpression(originalValueNode, node.left) &&
116 + isSafeTypeofExpression(originalValueNode, node.right)
117 + );
118 + }
119 + }
120 + return false;
121 +}
122 +
123 +/**
124 + Returns true if the code is inside an `if` block that validates the value
125 + excludes symbols and objects. Examples:
126 + * if (typeof value === 'string') { }
127 + * if (typeof value === 'string' || typeof value === 'number') { }
128 + * if (typeof value === 'string' || someOtherTest) { }
129 +
130 + @param - originalValueNode Top-level expression to test. Kept unchanged during
131 + recursion.
132 + @param - node Expression to test at current recursion level. Will be undefined
133 + on non-recursive call.
134 +*/
135 +function isInSafeTypeofBlock(originalValueNode, node) {
136 + if (!node) {
137 + node = originalValueNode;
138 + }
139 + let parent = node.parent;
140 + while (parent) {
141 + if (!parent) {
142 + return false;
143 + }
144 + // Normally, if the parent block is inside a type-safe `if` statement,
145 + // then all child code is also type-safe. But there's a quirky case we
146 + // need to defend against:
147 + // if (typeof obj === 'string') { } else if (typeof obj === 'object') {'' + obj}
148 + // if (typeof obj === 'string') { } else {'' + obj}
149 + // In that code above, the `if` block is safe, but the `else` block is
150 + // unsafe and should report. But the AST parent of the `else` clause is the
151 + // `if` statement. This is the one case where the parent doesn't confer
152 + // safety onto the child. The code below identifies that case and keeps
153 + // moving up the tree until we get out of the `else`'s parent `if` block.
154 + // This ensures that we don't use any of these "parents" (really siblings)
155 + // to confer safety onto the current node.
156 + if (
157 + parent.type === 'IfStatement' &&
158 + !isDescendant(originalValueNode, parent.alternate)
159 + ) {
160 + const test = parent.test;
161 + if (isSafeTypeofExpression(originalValueNode, test)) {
162 + return true;
163 + }
164 + }
165 + parent = parent.parent;
166 + }
167 +}
168 +
169 +const missingDevCheckMessage =
170 + 'Missing DEV check before this string coercion.' +
171 + ' Check should be in this format:\n' +
172 + ' if (__DEV__) {\n' +
173 + ' checkXxxxxStringCoercion(value);\n' +
174 + ' }';
175 +
176 +const prevStatementNotDevCheckMessage =
177 + 'The statement before this coercion must be a DEV check in this format:\n' +
178 + ' if (__DEV__) {\n' +
179 + ' checkXxxxxStringCoercion(value);\n' +
180 + ' }';
181 +
182 +/**
183 + * Does this node have an "is coercion safe?" DEV check
184 + * in the same block?
185 + */
186 +function hasCoercionCheck(node) {
187 + // find the containing statement
188 + let topOfExpression = node;
189 + while (!topOfExpression.parent.body) {
190 + topOfExpression = topOfExpression.parent;
191 + if (!topOfExpression) {
192 + return 'Cannot find top of expression.';
193 + }
194 + }
195 + const containingBlock = topOfExpression.parent.body;
196 + const index = containingBlock.indexOf(topOfExpression);
197 + if (index <= 0) {
198 + return missingDevCheckMessage;
199 + }
200 + const prev = containingBlock[index - 1];
201 +
202 + // The previous statement is expected to be like this:
203 + // if (__DEV__) {
204 + // checkFormFieldValueStringCoercion(foo);
205 + // }
206 + // where `foo` must be equivalent to `node` (which is the
207 + // mixed value being coerced to a string).
208 + if (
209 + prev.type !== 'IfStatement' ||
210 + prev.test.type !== 'Identifier' ||
211 + prev.test.name !== '__DEV__'
212 + ) {
213 + return prevStatementNotDevCheckMessage;
214 + }
215 + let maybeCheckNode = prev.consequent;
216 + if (maybeCheckNode.type === 'BlockStatement') {
217 + const body = maybeCheckNode.body;
218 + if (body.length === 0) {
219 + return prevStatementNotDevCheckMessage;
220 + }
221 + if (body.length !== 1) {
222 + return (
223 + 'Too many statements in DEV block before this coercion.' +
224 + ' Expected only one (the check function call). ' +
225 + prevStatementNotDevCheckMessage
226 + );
227 + }
228 + maybeCheckNode = body[0];
229 + }
230 +
231 + if (maybeCheckNode.type !== 'ExpressionStatement') {
232 + return (
233 + 'The DEV block before this coercion must only contain an expression. ' +
234 + prevStatementNotDevCheckMessage
235 + );
236 + }
237 +
238 + const call = maybeCheckNode.expression;
239 + if (
240 + call.type !== 'CallExpression' ||
241 + call.callee.type !== 'Identifier' ||
242 + !/^check(\w+?)StringCoercion$/.test(call.callee.name) ||
243 + !call.arguments.length
244 + ) {
245 + // `maybeCheckNode` should be a call of a function named checkXXXStringCoercion
246 + return (
247 + 'Missing or invalid check function call before this coercion.' +
248 + ' Expected: call of a function like checkXXXStringCoercion. ' +
249 + prevStatementNotDevCheckMessage
250 + );
251 + }
252 +
253 + const same = isEquivalentCode(call.arguments[0], node);
254 + if (!same) {
255 + return (
256 + 'Value passed to the check function before this coercion' +
257 + ' must match the value being coerced.'
258 + );
259 + }
260 +}
261 +
262 +function plusEmptyString(context, node) {
263 + if (
264 + node.operator === '+' &&
265 + (isEmptyLiteral(node.left) || isEmptyLiteral(node.right))
266 + ) {
267 + let valueToTest = isEmptyLiteral(node.left) ? node.right : node.left;
268 + if (valueToTest.type === 'TypeCastExpression' && valueToTest.expression) {
269 + valueToTest = valueToTest.expression;
270 + }
271 +
272 + if (
273 + valueToTest.type === 'Identifier' &&
274 + ['i', 'idx', 'lineNumber'].includes(valueToTest.name)
275 + ) {
276 + // Common non-object variable names are assumed to be safe
277 + return;
278 + }
279 + if (
280 + valueToTest.type === 'UnaryExpression' ||
281 + valueToTest.type === 'UpdateExpression'
282 + ) {
283 + // Any unary expression will return a non-object, non-symbol type.
284 + return;
285 + }
286 + if (isInSafeTypeofBlock(valueToTest)) {
287 + // The value is inside an if (typeof...) block that ensures it's safe
288 + return;
289 + }
290 + const coercionCheckMessage = hasCoercionCheck(valueToTest);
291 + if (!coercionCheckMessage) {
292 + // The previous statement is a correct check function call, so no report.
293 + return;
294 + }
295 +
296 + context.report({
297 + node,
298 + message:
299 + coercionCheckMessage +
300 + '\n' +
301 + "Using `'' + value` or `value + ''` is fast to coerce strings, but may throw." +
302 + ' For prod code, add a DEV check from shared/CheckStringCoercion immediately' +
303 + ' before this coercion.' +
304 + ' For non-prod code and prod error handling, use `String(value)` instead.',
305 + });
306 + }
307 +}
308 +
309 +function coerceWithStringConstructor(context, node) {
310 + const isProductionUserAppCode =
311 + context.options[0] && context.options[0].isProductionUserAppCode;
312 + if (isProductionUserAppCode && node.callee.name === 'String') {
313 + context.report(
314 + node,
315 + "For perf-sensitive coercion, avoid `String(value)`. Instead, use `'' + value`." +
316 + ' Precede it with a DEV check from shared/CheckStringCoercion' +
317 + ' unless Symbol and Temporal.* values are impossible.' +
318 + ' For non-prod code and prod error handling, use `String(value)` and disable this rule.'
319 + );
320 + }
321 +}
322 +
323 +module.exports = {
324 + meta: {
325 + schema: [
326 + {
327 + type: 'object',
328 + properties: {
329 + isProductionUserAppCode: {
330 + type: 'boolean',
331 + default: false,
332 + },
333 + },
334 + additionalProperties: false,
335 + },
336 + ],
337 + },
338 + create(context) {
339 + return {
340 + BinaryExpression: node => plusEmptyString(context, node),
341 + CallExpression: node => coerceWithStringConstructor(context, node),
342 + };
343 + },
344 +};
scripts/flow/runFlow.js
+2 -2
@@ -33,8 +33,8 @@ async function runFlow(renderer, args) {
33 const srcStat = fs.statSync(__dirname + '/config/flowconfig');
34 const destPath = './.flowconfig';
35 if (fs.existsSync(destPath)) {
36 - const oldConfig = fs.readFileSync(destPath) + '';
37 - const newConfig = fs.readFileSync(srcPath) + '';
36 + const oldConfig = String(fs.readFileSync(destPath));
37 + const newConfig = String(fs.readFileSync(srcPath));
38 if (oldConfig !== newConfig) {
39 // Use the mtime to detect if the file was manually edited. If so,
40 // log an error.
scripts/merge-fork/merge-fork.js
+2 -2
@@ -90,7 +90,7 @@ for (const {base: baseFilename, from, to} of getTransforms()) {
90 }
91 );
92 if (gitShowResult.status !== 0) {
93 - console.error('' + gitShowResult.stderr);
93 + console.error(String(gitShowResult.stderr));
94 continue;
95 }
96
@@ -104,6 +104,6 @@ for (const {base: baseFilename, from, to} of getTransforms()) {
104 });
105
106 if (mergeFileResult.status !== 0) {
107 - console.error('' + mergeFileResult.stderr);
107 + console.error(String(mergeFileResult.stderr));
108 }
109 }
scripts/rollup/build-all-release-channels.js
+4 -4
@@ -18,13 +18,13 @@ const {
18 // Runs the build script for both stable and experimental release channels,
19 // by configuring an environment variable.
20
21 -const sha = (
22 - spawnSync('git', ['show', '-s', '--format=%h']).stdout + ''
21 +const sha = String(
22 + spawnSync('git', ['show', '-s', '--format=%h']).stdout
23 ).trim();
24
25 -let dateString = (
25 +let dateString = String(
26 spawnSync('git', ['show', '-s', '--format=%cd', '--date=format:%Y%m%d', sha])
27 - .stdout + ''
27 + .stdout
28 ).trim();
29
30 // On CI environment, this string is wrapped with quotes '...'s