Skip to content

Commit 06e63d3

Browse files
authored
Fix remaining React strict mode issues (#4564)
1 parent fc0079e commit 06e63d3

File tree

93 files changed

+1402
-841
lines changed

Some content is hidden

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

93 files changed

+1402
-841
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ module.exports = {
169169
'rulesdir/sort-imports': [ERROR],
170170
'rulesdir/imports': [ERROR],
171171
'rulesdir/useLayoutEffectRule': [ERROR],
172+
'rulesdir/pure-render': [ERROR],
172173

173174
// jsx-a11y rules
174175
'jsx-a11y/accessible-emoji': ERROR,

.storybook/custom-addons/strictmode/index.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, {StrictMode, useEffect, useState} from 'react';
44

55
function StrictModeDecorator(props) {
66
let {children} = props;
7-
let [isStrict, setStrict] = useState(getQueryParams()?.strict === 'true' || false);
7+
let [isStrict, setStrict] = useState(getQueryParams()?.strict !== 'false');
88

99
useEffect(() => {
1010
let channel = addons.getChannel();

.storybook/custom-addons/strictmode/register.js

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import React, {useEffect, useState} from 'react';
44

55
const StrictModeToolBar = ({api}) => {
66
let channel = addons.getChannel();
7-
let [isStrict, setStrict] = useState(getQueryParams()?.strict === 'true' || false);
7+
let [isStrict, setStrict] = useState(getQueryParams()?.strict !== 'false');
88
let onChange = () => {
99
setStrict((old) => {
1010
channel.emit('strict/updated', !old);
@@ -29,12 +29,14 @@ const StrictModeToolBar = ({api}) => {
2929
);
3030
};
3131

32-
addons.register('StrictModeSwitcher', (api) => {
33-
addons.add('StrictModeSwitcher', {
34-
title: 'Strict mode switcher',
35-
type: types.TOOL,
36-
//👇 Shows the Toolbar UI element if either the Canvas or Docs tab is active
37-
match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
38-
render: () => <StrictModeToolBar api={api} />
32+
if (process.env.NODE_ENV !== 'production') {
33+
addons.register('StrictModeSwitcher', (api) => {
34+
addons.add('StrictModeSwitcher', {
35+
title: 'Strict mode switcher',
36+
type: types.TOOL,
37+
//👇 Shows the Toolbar UI element if either the Canvas or Docs tab is active
38+
match: ({ viewMode }) => !!(viewMode && viewMode.match(/^(story|docs)$/)),
39+
render: () => <StrictModeToolBar api={api} />
40+
});
3941
});
40-
});
42+
}

.storybook/preview.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,6 @@ export const parameters = {
2626

2727
export const decorators = [
2828
withScrollingSwitcher,
29-
withStrictModeSwitcher,
29+
...(process.env.NODE_ENV !== 'production' ? [withStrictModeSwitcher] : []),
3030
withProviderSwitcher
3131
];

bin/pure-render.js

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
/*
2+
* Copyright 2020 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
13+
// Copied from https://github.com/facebook/react/pull/24506
14+
module.exports = {
15+
meta: {
16+
type: 'problem',
17+
docs: {
18+
description: 'enforces component render purity',
19+
recommended: true,
20+
url: 'https://beta.reactjs.org/learn/keeping-components-pure'
21+
}
22+
},
23+
create(context) {
24+
return {
25+
MemberExpression(member) {
26+
// Look for member expressions that look like refs (i.e. `ref.current`).
27+
if (
28+
member.object.type !== 'Identifier' ||
29+
member.computed ||
30+
member.property.type !== 'Identifier' ||
31+
member.property.name !== 'current'
32+
) {
33+
return;
34+
}
35+
36+
// Find the parent function of this node, as well as any if statement matching against the ref value
37+
// (i.e. lazy init pattern shown in React docs).
38+
let node = member;
39+
let fn;
40+
let conditional;
41+
while (node) {
42+
if (
43+
node.type === 'FunctionDeclaration' ||
44+
node.type === 'FunctionExpression' ||
45+
node.type === 'ArrowFunctionExpression'
46+
) {
47+
fn = node;
48+
break;
49+
}
50+
51+
if (
52+
node.type === 'IfStatement' &&
53+
node.test.type === 'BinaryExpression' &&
54+
(node.test.operator === '==' || node.test.operator === '===') &&
55+
isMemberExpressionEqual(node.test.left, member)
56+
) {
57+
conditional = node.test;
58+
}
59+
60+
node = node.parent;
61+
}
62+
63+
if (!fn) {
64+
return;
65+
}
66+
67+
// Find the variable definition for the object.
68+
const variable = getVariable(context.getScope(), member.object.name);
69+
if (!variable) {
70+
return;
71+
}
72+
73+
// Find the initialization of the variable and see if it's a call to useRef.
74+
const refDefinition = variable.defs.find(def => {
75+
const init = def.node.init;
76+
if (!init) {
77+
return false;
78+
}
79+
80+
return (
81+
init.type === 'CallExpression' &&
82+
((init.callee.type === 'Identifier' &&
83+
init.callee.name === 'useRef') ||
84+
(init.callee.type === 'MemberExpression' &&
85+
init.callee.object.type === 'Identifier' &&
86+
init.callee.object.name === 'React' &&
87+
init.callee.property.type === 'Identifier' &&
88+
init.callee.property.name === 'useRef')) &&
89+
parentFunction(def.node) === fn
90+
);
91+
});
92+
93+
if (refDefinition) {
94+
// If within an if statement, check if comparing with the initial value passed to useRef.
95+
// This indicates the lazy init pattern, which is allowed.
96+
if (conditional) {
97+
const init = refDefinition.node.init.arguments[0] || {
98+
type: 'Identifier',
99+
name: 'undefined'
100+
};
101+
if (isLiteralEqual(conditional.operator, init, conditional.right)) {
102+
return;
103+
}
104+
}
105+
106+
// Otherwise, report an error for either writing or reading to this ref based on parent expression.
107+
context.report({
108+
node: member,
109+
message:
110+
member.parent.type === 'AssignmentExpression' &&
111+
member.parent.left === member
112+
? 'Writing to refs during rendering is not allowed. Move this into a useEffect or useLayoutEffect. See https://beta.reactjs.org/apis/useref'
113+
: 'Reading from refs during rendering is not allowed. See https://beta.reactjs.org/apis/useref'
114+
});
115+
}
116+
}
117+
};
118+
}
119+
};
120+
121+
function getVariable(scope, name) {
122+
while (scope) {
123+
const variable = scope.set.get(name);
124+
if (variable) {
125+
return variable;
126+
}
127+
128+
scope = scope.upper;
129+
}
130+
}
131+
132+
function parentFunction(node) {
133+
while (node) {
134+
if (
135+
node.type === 'FunctionDeclaration' ||
136+
node.type === 'FunctionExpression' ||
137+
node.type === 'ArrowFunctionExpression'
138+
) {
139+
return node;
140+
}
141+
142+
node = node.parent;
143+
}
144+
}
145+
146+
function isMemberExpressionEqual(a, b) {
147+
if (a === b) {
148+
return true;
149+
}
150+
151+
return (
152+
a.type === 'MemberExpression' &&
153+
b.type === 'MemberExpression' &&
154+
a.object.type === 'Identifier' &&
155+
b.object.type === 'Identifier' &&
156+
a.object.name === b.object.name &&
157+
a.property.type === 'Identifier' &&
158+
b.property.type === 'Identifier' &&
159+
a.property.name === b.property.name
160+
);
161+
}
162+
163+
function isLiteralEqual(operator, a, b) {
164+
let aValue, bValue;
165+
if (a.type === 'Identifier' && a.name === 'undefined') {
166+
aValue = undefined;
167+
} else if (a.type === 'Literal') {
168+
aValue = a.value;
169+
} else {
170+
return;
171+
}
172+
173+
if (b.type === 'Identifier' && b.name === 'undefined') {
174+
bValue = undefined;
175+
} else if (b.type === 'Literal') {
176+
bValue = b.value;
177+
} else {
178+
return;
179+
}
180+
181+
if (operator === '===') {
182+
return aValue === bValue;
183+
} else if (operator === '==') {
184+
// eslint-disable-next-line
185+
return aValue == bValue;
186+
}
187+
188+
return false;
189+
}

package.json

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,11 @@
2020
"build:chromatic": "CHROMATIC=1 build-storybook -c .chromatic -o dist/$(git rev-parse HEAD)/chromatic",
2121
"start:docs": "DOCS_ENV=dev parcel 'packages/@react-{spectrum,aria,stately}/*/docs/*.mdx' 'packages/react-aria-components/docs/*.mdx' 'packages/@internationalized/*/docs/*.mdx' 'packages/dev/docs/pages/**/*.mdx'",
2222
"build:docs": "DOCS_ENV=staging parcel build 'packages/@react-{spectrum,aria,stately}/*/docs/*.mdx' 'packages/react-aria-components/docs/*.mdx' 'packages/@internationalized/*/docs/*.mdx' 'packages/dev/docs/pages/**/*.mdx'",
23-
"test": "yarn jest",
24-
"test-strict": "cross-env STRICT_MODE=1 yarn jest",
23+
"test": "cross-env STRICT_MODE=1 yarn jest",
24+
"test-loose": "yarn jest",
2525
"build": "make build",
26-
"test:ssr": "yarn jest --config jest.ssr.config.js",
27-
"ci-test": "yarn jest --maxWorkers=2 && yarn test:ssr --runInBand",
28-
"ci-test-16": "yarn jest --maxWorkers=2 && yarn test:ssr --runInBand",
26+
"test:ssr": "cross-env STRICT_MODE=1 yarn jest --config jest.ssr.config.js",
27+
"ci-test": "cross-env STRICT_MODE=1 yarn jest --maxWorkers=2 && cross-env STRICT_MODE=1 yarn test:ssr --runInBand",
2928
"lint": "concurrently \"yarn check-types\" \"eslint packages --ext .js,.ts,.tsx\" \"node scripts/lint-packages.js\"",
3029
"jest": "node scripts/jest.js",
3130
"copyrights": "babel-node --presets @babel/env ./scripts/addHeaders.js",

packages/@react-aria/actiongroup/src/useActionGroupItem.ts

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,9 @@
1111
*/
1212

1313
import {DOMAttributes, FocusableElement} from '@react-types/shared';
14-
import {Key, RefObject, useEffect, useRef} from 'react';
14+
import {Key, RefObject, useEffect} from 'react';
1515
import {ListState} from '@react-stately/list';
16-
import {mergeProps} from '@react-aria/utils';
16+
import {mergeProps, useEffectEvent} from '@react-aria/utils';
1717
import {PressProps} from '@react-aria/interactions';
1818

1919
export interface AriaActionGroupItemProps {
@@ -43,18 +43,18 @@ export function useActionGroupItem<T>(props: AriaActionGroupItemProps, state: Li
4343
}
4444

4545
let isFocused = props.key === state.selectionManager.focusedKey;
46-
let lastRender = useRef({isFocused, state});
47-
lastRender.current = {isFocused, state};
46+
let onRemovedWithFocus = useEffectEvent(() => {
47+
if (isFocused) {
48+
state.selectionManager.setFocusedKey(null);
49+
}
50+
});
4851

4952
// If the focused item is removed from the DOM, reset the focused key to null.
50-
// eslint-disable-next-line arrow-body-style
5153
useEffect(() => {
5254
return () => {
53-
if (lastRender.current.isFocused) {
54-
lastRender.current.state.selectionManager.setFocusedKey(null);
55-
}
55+
onRemovedWithFocus();
5656
};
57-
}, []);
57+
}, [onRemovedWithFocus]);
5858

5959
return {
6060
buttonProps: mergeProps(buttonProps, {

packages/@react-aria/calendar/src/useCalendarBase.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import {hookData, useSelectedDateDescription, useVisibleRangeDescription} from '
2121
// @ts-ignore
2222
import intlMessages from '../intl/*.json';
2323
import {useLocalizedStringFormatter} from '@react-aria/i18n';
24-
import {useRef} from 'react';
24+
import {useState} from 'react';
2525

2626
export interface CalendarAria {
2727
/** Props for the calendar grouping element. */
@@ -71,17 +71,17 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps & AriaLabeli
7171
});
7272

7373
// If the next or previous buttons become disabled while they are focused, move focus to the calendar body.
74-
let nextFocused = useRef(false);
74+
let [nextFocused, setNextFocused] = useState(false);
7575
let nextDisabled = props.isDisabled || state.isNextVisibleRangeInvalid();
76-
if (nextDisabled && nextFocused.current) {
77-
nextFocused.current = false;
76+
if (nextDisabled && nextFocused) {
77+
setNextFocused(false);
7878
state.setFocused(true);
7979
}
8080

81-
let previousFocused = useRef(false);
81+
let [previousFocused, setPreviousFocused] = useState(false);
8282
let previousDisabled = props.isDisabled || state.isPreviousVisibleRangeInvalid();
83-
if (previousDisabled && previousFocused.current) {
84-
previousFocused.current = false;
83+
if (previousDisabled && previousFocused) {
84+
setPreviousFocused(false);
8585
state.setFocused(true);
8686
}
8787

@@ -100,15 +100,13 @@ export function useCalendarBase(props: CalendarPropsBase & DOMProps & AriaLabeli
100100
onPress: () => state.focusNextPage(),
101101
'aria-label': stringFormatter.format('next'),
102102
isDisabled: nextDisabled,
103-
onFocus: () => nextFocused.current = true,
104-
onBlur: () => nextFocused.current = false
103+
onFocusChange: setNextFocused
105104
},
106105
prevButtonProps: {
107106
onPress: () => state.focusPreviousPage(),
108107
'aria-label': stringFormatter.format('previous'),
109108
isDisabled: previousDisabled,
110-
onFocus: () => previousFocused.current = true,
111-
onBlur: () => previousFocused.current = false
109+
onFocusChange: setPreviousFocused
112110
},
113111
errorMessageProps: {
114112
id: errorMessageId

packages/@react-aria/calendar/src/useCalendarCell.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import {CalendarDate, isEqualDay, isSameDay, isToday} from '@internationalized/date';
1414
import {CalendarState, RangeCalendarState} from '@react-stately/calendar';
1515
import {DOMAttributes} from '@react-types/shared';
16-
import {focusWithoutScrolling, getScrollParent, scrollIntoViewport, useDescription} from '@react-aria/utils';
16+
import {focusWithoutScrolling, getScrollParent, scrollIntoViewport, useDeepMemo, useDescription} from '@react-aria/utils';
1717
import {getEraFormat, hookData} from './utils';
1818
import {getInteractionModality, usePress} from '@react-aria/interactions';
1919
// @ts-ignore
@@ -102,13 +102,7 @@ export function useCalendarCell(props: AriaCalendarCellProps, state: CalendarSta
102102

103103
// For performance, reuse the same date object as before if the new date prop is the same.
104104
// This allows subsequent useMemo results to be reused.
105-
let lastDate = useRef(null);
106-
if (lastDate.current && isEqualDay(date, lastDate.current)) {
107-
date = lastDate.current;
108-
}
109-
110-
lastDate.current = date;
111-
105+
date = useDeepMemo<CalendarDate>(date, isEqualDay);
112106
let nativeDate = useMemo(() => date.toDate(state.timeZone), [date, state.timeZone]);
113107

114108
// aria-label should be localize Day of week, Month, Day and Year without Time.

0 commit comments

Comments
 (0)