Compare commits

..

5 Commits

Author SHA1 Message Date
Joe Savona
bc50ab4bff [compiler] Add enableObjectIsComparison feature flag
Adds a feature flag that causes the compiler to emit `!Object.is(a, b)` instead of `a !== b` for dependency comparisons in memoized scopes. This matches the comparison semantics used by React's own hooks (useState, useMemo, etc.), correctly handling NaN values.
2026-02-24 12:56:40 -08:00
Joseph Savona
e33071c614 [compiler] Improved ref validation for non-mutating functions (#35893)
If a function is known to freeze its inputs, and captures refs, then we
can safely assume those refs are not mutated during render.

An example is React Native's PanResponder, which is designed for use in
interaction handling. Calling `PanResponder.create()` creates an object
that shouldn't be interacted with at render time, so we can treat it as
freezing its arguments, returning a frozen value, and not accessing any
refs in the callbacks passed to it. ValidateNoRefAccessInRender is
updated accordingly - if we see a Freeze <place> and ImmutableCapture
<place> for the same place in the same instruction, we know that it's
not being mutated.

Note that this is a pretty targeted fix. One weakness is that we may not
always emit a Freeze effect if a value is already frozen, which could
cause this optimization not to kick in. The worst case there is that
you'd just get a ref access in render error though, not miscompilation.
And we could always choose to always emit Freeze effects, even for
frozen values, just to retain the information for validations like this.
2026-02-24 12:36:32 -08:00
Mushaheed Kapadia
c0060cf2a6 [DevTools] Enable support for the React DevTools Client to connect to different host/port/path (#35886)
## Summary

This enables routing the React Dev Tools through a remote server by
being able to specify host, port, and path for the client to connect to.
Basically allowing the React Dev Tools server to have the client connect
elsewhere.

This setups a `clientOptions` which can be set up through environment
variables when starting the React Dev Tools server.

This change shouldn't affect the traditional usage for React Dev Tools.

EDIT: the additional change was moved to another PR 

## How did you test this change?

Run React DevTools with 
```
$ REACT_DEVTOOLS_CLIENT_HOST=<MY_HOST> REACT_DEVTOOLS_CLIENT_PORT=443 REACT_DEVTOOLS_CLIENT_USE_HTTPS=true REACT_DEVTOOLS_PATH=/__react_devtools__/ yarn start

```

Confirm that my application connects to the local React Dev Tools
server/instance/electron app through my remote server.
2026-02-24 15:36:32 +00:00
Mushaheed Kapadia
bd76b456c1 [DevTools] Fix ReactDevToolsBackend module for AMD (#35891)
## Summary

For apps that use AMD, we need to actually `require()` the
ReactDevToolsBackend and load it from the AMD module cache. This adds a
check for the case where the `ReactDevToolsBackend` isn't defined
globally, and so we load it with `require()`.


## How did you test this change?

Tested through https://github.com/facebook/react/pull/35886
2026-02-24 15:27:59 +00:00
Joseph Savona
b354bbd2d2 [compiler] Update docs with fault tolerance summary, remove planning doc (#35888)
Add concise fault tolerance documentation to CLAUDE.md and the passes
README covering error accumulation, tryRecord wrapping, and the
distinction between validation vs infrastructure passes. Remove the
detailed planning document now that the work is complete.
2026-02-23 16:18:44 -08:00
26 changed files with 784 additions and 45 deletions

View File

@@ -35,6 +35,20 @@ yarn snap -p <file-basename> -d
yarn snap -u
```
## Linting
```bash
# Run lint on the compiler source
yarn workspace babel-plugin-react-compiler lint
```
## Formatting
```bash
# Run prettier on all files (from the react root directory, not compiler/)
yarn prettier-all
```
## Compiling Arbitrary Files
Use `yarn snap compile` to compile any file (not just fixtures) with the React Compiler:

View File

@@ -83,6 +83,8 @@ export class ProgramContext {
knownReferencedNames: Set<string> = new Set();
// generated imports
imports: Map<string, Map<string, NonLocalImportSpecifier>> = new Map();
// hoisted program-level constants
constants: Map<string, {uid: string; init: t.Expression}> = new Map();
constructor({
program,
@@ -182,6 +184,16 @@ export class ProgramContext {
this.knownReferencedNames.add(name);
}
addProgramConstant(name: string, init: () => t.Expression): string {
const existing = this.constants.get(name);
if (existing !== undefined) {
return existing.uid;
}
const uid = this.newUid(name);
this.constants.set(name, {uid, init: init()});
return uid;
}
assertGlobalBinding(
name: string,
localScope?: BabelScope,
@@ -302,6 +314,13 @@ export function addImportsToProgram(
}
}
}
for (const [, {uid, init}] of programContext.constants) {
stmts.push(
t.variableDeclaration('const', [
t.variableDeclarator(t.identifier(uid), init),
]),
);
}
path.unshiftContainer('body', stmts);
}

View File

@@ -502,6 +502,16 @@ export const EnvironmentConfigSchema = z.object({
* 3. Force update / external sync - should use useSyncExternalStore
*/
enableVerboseNoSetStateInEffect: z.boolean().default(false),
/**
* When enabled, the compiler emits `!Object.is(a, b)` instead of `a !== b`
* for dependency comparisons in memoized scopes. This matches the comparison
* semantics used by React's own hooks (useState, useMemo, etc.), which use
* Object.is. The main difference is in handling of NaN: `NaN !== NaN` is
* always true (causing memos to never be reused), while
* `Object.is(NaN, NaN)` is true (correctly treating NaN as unchanged).
*/
enableObjectIsComparison: z.boolean().default(false),
});
export type EnvironmentConfig = z.infer<typeof EnvironmentConfigSchema>;

View File

@@ -182,8 +182,8 @@ export function codegenFunction(
const index = cx.synthesizeName('$i');
preface.push(
t.ifStatement(
t.binaryExpression(
'!==',
codegenNegatedComparison(
cx,
t.memberExpression(
t.identifier(cx.synthesizeName('$')),
t.numericLiteral(fastRefreshState.cacheIndex),
@@ -557,6 +557,23 @@ function codegenBlockNoReset(
return t.blockStatement(statements);
}
function codegenNegatedComparison(
cx: Context,
left: t.Expression,
right: t.Expression,
): t.Expression {
if (cx.env.config.enableObjectIsComparison) {
const isName = cx.env.programContext.addProgramConstant('is', () =>
t.memberExpression(t.identifier('Object'), t.identifier('is')),
);
return t.unaryExpression(
'!',
t.callExpression(t.identifier(isName), [left, right]),
);
}
return t.binaryExpression('!==', left, right);
}
function codegenReactiveScope(
cx: Context,
statements: Array<t.Statement>,
@@ -574,8 +591,8 @@ function codegenReactiveScope(
for (const dep of [...scope.dependencies].sort(compareScopeDependency)) {
const index = cx.nextCacheIndex;
const comparison = t.binaryExpression(
'!==',
const comparison = codegenNegatedComparison(
cx,
t.memberExpression(
t.identifier(cx.synthesizeName('$')),
t.numericLiteral(index),
@@ -720,8 +737,8 @@ function codegenReactiveScope(
const name: ValidIdentifierName = earlyReturnValue.value.name.value;
statements.push(
t.ifStatement(
t.binaryExpression(
'!==',
codegenNegatedComparison(
cx,
t.identifier(name),
t.callExpression(
t.memberExpression(t.identifier('Symbol'), t.identifier('for')),

View File

@@ -487,24 +487,26 @@ function validateNoRefAccessInRenderImpl(
*/
if (!didError) {
const isRefLValue = isUseRefType(instr.lvalue.identifier);
for (const operand of eachInstructionValueOperand(instr.value)) {
/**
* By default we check that function call operands are not refs,
* ref values, or functions that can access refs.
*/
if (
isRefLValue ||
(hookKind != null &&
hookKind !== 'useState' &&
hookKind !== 'useReducer')
) {
if (
isRefLValue ||
(hookKind != null &&
hookKind !== 'useState' &&
hookKind !== 'useReducer')
) {
for (const operand of eachInstructionValueOperand(
instr.value,
)) {
/**
* Allow passing refs or ref-accessing functions when:
* 1. lvalue is a ref (mergeRefs pattern: `mergeRefs(ref1, ref2)`)
* 2. calling hooks (independently validated for ref safety)
*/
validateNoDirectRefValueAccess(errors, operand, env);
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) {
}
} else if (interpolatedAsJsx.has(instr.lvalue.identifier.id)) {
for (const operand of eachInstructionValueOperand(
instr.value,
)) {
/**
* Special case: the lvalue is passed as a jsx child
*
@@ -513,7 +515,98 @@ function validateNoRefAccessInRenderImpl(
* render function which attempts to obey the rules.
*/
validateNoRefValueAccess(errors, env, operand);
} else {
}
} else if (hookKind == null && instr.effects != null) {
/**
* For non-hook functions with known aliasing effects, use the
* effects to determine what validation to apply for each place.
* Track visited id:kind pairs to avoid duplicate errors.
*/
const visitedEffects: Set<string> = new Set();
for (const effect of instr.effects) {
let place: Place | null = null;
let validation: 'ref-passed' | 'direct-ref' | 'none' = 'none';
switch (effect.kind) {
case 'Freeze': {
place = effect.value;
validation = 'direct-ref';
break;
}
case 'Mutate':
case 'MutateTransitive':
case 'MutateConditionally':
case 'MutateTransitiveConditionally': {
place = effect.value;
validation = 'ref-passed';
break;
}
case 'Render': {
place = effect.place;
validation = 'ref-passed';
break;
}
case 'Capture':
case 'Alias':
case 'MaybeAlias':
case 'Assign':
case 'CreateFrom': {
place = effect.from;
validation = 'ref-passed';
break;
}
case 'ImmutableCapture': {
/**
* ImmutableCapture can come from two sources:
* 1. A known signature that explicitly freezes the operand
* (e.g. PanResponder.create) — safe, the function doesn't
* call callbacks during render.
* 2. Downgraded defaults when the operand is already frozen
* (e.g. foo(propRef)) — the function is unknown and may
* access the ref.
*
* We distinguish these by checking whether the same operand
* also has a Freeze effect on this instruction, which only
* comes from known signatures.
*/
place = effect.from;
const isFrozen = instr.effects.some(
e =>
e.kind === 'Freeze' &&
e.value.identifier.id === effect.from.identifier.id,
);
validation = isFrozen ? 'direct-ref' : 'ref-passed';
break;
}
case 'Create':
case 'CreateFunction':
case 'Apply':
case 'Impure':
case 'MutateFrozen':
case 'MutateGlobal': {
break;
}
}
if (place !== null && validation !== 'none') {
const key = `${place.identifier.id}:${validation}`;
if (!visitedEffects.has(key)) {
visitedEffects.add(key);
if (validation === 'direct-ref') {
validateNoDirectRefValueAccess(errors, place, env);
} else {
validateNoRefPassedToFunction(
errors,
env,
place,
place.loc,
);
}
}
}
}
} else {
for (const operand of eachInstructionValueOperand(
instr.value,
)) {
validateNoRefPassedToFunction(
errors,
env,

View File

@@ -3,8 +3,10 @@
```javascript
// @validateRefAccessDuringRender:true
import {mutate} from 'shared-runtime';
function Foo(props, ref) {
console.log(ref.current);
mutate(ref.current);
return <div>{props.bar}</div>;
}
@@ -26,14 +28,14 @@ Error: Cannot access refs during render
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).
error.validate-mutate-ref-arg-in-render.ts:3:14
1 | // @validateRefAccessDuringRender:true
2 | function Foo(props, ref) {
> 3 | console.log(ref.current);
| ^^^^^^^^^^^ Passing a ref to a function may read its value during render
4 | return <div>{props.bar}</div>;
5 | }
6 |
error.validate-mutate-ref-arg-in-render.ts:5:9
3 |
4 | function Foo(props, ref) {
> 5 | mutate(ref.current);
| ^^^^^^^^^^^ Passing a ref to a function may read its value during render
6 | return <div>{props.bar}</div>;
7 | }
8 |
```

View File

@@ -1,6 +1,8 @@
// @validateRefAccessDuringRender:true
import {mutate} from 'shared-runtime';
function Foo(props, ref) {
console.log(ref.current);
mutate(ref.current);
return <div>{props.bar}</div>;
}

View File

@@ -0,0 +1,56 @@
## Input
```javascript
// @enableObjectIsComparison
const is = null;
function Component(props) {
const x = [props.x];
console.log(is);
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 42}],
sequentialRenders: [{x: 42}, {x: 42}, {x: 3.14}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
const _is = Object.is; // @enableObjectIsComparison
const is = null;
function Component(props) {
const $ = _c(2);
let t0;
if (!_is($[0], props.x)) {
t0 = [props.x];
$[0] = props.x;
$[1] = t0;
} else {
t0 = $[1];
}
const x = t0;
console.log(is);
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
sequentialRenders: [{ x: 42 }, { x: 42 }, { x: 3.14 }],
};
```
### Eval output
(kind: ok) [42]
[42]
[3.14]
logs: [null,null,null]

View File

@@ -0,0 +1,14 @@
// @enableObjectIsComparison
const is = null;
function Component(props) {
const x = [props.x];
console.log(is);
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 42}],
sequentialRenders: [{x: 42}, {x: 42}, {x: 3.14}],
};

View File

@@ -0,0 +1,91 @@
## Input
```javascript
// @enableObjectIsComparison
import {makeArray} from 'shared-runtime';
function Component(props) {
let x = [];
if (props.cond) {
x.push(props.a);
return x;
} else {
return makeArray(props.b);
}
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
sequentialRenders: [
{cond: true, a: 42},
{cond: true, a: 42},
{cond: false, b: 3.14},
{cond: false, b: 3.14},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
const is = Object.is; // @enableObjectIsComparison
import { makeArray } from "shared-runtime";
function Component(props) {
const $ = _c(6);
let t0;
if (!is($[0], props.a) || !is($[1], props.b) || !is($[2], props.cond)) {
t0 = Symbol.for("react.early_return_sentinel");
bb0: {
const x = [];
if (props.cond) {
x.push(props.a);
t0 = x;
break bb0;
} else {
let t1;
if (!is($[4], props.b)) {
t1 = makeArray(props.b);
$[4] = props.b;
$[5] = t1;
} else {
t1 = $[5];
}
t0 = t1;
break bb0;
}
}
$[0] = props.a;
$[1] = props.b;
$[2] = props.cond;
$[3] = t0;
} else {
t0 = $[3];
}
if (!is(t0, Symbol.for("react.early_return_sentinel"))) {
return t0;
}
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
sequentialRenders: [
{ cond: true, a: 42 },
{ cond: true, a: 42 },
{ cond: false, b: 3.14 },
{ cond: false, b: 3.14 },
],
};
```
### Eval output
(kind: ok) [42]
[42]
[3.14]
[3.14]

View File

@@ -0,0 +1,23 @@
// @enableObjectIsComparison
import {makeArray} from 'shared-runtime';
function Component(props) {
let x = [];
if (props.cond) {
x.push(props.a);
return x;
} else {
return makeArray(props.b);
}
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [],
sequentialRenders: [
{cond: true, a: 42},
{cond: true, a: 42},
{cond: false, b: 3.14},
{cond: false, b: 3.14},
],
};

View File

@@ -0,0 +1,60 @@
## Input
```javascript
// @enableObjectIsComparison @enableResetCacheOnSourceFileChanges
function Component(props) {
const x = [props.x];
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 42}],
sequentialRenders: [{x: 42}, {x: 42}, {x: 3.14}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
const is = Object.is; // @enableObjectIsComparison @enableResetCacheOnSourceFileChanges
function Component(props) {
const $ = _c(3);
if (
!is(
$[0],
"eb2ec56d8fdd083c203a119ff37576dc8782330598640f725524102ae79e8b5c",
)
) {
for (let $i = 0; $i < 3; $i += 1) {
$[$i] = Symbol.for("react.memo_cache_sentinel");
}
$[0] = "eb2ec56d8fdd083c203a119ff37576dc8782330598640f725524102ae79e8b5c";
}
let t0;
if (!is($[1], props.x)) {
t0 = [props.x];
$[1] = props.x;
$[2] = t0;
} else {
t0 = $[2];
}
const x = t0;
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
sequentialRenders: [{ x: 42 }, { x: 42 }, { x: 3.14 }],
};
```
### Eval output
(kind: ok) [42]
[42]
[3.14]

View File

@@ -0,0 +1,11 @@
// @enableObjectIsComparison @enableResetCacheOnSourceFileChanges
function Component(props) {
const x = [props.x];
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 42}],
sequentialRenders: [{x: 42}, {x: 42}, {x: 3.14}],
};

View File

@@ -0,0 +1,49 @@
## Input
```javascript
// @enableObjectIsComparison
function Component(props) {
const x = [props.x];
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 42}],
sequentialRenders: [{x: 42}, {x: 42}, {x: 3.14}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
const is = Object.is; // @enableObjectIsComparison
function Component(props) {
const $ = _c(2);
let t0;
if (!is($[0], props.x)) {
t0 = [props.x];
$[0] = props.x;
$[1] = t0;
} else {
t0 = $[1];
}
const x = t0;
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 42 }],
sequentialRenders: [{ x: 42 }, { x: 42 }, { x: 3.14 }],
};
```
### Eval output
(kind: ok) [42]
[42]
[3.14]

View File

@@ -0,0 +1,11 @@
// @enableObjectIsComparison
function Component(props) {
const x = [props.x];
return x;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 42}],
sequentialRenders: [{x: 42}, {x: 42}, {x: 3.14}],
};

View File

@@ -0,0 +1,77 @@
## Input
```javascript
// @flow
import {PanResponder, Stringify} from 'shared-runtime';
export default component Playground() {
const onDragEndRef = useRef(() => {});
useEffect(() => {
onDragEndRef.current = () => {
console.log('drag ended');
};
});
const panResponder = useMemo(
() =>
PanResponder.create({
onPanResponderTerminate: () => {
onDragEndRef.current();
},
}),
[]
);
return <Stringify responder={panResponder} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { PanResponder, Stringify } from "shared-runtime";
export default function Playground() {
const $ = _c(3);
const onDragEndRef = useRef(_temp);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
onDragEndRef.current = _temp2;
};
$[0] = t0;
} else {
t0 = $[0];
}
useEffect(t0);
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = PanResponder.create({
onPanResponderTerminate: () => {
onDragEndRef.current();
},
});
$[1] = t1;
} else {
t1 = $[1];
}
const panResponder = t1;
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = <Stringify responder={panResponder} />;
$[2] = t2;
} else {
t2 = $[2];
}
return t2;
}
function _temp2() {
console.log("drag ended");
}
function _temp() {}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,21 @@
// @flow
import {PanResponder, Stringify} from 'shared-runtime';
export default component Playground() {
const onDragEndRef = useRef(() => {});
useEffect(() => {
onDragEndRef.current = () => {
console.log('drag ended');
};
});
const panResponder = useMemo(
() =>
PanResponder.create({
onPanResponderTerminate: () => {
onDragEndRef.current();
},
}),
[]
);
return <Stringify responder={panResponder} />;
}

View File

@@ -196,6 +196,44 @@ export function makeSharedRuntimeTypeProvider({
],
},
},
PanResponder: {
kind: 'object',
properties: {
create: {
kind: 'function',
positionalParams: [EffectEnum.Freeze],
restParam: null,
calleeEffect: EffectEnum.Read,
returnType: {kind: 'type', name: 'Any'},
returnValueKind: ValueKindEnum.Frozen,
aliasing: {
receiver: '@receiver',
params: ['@config'],
rest: null,
returns: '@returns',
temporaries: [],
effects: [
{
kind: 'Freeze',
value: '@config',
reason: ValueReasonEnum.KnownReturnSignature,
},
{
kind: 'Create',
into: '@returns',
value: ValueKindEnum.Frozen,
reason: ValueReasonEnum.KnownReturnSignature,
},
{
kind: 'ImmutableCapture',
from: '@config',
into: '@returns',
},
],
},
},
},
},
},
};
} else if (moduleName === 'ReactCompilerKnownIncompatibleTest') {

View File

@@ -421,4 +421,10 @@ export function typedMutate(x: any, v: any = null): void {
x.property = v;
}
export const PanResponder = {
create(obj: any): any {
return obj;
},
};
export default typedLog;

View File

@@ -54,6 +54,7 @@ Each filter object must include `type` and `isEnabled`. Some filters also requir
|------------------------|---------------|---------------------------------------------------------------------------------------------------------------------------|
| `host` | `"localhost"` | Socket connection to frontend should use this host. |
| `isAppActive` | | (Optional) function that returns true/false, telling DevTools when it's ready to connect to React. |
| `path` | `""` | Path appended to the WebSocket URI (e.g. `"/__react_devtools__/"`). Useful when proxying through a reverse proxy on a subpath. A leading `/` is added automatically if missing. |
| `port` | `8097` | Socket connection to frontend should use this port. |
| `resolveRNStyle` | | (Optional) function that accepts a key (number) and returns a style (object); used by React Native. |
| `retryConnectionDelay` | `200` | Delay (ms) to wait between retrying a failed Websocket connection |
@@ -141,16 +142,51 @@ function onStatus(
}
```
#### `startServer(port?: number, host?: string, httpsOptions?: Object, loggerOptions?: Object)`
#### `startServer(port?, host?, httpsOptions?, loggerOptions?, path?, clientOptions?)`
Start a socket server (used to communicate between backend and frontend) and renders the DevTools UI.
This method accepts the following parameters:
| Name | Default | Description |
|---|---|---|
| `port` | `8097` | Socket connection to backend should use this port. |
| `host` | `"localhost"` | Socket connection to backend should use this host. |
| `port` | `8097` | Port the local server listens on. |
| `host` | `"localhost"` | Host the local server binds to. |
| `httpsOptions` | | _Optional_ object defining `key` and `cert` strings. |
| `loggerOptions` | | _Optional_ object defining a `surface` string (to be included with DevTools logging events). |
| `path` | | _Optional_ path to append to the WebSocket URI served to connecting clients (e.g. `"/__react_devtools__/"`). Also set via the `REACT_DEVTOOLS_PATH` env var in the Electron app. |
| `clientOptions` | | _Optional_ object with client-facing overrides (see below). |
##### `clientOptions`
When connecting through a reverse proxy, the client may need to connect to a different host, port, or protocol than the local server. Use `clientOptions` to override what appears in the `connectToDevTools()` script served to clients. Any field not set falls back to the corresponding server value.
| Field | Default | Description |
|---|---|---|
| `host` | server `host` | Host the client connects to. |
| `port` | server `port` | Port the client connects to. |
| `useHttps` | server `useHttps` | Whether the client should use `wss://`. |
These can also be set via environment variables in the Electron app:
| Env Var | Description |
|---|---|
| `REACT_DEVTOOLS_CLIENT_HOST` | Overrides the host in the served client script. |
| `REACT_DEVTOOLS_CLIENT_PORT` | Overrides the port in the served client script. |
| `REACT_DEVTOOLS_CLIENT_USE_HTTPS` | Set to `"true"` to make the served client script use `wss://`. |
##### Reverse proxy example
Run DevTools locally on the default port, but tell clients to connect through a remote proxy:
```sh
REACT_DEVTOOLS_CLIENT_HOST=remote.example.com \
REACT_DEVTOOLS_CLIENT_PORT=443 \
REACT_DEVTOOLS_CLIENT_USE_HTTPS=true \
REACT_DEVTOOLS_PATH=/__react_devtools__/ \
react-devtools
```
The server listens on `localhost:8097`. The served script tells clients:
```js
connectToDevTools({host: 'remote.example.com', port: 443, useHttps: true, path: '/__react_devtools__/'})
```
# Development

View File

@@ -33,6 +33,7 @@ import type {ResolveNativeStyle} from 'react-devtools-shared/src/backend/NativeS
type ConnectOptions = {
host?: string,
nativeStyleEditorValidAttributes?: $ReadOnlyArray<string>,
path?: string,
port?: number,
useHttps?: boolean,
resolveRNStyle?: ResolveNativeStyle,
@@ -93,6 +94,7 @@ export function connectToDevTools(options: ?ConnectOptions) {
const {
host = 'localhost',
nativeStyleEditorValidAttributes,
path = '',
useHttps = false,
port = 8097,
websocket,
@@ -107,6 +109,7 @@ export function connectToDevTools(options: ?ConnectOptions) {
} = options || {};
const protocol = useHttps ? 'wss' : 'ws';
const prefixedPath = path !== '' && !path.startsWith('/') ? '/' + path : path;
let retryTimeoutID: TimeoutID | null = null;
function scheduleRetry() {
@@ -129,7 +132,7 @@ export function connectToDevTools(options: ?ConnectOptions) {
let bridge: BackendBridge | null = null;
const messageListeners = [];
const uri = protocol + '://' + host + ':' + port;
const uri = protocol + '://' + host + ':' + port + prefixedPath;
// If existing websocket is passed, use it.
// This is necessary to support our custom integrations.

View File

@@ -306,11 +306,19 @@ type LoggerOptions = {
surface?: ?string,
};
type ClientOptions = {
host?: string,
port?: number,
useHttps?: boolean,
};
function startServer(
port: number = 8097,
host: string = 'localhost',
httpsOptions?: ServerOptions,
loggerOptions?: LoggerOptions,
path?: string,
clientOptions?: ClientOptions,
): {close(): void} {
registerDevToolsEventLogger(loggerOptions?.surface ?? 'standalone');
@@ -345,7 +353,18 @@ function startServer(
server.on('error', (event: $FlowFixMe) => {
onError(event);
log.error('Failed to start the DevTools server', event);
startServerTimeoutID = setTimeout(() => startServer(port), 1000);
startServerTimeoutID = setTimeout(
() =>
startServer(
port,
host,
httpsOptions,
loggerOptions,
path,
clientOptions,
),
1000,
);
});
httpServer.on('request', (request: $FlowFixMe, response: $FlowFixMe) => {
@@ -358,14 +377,21 @@ function startServer(
// This will ensure that saved filters are shared across different web pages.
const componentFiltersString = JSON.stringify(getSavedComponentFilters());
// Client overrides: when connecting through a reverse proxy, the client
// may need to connect to a different host/port/protocol than the server.
const clientHost = clientOptions?.host ?? host;
const clientPort = clientOptions?.port ?? port;
const clientUseHttps = clientOptions?.useHttps ?? useHttps;
response.end(
backendFile.toString() +
'\n;' +
`var ReactDevToolsBackend = typeof ReactDevToolsBackend !== "undefined" ? ReactDevToolsBackend : require("ReactDevToolsBackend");\n` +
`ReactDevToolsBackend.initialize(undefined, undefined, undefined, ${componentFiltersString});` +
'\n' +
`ReactDevToolsBackend.connectToDevTools({port: ${port}, host: '${host}', useHttps: ${
useHttps ? 'true' : 'false'
}});
`ReactDevToolsBackend.connectToDevTools({port: ${clientPort}, host: '${clientHost}', useHttps: ${
clientUseHttps ? 'true' : 'false'
}${path != null ? `, path: '${path}'` : ''}});
`,
);
});
@@ -373,7 +399,18 @@ function startServer(
httpServer.on('error', (event: $FlowFixMe) => {
onError(event);
statusListener('Failed to start the server.', 'error');
startServerTimeoutID = setTimeout(() => startServer(port), 1000);
startServerTimeoutID = setTimeout(
() =>
startServer(
port,
host,
httpsOptions,
loggerOptions,
path,
clientOptions,
),
1000,
);
});
httpServer.listen(port, () => {

View File

@@ -44,6 +44,7 @@ module.exports = {
// This name is important; standalone references it in order to connect.
library: 'ReactDevToolsBackend',
libraryTarget: 'umd',
umdNamedDefine: true,
},
resolve: {
alias: {

View File

@@ -87,7 +87,31 @@ This will ensure the developer tools are connected. **Dont forget to remove i
## Advanced
By default DevTools listen to port `8097` on `localhost`. The port can be modified by setting the `REACT_DEVTOOLS_PORT` environment variable. If you need to further customize host, port, or other settings, see the `react-devtools-core` package instead.
By default DevTools listen to port `8097` on `localhost`. If you need to customize the server or client connection settings, the following environment variables are available:
| Env Var | Default | Description |
|---|---|---|
| `HOST` | `"localhost"` | Host the local server binds to. |
| `PORT` | `8097` | Port the local server listens on. |
| `REACT_DEVTOOLS_PORT` | | Alias for `PORT`. Takes precedence if both are set. |
| `KEY` | | Path to an SSL key file. Enables HTTPS when set alongside `CERT`. |
| `CERT` | | Path to an SSL certificate file. Enables HTTPS when set alongside `KEY`. |
| `REACT_DEVTOOLS_PATH` | | Path appended to the WebSocket URI served to clients (e.g. `/__react_devtools__/`). |
| `REACT_DEVTOOLS_CLIENT_HOST` | `HOST` | Overrides the host in the script served to connecting clients. |
| `REACT_DEVTOOLS_CLIENT_PORT` | `PORT` | Overrides the port in the script served to connecting clients. |
| `REACT_DEVTOOLS_CLIENT_USE_HTTPS` | | Set to `"true"` to make the served client script use `wss://`. |
When connecting through a reverse proxy, use the `REACT_DEVTOOLS_CLIENT_*` variables to tell clients to connect to a different host/port/protocol than the local server:
```sh
REACT_DEVTOOLS_CLIENT_HOST=remote.example.com \
REACT_DEVTOOLS_CLIENT_PORT=443 \
REACT_DEVTOOLS_CLIENT_USE_HTTPS=true \
REACT_DEVTOOLS_PATH=/__react_devtools__/ \
react-devtools
```
For more details, see the [`react-devtools-core` documentation](https://github.com/facebook/react/tree/main/packages/react-devtools-core).
## FAQ

View File

@@ -158,12 +158,19 @@
<script>
// window.api is defined in preload.js
const {electron, readEnv, ip, getDevTools} = window.api;
const {options, useHttps, host, protocol, port} = readEnv();
const {options, useHttps, host, protocol, port, path, clientHost, clientPort, clientUseHttps} = readEnv();
const localIp = ip.address();
const defaultPort = (port === 443 && useHttps) || (port === 80 && !useHttps);
const server = defaultPort ? `${protocol}://${host}` : `${protocol}://${host}:${port}`;
const serverIp = defaultPort ? `${protocol}://${localIp}` : `${protocol}://${localIp}:${port}`;
// Effective values for display URLs: client overrides take precedence over server values.
const effectiveHost = clientHost != null ? clientHost : host;
const effectivePort = clientPort != null ? clientPort : port;
const effectiveUseHttps = clientUseHttps != null ? clientUseHttps : useHttps;
const effectiveProtocol = effectiveUseHttps ? 'https' : 'http';
const defaultPort = (effectivePort === 443 && effectiveUseHttps) || (effectivePort === 80 && !effectiveUseHttps);
const pathStr = path != null ? path : '';
const server = defaultPort ? `${effectiveProtocol}://${effectiveHost}${pathStr}` : `${effectiveProtocol}://${effectiveHost}:${effectivePort}${pathStr}`;
const serverIp = defaultPort ? `${effectiveProtocol}://${localIp}${pathStr}` : `${effectiveProtocol}://${localIp}:${effectivePort}${pathStr}`;
const $ = document.querySelector.bind(document);
let timeoutID;
@@ -234,7 +241,7 @@
element.innerText = status;
}
})
.startServer(port, host, options);
.startServer(port, host, options, undefined, path, {host: clientHost, port: clientPort, useHttps: clientUseHttps});
</script>
</body>
</html>

View File

@@ -36,6 +36,23 @@ contextBridge.exposeInMainWorld('api', {
const host = process.env.HOST || 'localhost';
const protocol = useHttps ? 'https' : 'http';
const port = +process.env.REACT_DEVTOOLS_PORT || +process.env.PORT || 8097;
return {options, useHttps, host, protocol, port};
const path = process.env.REACT_DEVTOOLS_PATH || undefined;
const clientHost = process.env.REACT_DEVTOOLS_CLIENT_HOST || undefined;
const clientPort = process.env.REACT_DEVTOOLS_CLIENT_PORT
? +process.env.REACT_DEVTOOLS_CLIENT_PORT
: undefined;
const clientUseHttps =
process.env.REACT_DEVTOOLS_CLIENT_USE_HTTPS === 'true' ? true : undefined;
return {
options,
useHttps,
host,
protocol,
port,
path,
clientHost,
clientPort,
clientUseHttps,
};
},
});