Compare commits

..

1 Commits

Author SHA1 Message Date
Joe Savona
bb9505c980 [compiler] Detect known incompatible libraries
A few libraries are known to be incompatible with memoization, whether manually via `useMemo()` or via React Compiler. This puts us in a tricky situation. On the one hand, we understand that these libraries were developed prior to our documenting the [Rules of React](https://react.dev/reference/rules), and their designs were the result of trying to deliver a great experience for their users and balance multiple priorities around DX, performance, etc. At the same time, using these libraries with memoization — and in particular with automatic memoization via React Compiler — can break apps by causing the components using these APIs not to update. Concretely, the APIs have in common that they return a function which returns different values over time, but where the function itself does not change. Memoizing the result on the identity of the function will mean that the value never changes. Developers reasonable interpret this as "React Compiler broke my code".

Of course, the best solution is to work with developers of these libraries to address the root cause, and we're doing that. We've previously discussed this situation with both of the respective libraries:
* React Hook Form: https://github.com/react-hook-form/react-hook-form/issues/11910#issuecomment-2135608761
* TanStack Table: https://github.com/facebook/react/issues/33057#issuecomment-2840600158 and https://github.com/TanStack/table/issues/5567

In the meantime we need to make sure that React Compiler can work out of the box as much as possible. This means teaching it about popular libraries that cannot be memoized. We also can't silently skip compilation, as this confuses users, so we need these error messages to be visible to users. To that end, this PR adds:

* A flag to mark functions/hooks as incompatible
* Validation against use of such functions
* A default type provider to provide declarations for two known-incompatible libraries

Note that Mobx is also incompatible, but the `observable()` function is called outside of the component itself, so the compiler cannot currently detect it. We may add validation for such APIs in the future.

Again, we really empathize with the developers of these libraries. We've tried to word the error message non-judgementally, because we get that it's hard! We're open to feedback about the error message, please let us know.
2025-08-28 16:12:16 -07:00
9 changed files with 68 additions and 266 deletions

View File

@@ -1,7 +1,7 @@
{
"packages": ["packages/react", "packages/react-dom", "packages/react-server-dom-webpack", "packages/scheduler"],
"buildCommand": "download-build-in-codesandbox-ci",
"node": "20",
"node": "18",
"publishDirectory": {
"react": "build/oss-experimental/react",
"react-dom": "build/oss-experimental/react-dom",

View File

@@ -175,41 +175,6 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void {
if (node != null) {
valueBlockNodes.set(fallthrough, node);
}
} else if (terminal.kind === 'goto') {
/**
* If we encounter a goto that is not to the natural fallthrough of the current
* block (not the topmost fallthrough on the stack), then this is a goto to a
* label. Any scopes that extend beyond the goto must be extended to include
* the labeled range, so that the break statement doesn't accidentally jump
* out of the scope. We do this by extending the start and end of the scope's
* range to the label and its fallthrough respectively.
*/
const start = activeBlockFallthroughRanges.find(
range => range.fallthrough === terminal.block,
);
if (start != null && start !== activeBlockFallthroughRanges.at(-1)) {
const fallthroughBlock = fn.body.blocks.get(start.fallthrough)!;
const firstId =
fallthroughBlock.instructions[0]?.id ?? fallthroughBlock.terminal.id;
for (const scope of activeScopes) {
/**
* activeScopes is only filtered at block start points, so some of the
* scopes may not actually be active anymore, ie we've past their end
* instruction. Only extend ranges for scopes that are actually active.
*
* TODO: consider pruning activeScopes per instruction
*/
if (scope.range.end <= terminal.id) {
continue;
}
scope.range.start = makeInstructionId(
Math.min(start.range.start, scope.range.start),
);
scope.range.end = makeInstructionId(
Math.max(firstId, scope.range.end),
);
}
}
}
/*

View File

@@ -46,16 +46,14 @@ function useFoo(t0) {
t1 = $[0];
}
let items = t1;
if ($[1] !== cond) {
bb0: {
if (cond) {
items = [];
} else {
break bb0;
}
items.push(2);
bb0: if ($[1] !== cond) {
if (cond) {
items = [];
} else {
break bb0;
}
items.push(2);
$[1] = cond;
$[2] = items;
} else {

View File

@@ -49,12 +49,12 @@ import {
} from "shared-runtime";
function useFoo(t0) {
const $ = _c(4);
const $ = _c(3);
const { data } = t0;
let obj;
let myDiv = null;
if ($[0] !== data.cond || $[1] !== data.cond1) {
bb0: if (data.cond) {
bb0: if (data.cond) {
if ($[0] !== data.cond1) {
obj = makeObject_Primitives();
if (data.cond1) {
myDiv = <Stringify value={mutateAndReturn(obj)} />;
@@ -62,14 +62,13 @@ function useFoo(t0) {
}
mutate(obj);
$[0] = data.cond1;
$[1] = obj;
$[2] = myDiv;
} else {
obj = $[1];
myDiv = $[2];
}
$[0] = data.cond;
$[1] = data.cond1;
$[2] = obj;
$[3] = myDiv;
} else {
obj = $[2];
myDiv = $[3];
}
return myDiv;
}

View File

@@ -34,16 +34,17 @@ import { c as _c } from "react/compiler-runtime"; // @enablePropagateDepsInHIR
import { useMemo } from "react";
function Component(props) {
const $ = _c(5);
const $ = _c(6);
let t0;
if (
$[0] !== props.a ||
$[1] !== props.b ||
$[2] !== props.cond ||
$[3] !== props.cond2
) {
bb0: {
const y = [];
bb0: {
let y;
if (
$[0] !== props.a ||
$[1] !== props.b ||
$[2] !== props.cond ||
$[3] !== props.cond2
) {
y = [];
if (props.cond) {
y.push(props.a);
}
@@ -53,15 +54,17 @@ function Component(props) {
}
y.push(props.b);
t0 = y;
$[0] = props.a;
$[1] = props.b;
$[2] = props.cond;
$[3] = props.cond2;
$[4] = y;
$[5] = t0;
} else {
y = $[4];
t0 = $[5];
}
$[0] = props.a;
$[1] = props.b;
$[2] = props.cond;
$[3] = props.cond2;
$[4] = t0;
} else {
t0 = $[4];
t0 = y;
}
const x = t0;
return x;

View File

@@ -1,118 +0,0 @@
## Input
```javascript
import {useMemo} from 'react';
import {
makeObject_Primitives,
mutate,
Stringify,
ValidateMemoization,
} from 'shared-runtime';
function Component({cond}) {
const memoized = useMemo(() => {
const value = makeObject_Primitives();
if (cond) {
return value;
} else {
mutate(value);
return value;
}
}, [cond]);
return <ValidateMemoization inputs={[cond]} output={memoized} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{cond: false}],
sequentialRenders: [
{cond: false},
{cond: false},
{cond: true},
{cond: true},
{cond: false},
{cond: true},
{cond: false},
{cond: true},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { useMemo } from "react";
import {
makeObject_Primitives,
mutate,
Stringify,
ValidateMemoization,
} from "shared-runtime";
function Component(t0) {
const $ = _c(7);
const { cond } = t0;
let t1;
if ($[0] !== cond) {
const value = makeObject_Primitives();
if (cond) {
t1 = value;
} else {
mutate(value);
t1 = value;
}
$[0] = cond;
$[1] = t1;
} else {
t1 = $[1];
}
const memoized = t1;
let t2;
if ($[2] !== cond) {
t2 = [cond];
$[2] = cond;
$[3] = t2;
} else {
t2 = $[3];
}
let t3;
if ($[4] !== memoized || $[5] !== t2) {
t3 = <ValidateMemoization inputs={t2} output={memoized} />;
$[4] = memoized;
$[5] = t2;
$[6] = t3;
} else {
t3 = $[6];
}
return t3;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ cond: false }],
sequentialRenders: [
{ cond: false },
{ cond: false },
{ cond: true },
{ cond: true },
{ cond: false },
{ cond: true },
{ cond: false },
{ cond: true },
],
};
```
### Eval output
(kind: ok) <div>{"inputs":[false],"output":{"a":0,"b":"value1","c":true,"wat0":"joe"}}</div>
<div>{"inputs":[false],"output":{"a":0,"b":"value1","c":true,"wat0":"joe"}}</div>
<div>{"inputs":[true],"output":{"a":0,"b":"value1","c":true}}</div>
<div>{"inputs":[true],"output":{"a":0,"b":"value1","c":true}}</div>
<div>{"inputs":[false],"output":{"a":0,"b":"value1","c":true,"wat0":"joe"}}</div>
<div>{"inputs":[true],"output":{"a":0,"b":"value1","c":true}}</div>
<div>{"inputs":[false],"output":{"a":0,"b":"value1","c":true,"wat0":"joe"}}</div>
<div>{"inputs":[true],"output":{"a":0,"b":"value1","c":true}}</div>

View File

@@ -1,35 +0,0 @@
import {useMemo} from 'react';
import {
makeObject_Primitives,
mutate,
Stringify,
ValidateMemoization,
} from 'shared-runtime';
function Component({cond}) {
const memoized = useMemo(() => {
const value = makeObject_Primitives();
if (cond) {
return value;
} else {
mutate(value);
return value;
}
}, [cond]);
return <ValidateMemoization inputs={[cond]} output={memoized} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{cond: false}],
sequentialRenders: [
{cond: false},
{cond: false},
{cond: true},
{cond: true},
{cond: false},
{cond: true},
{cond: false},
{cond: true},
],
};

View File

@@ -33,16 +33,17 @@ import { c as _c } from "react/compiler-runtime";
import { useMemo } from "react";
function Component(props) {
const $ = _c(5);
const $ = _c(6);
let t0;
if (
$[0] !== props.a ||
$[1] !== props.b ||
$[2] !== props.cond ||
$[3] !== props.cond2
) {
bb0: {
const y = [];
bb0: {
let y;
if (
$[0] !== props.a ||
$[1] !== props.b ||
$[2] !== props.cond ||
$[3] !== props.cond2
) {
y = [];
if (props.cond) {
y.push(props.a);
}
@@ -52,15 +53,17 @@ function Component(props) {
}
y.push(props.b);
t0 = y;
$[0] = props.a;
$[1] = props.b;
$[2] = props.cond;
$[3] = props.cond2;
$[4] = y;
$[5] = t0;
} else {
y = $[4];
t0 = $[5];
}
$[0] = props.a;
$[1] = props.b;
$[2] = props.cond;
$[3] = props.cond2;
$[4] = t0;
} else {
t0 = $[4];
t0 = y;
}
const x = t0;
return x;

View File

@@ -22,22 +22,15 @@ yarn add eslint-plugin-react-hooks --dev
#### >= 6.0.0
For users of 6.0 and beyond, add the `recommended` config.
For users of 6.0 and beyond, simply add the `recommended` config.
```js
// eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
import * as reactHooks from 'eslint-plugin-react-hooks';
export default defineConfig([
{
files: ["src/**/*.{js,jsx,ts,tsx}"],
plugins: {
'react-hooks': reactHooks,
},
extends: ['react-hooks/recommended'],
},
]);
export default [
// ...
reactHooks.configs.recommended,
];
```
#### 5.2.0
@@ -45,18 +38,12 @@ export default defineConfig([
For users of 5.2.0 (the first version with flat config support), add the `recommended-latest` config.
```js
import reactHooks from 'eslint-plugin-react-hooks';
import { defineConfig } from 'eslint/config';
import * as reactHooks from 'eslint-plugin-react-hooks';
export default defineConfig([
{
files: ["src/**/*.{js,jsx,ts,tsx}"],
plugins: {
'react-hooks': reactHooks,
},
extends: ['react-hooks/recommended-latest'],
},
]);
export default [
// ...
reactHooks.configs['recommended-latest'],
];
```
### Legacy Config (.eslintrc)