Skip to content

Commit c78cc77

Browse files
committed
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.
1 parent de90529 commit c78cc77

20 files changed

+201
-7
lines changed

packages/react-dom/src/client/DOMPropertyOperations.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
disableJavaScriptURLs,
2121
enableTrustedTypesIntegration,
2222
} from 'shared/ReactFeatureFlags';
23+
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
2324
import {isOpaqueHydratingObject} from './ReactDOMHostConfig';
2425

2526
import type {PropertyInfo} from '../shared/DOMProperty';
@@ -40,10 +41,18 @@ export function getValueForProperty(
4041
const {propertyName} = propertyInfo;
4142
return (node: any)[propertyName];
4243
} 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+
4351
if (!disableJavaScriptURLs && propertyInfo.sanitizeURL) {
4452
// If we haven't fully disabled javascript: URLs, and if
4553
// the hydration is successful of a javascript: URL, we
4654
// still want to warn on the client.
55+
// eslint-disable-next-line react-internal/safe-string-coercion
4756
sanitizeURL('' + (expected: any));
4857
}
4958

@@ -60,6 +69,7 @@ export function getValueForProperty(
6069
if (shouldRemoveAttribute(name, expected, propertyInfo, false)) {
6170
return value;
6271
}
72+
// eslint-disable-next-line react-internal/safe-string-coercion
6373
if (value === '' + (expected: any)) {
6474
return expected;
6575
}
@@ -85,6 +95,7 @@ export function getValueForProperty(
8595

8696
if (shouldRemoveAttribute(name, expected, propertyInfo, false)) {
8797
return stringValue === null ? expected : stringValue;
98+
// eslint-disable-next-line react-internal/safe-string-coercion
8899
} else if (stringValue === '' + (expected: any)) {
89100
return expected;
90101
} else {
@@ -119,6 +130,9 @@ export function getValueForAttribute(
119130
return expected === undefined ? undefined : null;
120131
}
121132
const value = node.getAttribute(name);
133+
if (__DEV__) {
134+
checkAttributeStringCoercion(expected, name);
135+
}
122136
if (value === '' + (expected: any)) {
123137
return expected;
124138
}
@@ -153,6 +167,9 @@ export function setValueForProperty(
153167
if (value === null) {
154168
node.removeAttribute(attributeName);
155169
} else {
170+
if (__DEV__) {
171+
checkAttributeStringCoercion(value, name);
172+
}
156173
node.setAttribute(
157174
attributeName,
158175
enableTrustedTypesIntegration ? (value: any) : '' + (value: any),
@@ -191,6 +208,9 @@ export function setValueForProperty(
191208
if (enableTrustedTypesIntegration) {
192209
attributeValue = (value: any);
193210
} else {
211+
if (__DEV__) {
212+
checkAttributeStringCoercion(value, attributeName);
213+
}
194214
attributeValue = '' + (value: any);
195215
}
196216
if (propertyInfo.sanitizeURL) {

packages/react-dom/src/client/ReactDOMComponent.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414

1515
import {canUseDOM} from 'shared/ExecutionEnvironment';
1616
import hasOwnProperty from 'shared/hasOwnProperty';
17+
import {checkHtmlStringCoercion} from 'shared/CheckStringCoercion';
1718

1819
import {
1920
getValueForAttribute,
@@ -139,6 +140,9 @@ if (__DEV__) {
139140
const NORMALIZE_NULL_AND_REPLACEMENT_REGEX = /\u0000|\uFFFD/g;
140141

141142
normalizeMarkupForTextOrAttribute = function(markup: mixed): string {
143+
if (__DEV__) {
144+
checkHtmlStringCoercion(markup);
145+
}
142146
const markupString =
143147
typeof markup === 'string' ? markup : '' + (markup: any);
144148
return markupString

packages/react-dom/src/client/ReactDOMInput.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {checkControlledValueProps} from '../shared/ReactControlledValuePropTypes
1818
import {updateValueIfChanged} from './inputValueTracking';
1919
import getActiveElement from './getActiveElement';
2020
import {disableInputAttributeSyncing} from 'shared/ReactFeatureFlags';
21+
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
2122

2223
import type {ToStringValue} from './ToStringValue';
2324

@@ -365,6 +366,9 @@ function updateNamedCousins(rootNode, props) {
365366
// the input might not even be in a form. It might not even be in the
366367
// document. Let's just use the local `querySelectorAll` to ensure we don't
367368
// miss anything.
369+
if (__DEV__) {
370+
checkAttributeStringCoercion(name, 'name');
371+
}
368372
const group = queryRoot.querySelectorAll(
369373
'input[name=' + JSON.stringify('' + name) + '][type="radio"]',
370374
);

packages/react-dom/src/client/ToStringValue.js

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @flow
88
*/
99

10+
import {checkFormFieldValueStringCoercion} from 'shared/CheckStringCoercion';
11+
1012
export opaque type ToStringValue =
1113
| boolean
1214
| number
@@ -19,17 +21,23 @@ export opaque type ToStringValue =
1921
// around this limitation, we use an opaque type that can only be obtained by
2022
// passing the value through getToStringValue first.
2123
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
2226
return '' + (value: any);
2327
}
2428

2529
export function getToStringValue(value: mixed): ToStringValue {
2630
switch (typeof value) {
2731
case 'boolean':
2832
case 'number':
29-
case 'object':
3033
case 'string':
3134
case 'undefined':
3235
return value;
36+
case 'object':
37+
if (__DEV__) {
38+
checkFormFieldValueStringCoercion(value);
39+
}
40+
return value;
3341
default:
3442
// function, symbol are assigned as empty strings
3543
return '';

packages/react-dom/src/client/inputValueTracking.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
* @flow
88
*/
99

10+
import {checkFormFieldValueStringCoercion} from 'shared/CheckStringCoercion';
11+
1012
type ValueTracker = {|
1113
getValue(): string,
1214
setValue(value: string): void,
@@ -55,6 +57,9 @@ function trackValueOnNode(node: any): ?ValueTracker {
5557
valueField,
5658
);
5759

60+
if (__DEV__) {
61+
checkFormFieldValueStringCoercion(node[valueField]);
62+
}
5863
let currentValue = '' + node[valueField];
5964

6065
// if someone has already defined a value or Safari, then bail
@@ -76,6 +81,9 @@ function trackValueOnNode(node: any): ?ValueTracker {
7681
return get.call(this);
7782
},
7883
set: function(value) {
84+
if (__DEV__) {
85+
checkFormFieldValueStringCoercion(value);
86+
}
7987
currentValue = '' + value;
8088
set.call(this, value);
8189
},
@@ -93,6 +101,9 @@ function trackValueOnNode(node: any): ?ValueTracker {
93101
return currentValue;
94102
},
95103
setValue(value) {
104+
if (__DEV__) {
105+
checkFormFieldValueStringCoercion(value);
106+
}
96107
currentValue = '' + value;
97108
},
98109
stopTracking() {

packages/react-dom/src/server/DOMMarkupOperations.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
shouldRemoveAttribute,
1717
} from '../shared/DOMProperty';
1818
import sanitizeURL from '../shared/sanitizeURL';
19+
import {checkAttributeStringCoercion} from 'shared/CheckStringCoercion';
1920
import quoteAttributeValueForBrowser from './quoteAttributeValueForBrowser';
2021

2122
/**
@@ -44,6 +45,9 @@ export function createMarkupForProperty(name: string, value: mixed): string {
4445
return attributeName + '=""';
4546
} else {
4647
if (propertyInfo.sanitizeURL) {
48+
if (__DEV__) {
49+
checkAttributeStringCoercion(value, attributeName);
50+
}
4751
value = '' + (value: any);
4852
sanitizeURL(value);
4953
}

packages/react-dom/src/server/ReactDOMServerFormatConfig.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,12 @@
99

1010
import type {ReactNodeList} from 'shared/ReactTypes';
1111

12+
import {
13+
checkHtmlStringCoercion,
14+
checkCSSPropertyStringCoercion,
15+
checkAttributeStringCoercion,
16+
} from 'shared/CheckStringCoercion';
17+
1218
import {Children} from 'react';
1319

1420
import {enableFilterEmptyStringAttributesDOM} from 'shared/ReactFeatureFlags';
@@ -272,6 +278,9 @@ function pushStyle(
272278
const isCustomProperty = styleName.indexOf('--') === 0;
273279
if (isCustomProperty) {
274280
nameChunk = stringToChunk(escapeTextForBrowser(styleName));
281+
if (__DEV__) {
282+
checkCSSPropertyStringCoercion(styleValue, styleName);
283+
}
275284
valueChunk = stringToChunk(
276285
escapeTextForBrowser(('' + styleValue).trim()),
277286
);
@@ -291,6 +300,9 @@ function pushStyle(
291300
valueChunk = stringToChunk('' + styleValue);
292301
}
293302
} else {
303+
if (__DEV__) {
304+
checkCSSPropertyStringCoercion(styleValue, styleName);
305+
}
294306
valueChunk = stringToChunk(
295307
escapeTextForBrowser(('' + styleValue).trim()),
296308
);
@@ -439,6 +451,9 @@ function pushAttribute(
439451
break;
440452
default:
441453
if (propertyInfo.sanitizeURL) {
454+
if (__DEV__) {
455+
checkAttributeStringCoercion(value, attributeName);
456+
}
442457
value = '' + (value: any);
443458
sanitizeURL(value);
444459
}
@@ -496,6 +511,9 @@ function pushInnerHTML(
496511
);
497512
const html = innerHTML.__html;
498513
if (html !== null && html !== undefined) {
514+
if (__DEV__) {
515+
checkHtmlStringCoercion(html);
516+
}
499517
target.push(stringToChunk('' + html));
500518
}
501519
}
@@ -679,6 +697,9 @@ function pushStartOption(
679697
if (selectedValue !== null) {
680698
let stringValue;
681699
if (value !== null) {
700+
if (__DEV__) {
701+
checkAttributeStringCoercion(value, 'value');
702+
}
682703
stringValue = '' + value;
683704
} else {
684705
if (__DEV__) {
@@ -697,6 +718,9 @@ function pushStartOption(
697718
if (isArray(selectedValue)) {
698719
// multiple
699720
for (let i = 0; i < selectedValue.length; i++) {
721+
if (__DEV__) {
722+
checkAttributeStringCoercion(selectedValue[i], 'value');
723+
}
700724
const v = '' + selectedValue[i];
701725
if (v === stringValue) {
702726
target.push(selectedMarkerAttribute);
@@ -895,8 +919,16 @@ function pushStartTextArea(
895919
children.length <= 1,
896920
'<textarea> can only have at most one child.',
897921
);
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+
}
898927
value = '' + children[0];
899928
}
929+
if (__DEV__) {
930+
checkHtmlStringCoercion(children);
931+
}
900932
value = '' + children;
901933
}
902934

@@ -1142,6 +1174,9 @@ function pushStartPreformattedElement(
11421174
if (typeof html === 'string' && html.length > 0 && html[0] === '\n') {
11431175
target.push(leadingNewline, stringToChunk(html));
11441176
} else {
1177+
if (__DEV__) {
1178+
checkHtmlStringCoercion(html);
1179+
}
11451180
target.push(stringToChunk('' + html));
11461181
}
11471182
}

packages/react-dom/src/server/ReactPartialRenderer.js

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,10 @@ import {
2525
enableSuspenseServerRenderer,
2626
enableScopeAPI,
2727
} from 'shared/ReactFeatureFlags';
28+
import {
29+
checkPropStringCoercion,
30+
checkFormFieldValueStringCoercion,
31+
} from 'shared/CheckStringCoercion';
2832

2933
import {
3034
REACT_DEBUG_TRACING_MODE_TYPE,
@@ -1472,6 +1476,9 @@ class ReactDOMServerRenderer {
14721476
textareaChildren = textareaChildren[0];
14731477
}
14741478

1479+
if (__DEV__) {
1480+
checkPropStringCoercion(textareaChildren, 'children');
1481+
}
14751482
defaultValue = '' + textareaChildren;
14761483
}
14771484
if (defaultValue == null) {
@@ -1480,6 +1487,9 @@ class ReactDOMServerRenderer {
14801487
initialValue = defaultValue;
14811488
}
14821489

1490+
if (__DEV__) {
1491+
checkFormFieldValueStringCoercion(initialValue);
1492+
}
14831493
props = Object.assign({}, props, {
14841494
value: undefined,
14851495
children: '' + initialValue,
@@ -1535,6 +1545,9 @@ class ReactDOMServerRenderer {
15351545
if (selectValue != null) {
15361546
let value;
15371547
if (props.value != null) {
1548+
if (__DEV__) {
1549+
checkFormFieldValueStringCoercion(props.value);
1550+
}
15381551
value = props.value + '';
15391552
} else {
15401553
if (__DEV__) {
@@ -1554,12 +1567,18 @@ class ReactDOMServerRenderer {
15541567
if (isArray(selectValue)) {
15551568
// multiple
15561569
for (let j = 0; j < selectValue.length; j++) {
1570+
if (__DEV__) {
1571+
checkFormFieldValueStringCoercion(selectValue[j]);
1572+
}
15571573
if ('' + selectValue[j] === value) {
15581574
selected = true;
15591575
break;
15601576
}
15611577
}
15621578
} else {
1579+
if (__DEV__) {
1580+
checkFormFieldValueStringCoercion(selectValue);
1581+
}
15631582
selected = '' + selectValue === value;
15641583
}
15651584

packages/react-dom/src/server/escapeTextForBrowser.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
* @private
3737
*/
3838

39+
import {checkHtmlStringCoercion} from 'shared/CheckStringCoercion';
40+
3941
const matchHtmlRegExp = /["'&<>]/;
4042

4143
/**
@@ -47,6 +49,9 @@ const matchHtmlRegExp = /["'&<>]/;
4749
*/
4850

4951
function escapeHtml(string) {
52+
if (__DEV__) {
53+
checkHtmlStringCoercion(string);
54+
}
5055
const str = '' + string;
5156
const match = matchHtmlRegExp.exec(str);
5257

packages/react-dom/src/shared/dangerousStyleValue.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
*/
77

88
import {isUnitlessNumber} from './CSSProperty';
9+
import {checkCSSPropertyStringCoercion} from 'shared/CheckStringCoercion';
910

1011
/**
1112
* Convert a value into the proper css writable value. The style name `name`
@@ -41,6 +42,9 @@ function dangerousStyleValue(name, value, isCustomProperty) {
4142
return value + 'px'; // Presumes implicit 'px' suffix for unitless numbers
4243
}
4344

45+
if (__DEV__) {
46+
checkCSSPropertyStringCoercion(value, name);
47+
}
4448
return ('' + value).trim();
4549
}
4650

0 commit comments

Comments
 (0)