Skip to content

Commit c88fb49

Browse files
authored
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.
1 parent 05726d7 commit c88fb49

File tree

58 files changed

+1561
-85
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1561
-85
lines changed

.eslintrc.js

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,10 @@ module.exports = {
108108
// CUSTOM RULES
109109
// the second argument of warning/invariant should be a literal string
110110
'react-internal/no-primitive-constructors': ERROR,
111+
'react-internal/safe-string-coercion': [
112+
ERROR,
113+
{isProductionUserAppCode: true},
114+
],
111115
'react-internal/no-to-warn-dev-within-to-throw': ERROR,
112116
'react-internal/invariant-args': ERROR,
113117
'react-internal/warning-args': ERROR,
@@ -168,10 +172,17 @@ module.exports = {
168172
'packages/*/npm/**/*.js',
169173
'packages/dom-event-testing-library/**/*.js',
170174
'packages/react-devtools*/**/*.js',
175+
'dangerfile.js',
176+
'fixtures',
177+
'packages/react-dom/src/test-utils/*.js',
171178
],
172179
rules: {
173180
'react-internal/no-production-logging': OFF,
174181
'react-internal/warning-args': OFF,
182+
'react-internal/safe-string-coercion': [
183+
ERROR,
184+
{isProductionUserAppCode: false},
185+
],
175186

176187
// Disable accessibility checks
177188
'jsx-a11y/aria-role': OFF,
@@ -185,7 +196,7 @@ module.exports = {
185196
{
186197
files: [
187198
'scripts/eslint-rules/*.js',
188-
'packages/eslint-plugin-react-hooks/src/*.js'
199+
'packages/eslint-plugin-react-hooks/src/*.js',
189200
],
190201
plugins: ['eslint-plugin'],
191202
rules: {

dangerfile.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,8 @@ function row(result) {
102102
let headSha;
103103
let baseSha;
104104
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();
107107
} catch {
108108
warn(
109109
"Failed to read build artifacts. It's possible a build configuration " +

fixtures/dom/src/components/fixtures/error-handling/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class ErrorBoundary extends React.Component {
4141
if (this.state.error) {
4242
return <p>Captured an error: {this.state.error.message}</p>;
4343
} else {
44-
return <p>Captured an error: {'' + this.state.error}</p>;
44+
return <p>Captured an error: {String(this.state.error)}</p>;
4545
}
4646
}
4747
if (this.state.shouldThrow) {

packages/react-debug-tools/src/__tests__/ReactHooksInspectionIntegration-test.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ describe('ReactHooksInspectionIntegration', () => {
615615
expect(tree[0].id).toEqual(0);
616616
expect(tree[0].isStateEditable).toEqual(false);
617617
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);
619619

620620
expect(tree[1]).toEqual({
621621
id: 1,
@@ -646,7 +646,7 @@ describe('ReactHooksInspectionIntegration', () => {
646646
expect(tree[0].id).toEqual(0);
647647
expect(tree[0].isStateEditable).toEqual(false);
648648
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);
650650

651651
expect(tree[1]).toEqual({
652652
id: 1,

packages/react-devtools-extensions/src/main.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ function createPanelIfReactLoaded() {
9494

9595
function initBridgeAndStore() {
9696
const port = chrome.runtime.connect({
97-
name: '' + tabId,
97+
name: String(tabId),
9898
});
9999
// Looks like `port.onDisconnect` does not trigger on in-tab navigation like new URL or back/forward navigation,
100100
// so it makes no sense to handle it here.

packages/react-devtools-shared/src/backend/legacy/renderer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ function getData(internalInstance: InternalInstance) {
6363
// != used deliberately here to catch undefined and null
6464
if (internalInstance._currentElement != null) {
6565
if (internalInstance._currentElement.key) {
66-
key = '' + internalInstance._currentElement.key;
66+
key = String(internalInstance._currentElement.key);
6767
}
6868

6969
const elementType = internalInstance._currentElement.type;

packages/react-devtools-shared/src/backend/renderer.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1848,7 +1848,7 @@ export function attach(
18481848

18491849
// This check is a guard to handle a React element that has been modified
18501850
// 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);
18521852
const keyStringID = getStringID(keyString);
18531853

18541854
pushOperation(TREE_OPERATION_ADD);

packages/react-devtools-shared/src/backend/utils.js

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -166,11 +166,7 @@ export function format(
166166
): string {
167167
const args = inputArgs.slice();
168168

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);
174170

175171
// If the first argument is a string, check for substitutions.
176172
if (typeof maybeMessage === 'string') {
@@ -203,17 +199,14 @@ export function format(
203199
// Arguments that remain after formatting.
204200
if (args.length) {
205201
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]);
210203
}
211204
}
212205

213206
// Update escaped %% values.
214207
formatted = formatted.replace(/%{2,2}/g, '%');
215208

216-
return '' + formatted;
209+
return String(formatted);
217210
}
218211

219212
export function isSynchronousXHRSupported(): boolean {

packages/react-devtools-shared/src/devtools/views/ErrorBoundary/ErrorBoundary.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ export default class ErrorBoundary extends Component<Props, State> {
4646
error !== null &&
4747
error.hasOwnProperty('message')
4848
? error.message
49-
: '' + error;
49+
: String(error);
5050

5151
const callStack =
5252
typeof error === 'object' &&

packages/react-devtools-shared/src/devtools/views/utils.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,8 @@ export function alphaSortEntries(
1919
): number {
2020
const a = entryA[0];
2121
const b = entryB[0];
22-
if ('' + +a === a) {
23-
if ('' + +b !== b) {
22+
if (String(+a) === a) {
23+
if (String(+b) !== b) {
2424
return -1;
2525
}
2626
return +a < +b ? -1 : 1;

packages/react-devtools-shared/src/hook.js

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -180,10 +180,7 @@ export function installHook(target: any): DevToolsHook | null {
180180
const args = inputArgs.slice();
181181

182182
// Symbols cannot be concatenated with Strings.
183-
let formatted: string =
184-
typeof maybeMessage === 'symbol'
185-
? maybeMessage.toString()
186-
: '' + maybeMessage;
183+
let formatted = String(maybeMessage);
187184

188185
// If the first argument is a string, check for substitutions.
189186
if (typeof maybeMessage === 'string') {
@@ -216,17 +213,14 @@ export function installHook(target: any): DevToolsHook | null {
216213
// Arguments that remain after formatting.
217214
if (args.length) {
218215
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]);
223217
}
224218
}
225219

226220
// Update escaped %% values.
227221
formatted = formatted.replace(/%{2,2}/g, '%');
228222

229-
return '' + formatted;
223+
return String(formatted);
230224
}
231225

232226
let unpatchFn = null;

packages/react-devtools-shared/src/hooks/SourceMapMetadataConsumer.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@ function normalizeSourcePath(
3838
const {sourceRoot} = map;
3939
let source = sourceInput;
4040

41-
// eslint-disable-next-line react-internal/no-primitive-constructors
4241
source = String(source);
4342
// Some source maps produce relative source paths like "./foo.js" instead of
4443
// "foo.js". Normalize these first so that future comparisons will succeed.

packages/react-devtools-shared/src/utils.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -834,7 +834,7 @@ export function formatDataForPreview(
834834
return data;
835835
default:
836836
try {
837-
return truncateForDisplay('' + data);
837+
return truncateForDisplay(String(data));
838838
} catch (error) {
839839
return 'unserializable';
840840
}

packages/react-dom/src/__tests__/ReactDOMAttribute-test.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,27 @@ describe('ReactDOM unknown attribute', () => {
8989
testUnknownAttributeAssignment(lol, 'lol');
9090
});
9191

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+
92113
it('removes symbols and warns', () => {
93114
expect(() => testUnknownAttributeRemoval(Symbol('foo'))).toErrorDev(
94115
'Warning: Invalid value for prop `unknown` on <div> tag. Either remove it ' +

packages/react-dom/src/__tests__/ReactDOMComponent-test.js

Lines changed: 46 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -254,6 +254,28 @@ describe('ReactDOMComponent', () => {
254254
ReactDOM.render(<span style={style} />, div);
255255
});
256256

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+
257279
it('should update styles if initially null', () => {
258280
let styles = null;
259281
const container = document.createElement('div');
@@ -1130,7 +1152,7 @@ describe('ReactDOMComponent', () => {
11301152

11311153
describe('createOpenTagMarkup', () => {
11321154
function quoteRegexp(str) {
1133-
return (str + '').replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
1155+
return String(str).replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
11341156
}
11351157

11361158
function expectToHaveAttribute(actual, expected) {
@@ -1164,7 +1186,7 @@ describe('ReactDOMComponent', () => {
11641186

11651187
describe('createContentMarkup', () => {
11661188
function quoteRegexp(str) {
1167-
return (str + '').replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
1189+
return String(str).replace(/([.?*+\^$\[\]\\(){}|-])/g, '\\$1');
11681190
}
11691191

11701192
function genMarkup(props) {
@@ -2412,6 +2434,28 @@ describe('ReactDOMComponent', () => {
24122434
expect(el.getAttribute('whatever')).toBe('[object Object]');
24132435
});
24142436

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+
24152459
it('allows cased data attributes', function() {
24162460
let el;
24172461
expect(() => {

0 commit comments

Comments
 (0)