Skip to content

Commit 64b40eb

Browse files
authored
chore: Update React Compiler adoption guidelines (#997)
This file is automatically synced from the `shared-configs` repository. Source: https://github.com/doist/shared-configs/blob/main/
1 parent be17eec commit 64b40eb

File tree

1 file changed

+297
-0
lines changed

1 file changed

+297
-0
lines changed

docs/react-compiler.md

Lines changed: 297 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,297 @@
1+
# React Compiler
2+
3+
[React Compiler](https://react.dev/learn/react-compiler) is an official compiler from the React team that automatically optimizes components and hooks. It analyzes your code and inserts `useMemo`, `useCallback`, and `React.memo` equivalents where beneficial, eliminating the need for manual optimization.
4+
5+
We are incrementally adopting the compiler across all of our React codebases, where it is enabled but not all code may be compliant yet. As there are real performance risks if compiler violations are re-introduced, especially in cases where manual optimizations have been removed, we've put safeguards in place to prevent them from happening.
6+
7+
## Violation tracking
8+
9+
We use [`@doist/react-compiler-tracker`](https://github.com/Doist/react-compiler-tracker) to track modules that the compiler cannot optimize.
10+
11+
Violations are recorded in a [`.react-compiler.rec.json`](https://github.com/Doist/todoist-web/blob/main/.react-compiler.rec.json), where each entry tracks the number of violations in that file:
12+
13+
```json
14+
{
15+
"recordVersion": 1,
16+
"react-compiler-version": "1.0.0",
17+
"files": {
18+
"src/path/to/file.tsx": {
19+
"CompileError": 3
20+
}
21+
}
22+
}
23+
```
24+
25+
We leverage [lint-staged](https://github.com/lint-staged/lint-staged) to automatically update the records file on commit. If the numbers of errors are increased, the commit is blocked until the errors either go back to their previous levels, or if the records file is explicitly re-created. The same check is also run on CI.
26+
27+
## Identifying and fixing violations
28+
29+
When modifying a file with violations, consider fixing them so we can take advantage of the compiler's optimizations.
30+
31+
A quick way to identify them is to install the [React Compiler Marker VSCode extension](https://marketplace.visualstudio.com/items?itemName=blazejkustra.react-compiler-marker), which highlights your components and hooks with ✨ or 🚫 emojis in real time.
32+
33+
Once fixed, the file's entry will be removed from `.react-compiler.rec.json`.
34+
35+
## Common errors
36+
37+
### Ref access during render
38+
39+
> Reason: Cannot access refs during render
40+
>
41+
> React refs are values that are not needed for rendering. Refs should only be accessed outside of render, such as in event handlers or effects. Accessing a ref value (the current property) during render can cause your component not to update as expected (<https://react.dev/reference/react/useRef>)
42+
43+
Assigning to refs during render is a violation as it's a side effect. The fix depends on how the ref was being used, but an example is that if we were using it to prevent a state value from being used to re-create callbacks, consider using `useEvent` instead.
44+
45+
**Before:**
46+
47+
```typescript
48+
const tasksBySectionIdRef = useRef(tasksBySectionId)
49+
const splitGroupsRef = useRef(splitGroups)
50+
51+
// Assigning during render - violation
52+
tasksBySectionIdRef.current = tasksBySectionId
53+
splitGroupsRef.current = splitGroups
54+
55+
const handleTaskEditClick = useCallback(
56+
(task: Task) => {
57+
const group = splitGroupsRef.current
58+
? Object.entries(tasksBySectionIdRef.current).find(...)
59+
: null
60+
onEditItem(group?.[0] ?? task.section_id, task.id)
61+
},
62+
[onEditItem],
63+
)
64+
```
65+
66+
**After:**
67+
68+
```typescript
69+
import { useEvent } from 'react-use-event-hook'
70+
71+
const handleTaskEditClick = useEvent((task: Task) => {
72+
const group = splitGroups
73+
? Object.entries(tasksBySectionId).find(...)
74+
: null
75+
onEditItem(group?.[0] ?? task.section_id, task.id)
76+
})
77+
```
78+
79+
### Mismatched `useMemo` dependencies
80+
81+
> Reason: Existing memoization could not be preserved
82+
>
83+
> React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. The inferred dependencies did not match the manually specified dependencies, which could cause the value to change more or less frequently than expected. The inferred dependency was \[...\], but the source dependencies were \[...\]. Inferred less specific property than source
84+
85+
Typically, this is caused by the use of the optional chaining operator, as the compiler infers the parent object as the actual dependency. Our options here are to either extract the optional-chained property into a variable, or use the parent object as a dependency. We can also consider removing the manual memoization.
86+
87+
**Before:**
88+
89+
```typescript
90+
const splitGroups = useProjectGrouping({ projectId })
91+
92+
const tasksBySectionId = useMemo(() => {
93+
if (splitGroups?.uncompleted) {
94+
return splitGroups.uncompleted.reduce(...)
95+
}
96+
// ...
97+
}, [sortFn, splitGroups?.uncompleted]) // Optional chaining in deps
98+
```
99+
100+
**After:**
101+
102+
```typescript
103+
const splitGroups = useProjectGrouping({ projectId })
104+
const uncompletedGroups = splitGroups?.uncompleted
105+
106+
const tasksBySectionId = useMemo(() => {
107+
if (uncompletedGroups) {
108+
return uncompletedGroups.reduce(...)
109+
}
110+
// ...
111+
}, [sortFn, uncompletedGroups]) // Extracted variable in deps
112+
```
113+
114+
### Mutating props
115+
116+
> Reason: Support destructuring of context variables
117+
118+
React Compiler requires props to be treated as immutable, so they can't be reassigned or mutated.
119+
120+
**Before:**
121+
122+
```typescript
123+
function ProjectBoardView({ showCompleted }) {
124+
const isViewOnly = useSelectIsViewOnlyPublicProject(projectId)
125+
if (isViewOnly) {
126+
showCompleted = true // Violation: reassigning prop
127+
}
128+
}
129+
```
130+
131+
**After:**
132+
133+
```typescript
134+
function ProjectBoardView({ showCompleted: showCompletedProp }) {
135+
const isViewOnly = useSelectIsViewOnlyPublicProject(projectId)
136+
const showCompleted = isViewOnly || showCompletedProp
137+
}
138+
```
139+
140+
### Default parameters for props
141+
142+
> Reason: (BuildHIR::node.lowerReorderableExpression) Expression type `MemberExpression` cannot be safely reordered
143+
>
144+
> Reason: (BuildHIR::node.lowerReorderableExpression) Expression type `OptionalMemberExpression` cannot be safely reordered
145+
146+
The compiler can't safely reorder default parameter values that reference other parameters. This applies to both regular member access (`task.id`) and optional chaining (`task?.id`), since both depend on a sibling parameter being evaluated first.
147+
148+
**Before:**
149+
150+
```typescript
151+
function DndTaskWrapper({
152+
task,
153+
stableTaskId = task.id, // Violation: references sibling parameter
154+
}: Props) {
155+
// ...
156+
}
157+
```
158+
159+
**After:**
160+
161+
```typescript
162+
function DndTaskWrapper({ task, stableTaskId: stableTaskIdProp }: Props) {
163+
const stableTaskId = stableTaskIdProp ?? task?.id
164+
// ...
165+
}
166+
```
167+
168+
### Computed property keys
169+
170+
> Reason: (BuildHIR::lowerExpression) Expected Identifier, got `LogicalExpression` key in ObjectExpression
171+
>
172+
> Reason: (BuildHIR::lowerExpression) Expected Identifier, got `BinaryExpression` key in ObjectExpression
173+
174+
The compiler expects simple identifiers as property keys. If computation is required, first extract them into a variable.
175+
176+
**Before:**
177+
178+
```typescript
179+
return {
180+
...all,
181+
[key ?? DEFAULT_SECTION_ID]: tasks,
182+
}
183+
```
184+
185+
**After:**
186+
187+
```typescript
188+
const groupKey = key ?? DEFAULT_SECTION_ID
189+
return {
190+
...all,
191+
[groupKey]: tasks,
192+
}
193+
```
194+
195+
### Loop variable reassignment
196+
197+
> Reason: Destructure should never be Reassign as it would be an Object/ArrayPattern
198+
199+
Declaring a variable before a loop and reassigning it inside is a violation, as the compiler cannot safely track mutable variables.
200+
201+
**Before:**
202+
203+
```typescript
204+
const hasCompletedTasks = useMemo(() => {
205+
let ancestorType: AncestorType
206+
for (ancestorType in transformedState) {
207+
const ancestors = transformedState[ancestorType]
208+
// ...
209+
}
210+
}, [transformedState])
211+
```
212+
213+
**After:**
214+
215+
```typescript
216+
const hasCompletedTasks = useMemo(() => {
217+
for (const ancestorType in transformedState) {
218+
const ancestors = transformedState[ancestorType as AncestorType]
219+
// ...
220+
}
221+
}, [transformedState])
222+
```
223+
224+
## Verifying fixes (for LLMs)
225+
226+
When fixing violations programmatically, use one of these methods to verify the fix was successful:
227+
228+
**1. CLI Tool (recommended)**
229+
230+
Run the tracker to check if a file has violations:
231+
232+
```bash
233+
npx @doist/react-compiler-tracker --check-files src/path/to/file.tsx
234+
```
235+
236+
A successful check produces no output. If violations remain, the tool reports the error count.
237+
238+
**2. Babel with Inline Logger**
239+
240+
Run a Node script that uses Babel's API with a custom logger to see exact errors:
241+
242+
```bash
243+
node -e "
244+
require('@babel/core').transformFileSync('src/path/to/file.tsx', {
245+
presets: ['@babel/preset-typescript', ['@babel/preset-react', { runtime: 'automatic' }]],
246+
plugins: [['babel-plugin-react-compiler', {
247+
logger: {
248+
logEvent(filename, event) {
249+
if (event.kind === 'CompileError') {
250+
console.error('[CompileError]', filename);
251+
console.error('Reason:', event.detail?.reason);
252+
const loc = event.detail?.primaryLocation?.();
253+
if (loc?.start) console.error('Location: Line', loc.start.line);
254+
}
255+
}
256+
}
257+
}]]
258+
});
259+
"
260+
```
261+
262+
This outputs the exact reason and location for each violation, regardless of repo-specific logging configuration.
263+
264+
**3. Transpiled Code Inspection**
265+
266+
Successfully optimized code will include `react-compiler-runtime` imports and compiler-generated memoization:
267+
268+
```typescript
269+
// Source
270+
function TaskList({ tasks }) {
271+
const sorted = tasks.toSorted((a, b) => a.order - b.order)
272+
return <List items={sorted} />
273+
}
274+
275+
// Transpiled (successfully optimized)
276+
import { c as _c } from "react-compiler-runtime";
277+
function TaskList({ tasks }) {
278+
const $ = _c(2);
279+
let sorted;
280+
if ($[0] !== tasks) {
281+
sorted = tasks.toSorted((a, b) => a.order - b.order);
282+
$[0] = tasks;
283+
$[1] = sorted;
284+
} else {
285+
sorted = $[1];
286+
}
287+
return <List items={sorted} />;
288+
}
289+
```
290+
291+
Key indicators of successful optimization:
292+
293+
- `import { c as _c } from "react-compiler-runtime"` at the top
294+
- `const $ = _c(N)` where N is the number of memo slots
295+
- Conditional blocks checking `$[n] !== value` for cache invalidation
296+
297+
If the transpiled output lacks these patterns and looks unchanged from the source, the component was not optimized due to a violation.

0 commit comments

Comments
 (0)