Compare commits

...

12 Commits

Author SHA1 Message Date
Joe Savona
758686c1dc [compiler] Fixes to enableTreatRefLikeIdentifiersAsRefs
We added the `@enableTreatRefLikeIdentifiersAsRefs` feature a while back but never enabled it. Since then we've continued to see examples that motivate this mode, so here we're fixing it up to prepare to enable by default. It now works as follows:

* If we find a property load or property store where both a) the object's name is ref-like (`ref` or `-Ref`) and b) the property is `current`, we infer the object itself as a ref and the value of the property as a ref value. Originally the feature only detected property loads, not stores.
* Inferred refs are not considered stable (this is a change from the original implementation). The only way to get a stable ref is by calling `useRef()`. We've seen issues with assuming refs are stable.

With this change, cases like the following now correctly error:

```js
function Foo(props) {
  const fooRef = props.fooRef;
  fooRef.current = true;
  ^^^^^^^^^^^^^^ cannot modify ref in render
}
```
2025-07-25 12:15:54 -07:00
Joseph Savona
2aa5f9d4e3 [compiler] fix false positive "mutate frozen" validation with refs (#33993)
The test case here previously reported a "Cannot modify local variables
after render completes" error (from
ValidateNoFreezingKnownMutableFunctions). This happens because one of
the functions passed to a hook clearly mutates a ref — except that we
try to ignore mutations of refs! The problem in this case is that the
`const ref = ...` was getting converted to a context variable since the
ref is accessed in a function before its declaration. We don't infer
types for context variables at all, and our ref handling is based on
types, so we failed to ignore this ref mutation.

The fix is to recognize that `StoreLocal const ...` is a special case:
the variable may be referenced in code before the declaration, but at
runtime it's either a TDZ error or the variable will have the type from
the declaration. So we can safely infer a type.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33993).
* __->__ #33993
* #33991
* #33984
2025-07-25 10:08:09 -07:00
Joseph Savona
8c587a2a41 [compiler] clarify text for setState-in-effect error (#33991)
---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33991).
* #33993
* __->__ #33991
* #33984
2025-07-25 10:07:55 -07:00
Joseph Savona
12483a119b [compiler] Fix for edge cases of mutation of potentially frozen values (#33984)
Fixes two related cases of mutation of potentially frozen values.

The first is method calls on frozen values. Previously, we modeled
unknown function calls as potentially aliasing their receiver+args into
the return value. If the receiver or argument were known to be frozen,
then we would downgrade the `Alias` effect into an `ImmutableCapture`.
However, within a function expression it's possible to call a function
using a frozen value as an argument (that gets `Alias`-ed into the
return) but where we don't have the context locally to know that the
value is frozen.

This results in cases like this:

```js
const frozen = useContext(...);
useEffect(() => {
  frozen.method().property = true;
  ^^^^^^^^^^^^^^^^^^^^^^^^ cannot mutate frozen value
}, [...]);
```

Within the function we would infer:

```
t0 = MethodCall ...
  Create t0 = mutable
  Alias t0 <- frozen
t1 = PropertyStore ...
  Mutate t0
```

And then transitively infer the function expression as having a `Mutate
'frozen'` effect, which when evaluated against the outer context
(`frozen` is frozen) is an error.

The fix is to model unknown function calls as _maybe_ aliasing their
receiver/args in the return, and then considering mutations of a
maybe-aliased value to only be a conditional mutation of the source:


```
t0 = MethodCall ...
  Create t0 = mutable
  MaybeAlias t0 <- frozen // maybe alias now
t1 = PropertyStore ...
  Mutate t0
```

Then, the `Mutate t0` turns into a `MutateConditional 'frozen'`, which
just gets ignored when we process the outer context.

The second, related fix is for known mutation of phis that may be a
frozen value. The previous inference model correctly recorded these as
errors, the new model does not. We now correctly report a validation
error for this case in the new model.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33984).
* #33993
* #33991
* __->__ #33984
2025-07-25 10:07:24 -07:00
Sebastian Markbåge
b2c30493ce [DevTools] Use the hard coded url instead of the local storage url for presets (and make VSCode default) (#33995)
Stacked on #33983.

Previously, the source of truth is the url stored in local storage but
that means if we change the presets then they don't take effect (e.g.
#33994). This PR uses the hardcoded value instead when a preset is
selected.

This also has the benefit that if you switch between custom and vs code
in the selector, then the custom url is preserved instead of getting
reset when you checkout other options.

Currently the default is custom with empty string, which means that
there's no code editor configured at all by default. It doesn't make a
lot of sense that we have it not working by default when so many people
use VS Code. So this also makes VS Code the default if there's no
EDITOR_URL env specified.
2025-07-25 10:27:27 -04:00
Sebastian Markbåge
36c2bf5c3e [DevTools] Allow all file links in Chrome DevTools to open in external editor (#33985)
Stacked on #33983.

Allow React to be configured as the default handler of all links in
Chrome DevTools. To do this you need to configure the Chrome DevTools
setting for "Link Handling:" to be set to "React Developer Tools". By
default this doesn't do anything but if you then check the box added in
#33983 it starts open local files directly in the external editor.

This needs docs to show how to enable that option.

(As far as I can tell this broke in Chrome Canary 🙄 but hopefully fixed
before stable.)
2025-07-25 10:27:09 -04:00
Sebastian Markbåge
190758e623 [DevTools] Add column to vscode editor preset (#33994)
We should jump to the right column.

Unfortunately, the way presets are set up now you have to switch off and
switch to the preset for this to take effect.
2025-07-25 10:21:00 -04:00
Sebastian Markbåge
b1a6f03f8a [DevTools] Rerender when the browser theme changes (#33992)
When the browser theme changes, we don't immediately rerender the UI so
we don't pick up the new theme if the React devtools are set to auto.

This picks up the change immediately.
2025-07-25 10:19:09 -04:00
Sebastian Markbåge
142fd27bf6 [DevTools] Add Option to Open Local Files directly in External Editor (#33983)
The `useOpenResource` hook is now used to open links. Currently, the
`<>` icon for the component stacks and the link in the bottom of the
components stack. But it'll also be used for many new links like stacks.
If this new option is configured, and this is a local file then this is
opened directly in the external editor. Otherwise it fallbacks to open
in the Sources tab or whatever the standalone or inline is configured to
use.

<img width="453" height="252" alt="Screenshot 2025-07-24 at 4 09 09 PM"
src="https://github.com/user-attachments/assets/04cae170-dd30-4485-a9ee-e8fe1612978e"
/>

I prominently surface this option in the Source pane to make it
discoverable.

<img width="588" height="144" alt="Screenshot 2025-07-24 at 4 03 48 PM"
src="https://github.com/user-attachments/assets/0f3a7da9-2fae-4b5b-90ec-769c5a9c5361"
/>

When this is configured, the "Open in Editor" is hidden since that's
just the default. I plan on deprecating this button to avoid having the
two buttons going forward.

Notably there's one exception where this doesn't work. When you click an
Action or Event listener it takes you to the Sources tab and you have to
open in editor from there. That's because we use the `inspect()`
mechanism instead of extracting the source location. That's because we
can't do the "throw trick" since these can have side-effects. The Chrome
debugger protocol would solve this but it pops up an annoying dialog. We
could maybe only attach the debugger only for that case. Especially if
the dialog disappears before you focus on the browser again.
2025-07-25 10:16:43 -04:00
Sebastian "Sebbie" Silbermann
7ca2d4cd2e Work around Chrome DevTools crash on performance.measure (#33997) 2025-07-25 12:32:30 +02:00
Sebastian Markbåge
99be14c883 [Flight] Promote enableAsyncDebugInfo to stable without enableComponentPerformanceTrack (#33996)
There's a lot of overlap between `enableComponentPerformanceTrack` and
`enableAsyncDebugInfo` because they both rely on timing information. The
former is mainly emit timestamps for how long server components and
awaits took. The latter how long I/O took.

`enableAsyncDebugInfo` is currently primarily for the component
performance track but its meta data is useful for other debug tools too.
This promotes that flag to stable.

However, `enableComponentPerformanceTrack` needs more work due to
performance concerns with Chrome DevTools so I need to separate them.
This keeps doing most of the timing tracking on the server but doesn't
emit the per-server component time stamps when
`enableComponentPerformanceTrack` is false.
2025-07-25 04:59:46 -04:00
Josh Story
5a04619f60 [Flight] Properly close stream when no chunks need to be written after prerender (#33982)
There is an edge case when prerendering where if you have nothing to
write you can end up in a state where the prerender is in status closed
before you can provide a destination. In this case the destination is
never closed becuase it assumes it already would have been.

This condition can happen now because of the introduction of the deubg
stream. Before this a request would never entere closed status if there
was no active destination. When a destination was added it would perform
a flush and possibly close the stream. Now, it is possible to flush
without a destination because you might have debug chunks to stream and
you can end up closing the stream independent of an active destination.

There are a number of ways we can solve this but the one that seems to
adhere best to the original design is to only set the status to CLOSED
when a destination is active. This means that if you don't have an
active destination when the pendingChunks count hits zero it will not
enter CLOSED status until you startFlowing.
2025-07-24 19:38:31 -07:00
67 changed files with 1344 additions and 385 deletions

View File

@@ -1211,6 +1211,8 @@ addObject(BUILTIN_SHAPES, BuiltInRefValueId, [
['*', {kind: 'Object', shapeId: BuiltInRefValueId}],
]);
addObject(BUILTIN_SHAPES, ReanimatedSharedValueId, []);
addFunction(
BUILTIN_SHAPES,
[],

View File

@@ -943,7 +943,10 @@ export function printAliasingEffect(effect: AliasingEffect): string {
return `Assign ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`;
}
case 'Alias': {
return `Alias ${printPlaceForAliasEffect(effect.into)} = ${printPlaceForAliasEffect(effect.from)}`;
return `Alias ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;
}
case 'MaybeAlias': {
return `MaybeAlias ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;
}
case 'Capture': {
return `Capture ${printPlaceForAliasEffect(effect.into)} <- ${printPlaceForAliasEffect(effect.from)}`;

View File

@@ -90,6 +90,23 @@ export type AliasingEffect =
* c could be mutating a.
*/
| {kind: 'Alias'; from: Place; into: Place}
/**
* Indicates the potential for information flow from `from` to `into`. This is used for a specific
* case: functions with unknown signatures. If the compiler sees a call such as `foo(x)`, it has to
* consider several possibilities (which may depend on the arguments):
* - foo(x) returns a new mutable value that does not capture any information from x.
* - foo(x) returns a new mutable value that *does* capture information from x.
* - foo(x) returns x itself, ie foo is the identity function
*
* The same is true of functions that take multiple arguments: `cond(a, b, c)` could conditionally
* return b or c depending on the value of a.
*
* To represent this case, MaybeAlias represents the fact that an aliasing relationship could exist.
* Any mutations that flow through this relationship automatically become conditional.
*/
| {kind: 'MaybeAlias'; from: Place; into: Place}
/**
* Records direct assignment: `into = from`.
*/
@@ -183,7 +200,8 @@ export function hashEffect(effect: AliasingEffect): string {
case 'ImmutableCapture':
case 'Assign':
case 'Alias':
case 'Capture': {
case 'Capture':
case 'MaybeAlias': {
return [
effect.kind,
effect.from.identifier.id,

View File

@@ -85,7 +85,8 @@ function lowerWithMutationAliasing(fn: HIRFunction): void {
case 'Assign':
case 'Alias':
case 'Capture':
case 'CreateFrom': {
case 'CreateFrom':
case 'MaybeAlias': {
capturedOrMutated.add(effect.from.identifier.id);
break;
}

View File

@@ -691,6 +691,7 @@ function applyEffect(
}
break;
}
case 'MaybeAlias':
case 'Alias':
case 'Capture': {
CompilerError.invariant(
@@ -955,7 +956,7 @@ function applyEffect(
context,
state,
// OK: recording information flow
{kind: 'Alias', from: operand, into: effect.into},
{kind: 'MaybeAlias', from: operand, into: effect.into},
initialized,
effects,
);
@@ -1323,7 +1324,7 @@ class InferenceState {
return 'mutate-global';
}
case ValueKind.MaybeFrozen: {
return 'none';
return 'mutate-frozen';
}
default: {
assertExhaustive(kind, `Unexpected kind ${kind}`);
@@ -2376,6 +2377,7 @@ function computeEffectsForSignature(
// Apply substitutions
for (const effect of signature.effects) {
switch (effect.kind) {
case 'MaybeAlias':
case 'Assign':
case 'ImmutableCapture':
case 'Alias':

View File

@@ -160,6 +160,8 @@ export function inferMutationAliasingRanges(
state.assign(index++, effect.from, effect.into);
} else if (effect.kind === 'Alias') {
state.assign(index++, effect.from, effect.into);
} else if (effect.kind === 'MaybeAlias') {
state.maybeAlias(index++, effect.from, effect.into);
} else if (effect.kind === 'Capture') {
state.capture(index++, effect.from, effect.into);
} else if (
@@ -247,6 +249,7 @@ export function inferMutationAliasingRanges(
}
for (const param of [...fn.context, ...fn.params]) {
const place = param.kind === 'Identifier' ? param : param.place;
const node = state.nodes.get(place.identifier);
if (node == null) {
continue;
@@ -346,7 +349,8 @@ export function inferMutationAliasingRanges(
case 'Assign':
case 'Alias':
case 'Capture':
case 'CreateFrom': {
case 'CreateFrom':
case 'MaybeAlias': {
const isMutatedOrReassigned =
effect.into.identifier.mutableRange.end > instr.id;
if (isMutatedOrReassigned) {
@@ -567,7 +571,12 @@ type Node = {
createdFrom: Map<Identifier, number>;
captures: Map<Identifier, number>;
aliases: Map<Identifier, number>;
edges: Array<{index: number; node: Identifier; kind: 'capture' | 'alias'}>;
maybeAliases: Map<Identifier, number>;
edges: Array<{
index: number;
node: Identifier;
kind: 'capture' | 'alias' | 'maybeAlias';
}>;
transitive: {kind: MutationKind; loc: SourceLocation} | null;
local: {kind: MutationKind; loc: SourceLocation} | null;
lastMutated: number;
@@ -585,6 +594,7 @@ class AliasingState {
createdFrom: new Map(),
captures: new Map(),
aliases: new Map(),
maybeAliases: new Map(),
edges: [],
transitive: null,
local: null,
@@ -630,6 +640,18 @@ class AliasingState {
}
}
maybeAlias(index: number, from: Place, into: Place): void {
const fromNode = this.nodes.get(from.identifier);
const toNode = this.nodes.get(into.identifier);
if (fromNode == null || toNode == null) {
return;
}
fromNode.edges.push({index, node: into.identifier, kind: 'maybeAlias'});
if (!toNode.maybeAliases.has(from.identifier)) {
toNode.maybeAliases.set(from.identifier, index);
}
}
render(index: number, start: Identifier, errors: CompilerError): void {
const seen = new Set<Identifier>();
const queue: Array<Identifier> = [start];
@@ -673,22 +695,24 @@ class AliasingState {
// Null is used for simulated mutations
end: InstructionId | null,
transitive: boolean,
kind: MutationKind,
startKind: MutationKind,
loc: SourceLocation,
errors: CompilerError,
): void {
const seen = new Set<Identifier>();
const seen = new Map<Identifier, MutationKind>();
const queue: Array<{
place: Identifier;
transitive: boolean;
direction: 'backwards' | 'forwards';
}> = [{place: start, transitive, direction: 'backwards'}];
kind: MutationKind;
}> = [{place: start, transitive, direction: 'backwards', kind: startKind}];
while (queue.length !== 0) {
const {place: current, transitive, direction} = queue.pop()!;
if (seen.has(current)) {
const {place: current, transitive, direction, kind} = queue.pop()!;
const previousKind = seen.get(current);
if (previousKind != null && previousKind >= kind) {
continue;
}
seen.add(current);
seen.set(current, kind);
const node = this.nodes.get(current);
if (node == null) {
continue;
@@ -724,13 +748,18 @@ class AliasingState {
if (edge.index >= index) {
break;
}
queue.push({place: edge.node, transitive, direction: 'forwards'});
queue.push({place: edge.node, transitive, direction: 'forwards', kind});
}
for (const [alias, when] of node.createdFrom) {
if (when >= index) {
continue;
}
queue.push({place: alias, transitive: true, direction: 'backwards'});
queue.push({
place: alias,
transitive: true,
direction: 'backwards',
kind,
});
}
if (direction === 'backwards' || node.value.kind !== 'Phi') {
/**
@@ -747,7 +776,25 @@ class AliasingState {
if (when >= index) {
continue;
}
queue.push({place: alias, transitive, direction: 'backwards'});
queue.push({place: alias, transitive, direction: 'backwards', kind});
}
/**
* MaybeAlias indicates potential data flow from unknown function calls,
* so we downgrade mutations through these aliases to consider them
* conditional. This means we'll consider them for mutation *range*
* purposes but not report validation errors for mutations, since
* we aren't sure that the `from` value could actually be aliased.
*/
for (const [alias, when] of node.maybeAliases) {
if (when >= index) {
continue;
}
queue.push({
place: alias,
transitive,
direction: 'backwards',
kind: MutationKind.Conditional,
});
}
}
/**
@@ -758,7 +805,12 @@ class AliasingState {
if (when >= index) {
continue;
}
queue.push({place: capture, transitive, direction: 'backwards'});
queue.push({
place: capture,
transitive,
direction: 'backwards',
kind,
});
}
}
}

View File

@@ -21,7 +21,6 @@ import {
isStableType,
isStableTypeContainer,
isUseOperator,
isUseRefType,
} from '../HIR';
import {PostDominator} from '../HIR/Dominator';
import {
@@ -70,13 +69,6 @@ class StableSidemap {
isStable: false,
});
}
} else if (
this.env.config.enableTreatRefLikeIdentifiersAsRefs &&
isUseRefType(lvalue.identifier)
) {
this.map.set(lvalue.identifier.id, {
isStable: true,
});
}
break;
}

View File

@@ -153,6 +153,10 @@ This is somewhat the inverse of `Capture`. The `CreateFrom` effect describes tha
Describes immutable data flow from one value to another. This is not currently used for anything, but is intended to eventually power a more sophisticated escape analysis.
### MaybeAlias
Describes potential data flow that the compiler knows may occur behind a function call, but cannot be sure about. For example, `foo(x)` _may_ be the identity function and return `x`, or `cond(a, b, c)` may conditionally return `b` or `c` depending on the value of `a`, but those functions could just as easily return new mutable values and not capture any information from their arguments. MaybeAlias represents that we have to consider the potential for data flow when deciding mutable ranges, but should be conservative about reporting errors. For example, `foo(someFrozenValue).property = true` should not error since we don't know for certain that foo returns its input.
### State-Changing Effects
The following effects describe state changes to specific values, not data flow. In many cases, JavaScript semantics will involve a combination of both data-flow effects *and* state-change effects. For example, `object.property = value` has data flow (`Capture object <- value`) and mutation (`Mutate object`).
@@ -347,6 +351,17 @@ a.b = b; // capture
mutate(a); // can transitively mutate b
```
### MaybeAlias makes mutation conditional
Because we don't know for certain that the aliasing occurs, we consider the mutation conditional against the source.
```
MaybeAlias a <- b
Mutate a
=>
MutateConditional b
```
### Freeze Does Not Freeze the Value
Freeze does not freeze the value itself:

View File

@@ -14,6 +14,7 @@ import {
Identifier,
IdentifierId,
Instruction,
InstructionKind,
makePropertyLiteral,
makeType,
PropType,
@@ -194,12 +195,29 @@ function* generateInstructionTypes(
break;
}
// We intentionally do not infer types for context variables
// We intentionally do not infer types for most context variables
case 'DeclareContext':
case 'StoreContext':
case 'LoadContext': {
break;
}
case 'StoreContext': {
/**
* The caveat is StoreContext const, where we know the value is
* assigned once such that everywhere the value is accessed, it
* must have the same type from the rvalue.
*
* A concrete example where this is useful is `const ref = useRef()`
* where the ref is referenced before its declaration in a function
* expression, causing it to be converted to a const context variable.
*/
if (value.lvalue.kind === InstructionKind.Const) {
yield equation(
value.lvalue.place.identifier.type,
value.value.identifier.type,
);
}
break;
}
case 'StoreLocal': {
if (env.config.enableUseTypeAnnotations) {
@@ -448,7 +466,36 @@ function* generateInstructionTypes(
yield equation(left, returnType);
break;
}
case 'PropertyStore':
case 'PropertyStore': {
/**
* Infer types based on assignments to known object properties
* This is important for refs, where assignment to `<maybeRef>.current`
* can help us infer that an object itself is a ref
*/
yield equation(
/**
* Our property type declarations are best-effort and we haven't tested
* using them to drive inference of rvalues from lvalues. We want to emit
* a Property type in order to infer refs from `.current` accesses, but
* stay conservative by not otherwise inferring anything about rvalues.
* So we use a dummy type here.
*
* TODO: consider using the rvalue type here
*/
makeType(),
// unify() only handles properties in the second position
{
kind: 'Property',
objectType: value.object.identifier.type,
objectName: getName(names, value.object.identifier.id),
propertyName: {
kind: 'literal',
value: value.property,
},
},
);
break;
}
case 'DeclareLocal':
case 'RegExpLiteral':
case 'MetaProperty':

View File

@@ -97,16 +97,21 @@ export function validateNoSetStateInEffects(
errors.pushDiagnostic(
CompilerDiagnostic.create({
category:
'Calling setState within an effect can trigger cascading renders',
'Calling setState synchronously within an effect can trigger cascading renders',
description:
'Calling setState directly within a useEffect causes cascading renders that can hurt performance, and is not recommended. Consider alternatives to useEffect. (https://react.dev/learn/you-might-not-need-an-effect)',
'Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. ' +
'In general, the body of an effect should do one or both of the following:\n' +
'* Update external systems with the latest state from React.\n' +
'* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\n' +
'Calling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. ' +
'(https://react.dev/learn/you-might-not-need-an-effect)',
severity: ErrorSeverity.InvalidReact,
suggestions: null,
}).withDetail({
kind: 'error',
loc: setState.loc,
message:
'Avoid calling setState() in the top-level of an effect',
'Avoid calling setState() directly within an effect',
}),
);
}

View File

@@ -0,0 +1,34 @@
## Input
```javascript
// @flow @enableTreatRefLikeIdentifiersAsRefs @validateRefAccessDuringRender
import {makeObject_Primitives} from 'shared-runtime';
component Example() {
const fooRef = makeObject_Primitives();
fooRef.current = true;
return <Stringify foo={fooRef} />;
}
```
## Error
```
Found 1 error:
Error: Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
4 | component Example() {
5 | const fooRef = makeObject_Primitives();
> 6 | fooRef.current = true;
| ^^^^^^^^^^^^^^ Ref values (the `current` property) may not be accessed during render. (https://react.dev/reference/react/useRef)
7 |
8 | return <Stringify foo={fooRef} />;
9 | }
```

View File

@@ -0,0 +1,9 @@
// @flow @enableTreatRefLikeIdentifiersAsRefs @validateRefAccessDuringRender
import {makeObject_Primitives} from 'shared-runtime';
component Example() {
const fooRef = makeObject_Primitives();
fooRef.current = true;
return <Stringify foo={fooRef} />;
}

View File

@@ -0,0 +1,39 @@
## Input
```javascript
import {useHook} from 'shared-runtime';
function Component(props) {
const frozen = useHook();
let x;
if (props.cond) {
x = frozen;
} else {
x = {};
}
x.property = true;
}
```
## Error
```
Found 1 error:
Error: This value cannot be modified
Modifying a value returned from a hook is not allowed. Consider moving the modification into the hook where the value is constructed.
error.invalid-mutate-phi-which-could-be-frozen.ts:11:2
9 | x = {};
10 | }
> 11 | x.property = true;
| ^ value cannot be modified
12 | }
13 |
```

View File

@@ -0,0 +1,12 @@
import {useHook} from 'shared-runtime';
function Component(props) {
const frozen = useHook();
let x;
if (props.cond) {
x = frozen;
} else {
x = {};
}
x.property = true;
}

View File

@@ -0,0 +1,61 @@
## Input
```javascript
import {Stringify, useIdentity} from 'shared-runtime';
function Component() {
const data = useIdentity(
new Map([
[0, 'value0'],
[1, 'value1'],
])
);
const items = [];
// NOTE: `i` is a context variable because it's reassigned and also referenced
// within a closure, the `onClick` handler of each item
// TODO: for loops create a unique environment on each iteration, which means
// that if the iteration variable is only updated in the updater, the variable
// is effectively const within the body and the "update" acts more like
// a re-initialization than a reassignment.
// Until we model this "new environment" semantic, we allow this case to error
for (let i = MIN; i <= MAX; i += INCREMENT) {
items.push(
<Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />
);
}
return <>{items}</>;
}
const MIN = 0;
const MAX = 3;
const INCREMENT = 1;
export const FIXTURE_ENTRYPOINT = {
params: [],
fn: Component,
};
```
## Error
```
Found 1 error:
Error: This value cannot be modified
Modifying a value used previously in JSX is not allowed. Consider moving the modification before the JSX.
error.todo-for-loop-with-context-variable-iterator.ts:18:30
16 | // a re-initialization than a reassignment.
17 | // Until we model this "new environment" semantic, we allow this case to error
> 18 | for (let i = MIN; i <= MAX; i += INCREMENT) {
| ^ `i` cannot be modified
19 | items.push(
20 | <Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />
21 | );
```

View File

@@ -10,6 +10,11 @@ function Component() {
const items = [];
// NOTE: `i` is a context variable because it's reassigned and also referenced
// within a closure, the `onClick` handler of each item
// TODO: for loops create a unique environment on each iteration, which means
// that if the iteration variable is only updated in the updater, the variable
// is effectively const within the body and the "update" acts more like
// a re-initialization than a reassignment.
// Until we model this "new environment" semantic, we allow this case to error
for (let i = MIN; i <= MAX; i += INCREMENT) {
items.push(
<Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />

View File

@@ -1,89 +0,0 @@
## Input
```javascript
import {Stringify, useIdentity} from 'shared-runtime';
function Component() {
const data = useIdentity(
new Map([
[0, 'value0'],
[1, 'value1'],
])
);
const items = [];
// NOTE: `i` is a context variable because it's reassigned and also referenced
// within a closure, the `onClick` handler of each item
for (let i = MIN; i <= MAX; i += INCREMENT) {
items.push(
<Stringify key={i} onClick={() => data.get(i)} shouldInvokeFns={true} />
);
}
return <>{items}</>;
}
const MIN = 0;
const MAX = 3;
const INCREMENT = 1;
export const FIXTURE_ENTRYPOINT = {
params: [],
fn: Component,
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { Stringify, useIdentity } from "shared-runtime";
function Component() {
const $ = _c(3);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = new Map([
[0, "value0"],
[1, "value1"],
]);
$[0] = t0;
} else {
t0 = $[0];
}
const data = useIdentity(t0);
let t1;
if ($[1] !== data) {
const items = [];
for (let i = MIN; i <= MAX; i = i + INCREMENT, i) {
items.push(
<Stringify
key={i}
onClick={() => data.get(i)}
shouldInvokeFns={true}
/>,
);
}
t1 = <>{items}</>;
$[1] = data;
$[2] = t1;
} else {
t1 = $[2];
}
return t1;
}
const MIN = 0;
const MAX = 3;
const INCREMENT = 1;
export const FIXTURE_ENTRYPOINT = {
params: [],
fn: Component,
};
```
### Eval output
(kind: ok) <div>{"onClick":{"kind":"Function","result":"value0"},"shouldInvokeFns":true}</div><div>{"onClick":{"kind":"Function","result":"value1"},"shouldInvokeFns":true}</div><div>{"onClick":{"kind":"Function"},"shouldInvokeFns":true}</div><div>{"onClick":{"kind":"Function"},"shouldInvokeFns":true}</div>

View File

@@ -65,7 +65,7 @@ function _temp(s) {
## Logs
```
{"kind":"CompileError","detail":{"options":{"category":"Calling setState within an effect can trigger cascading renders","description":"Calling setState directly within a useEffect causes cascading renders that can hurt performance, and is not recommended. Consider alternatives to useEffect. (https://react.dev/learn/you-might-not-need-an-effect)","severity":"InvalidReact","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":13,"column":4,"index":265},"end":{"line":13,"column":5,"index":266},"filename":"invalid-setState-in-useEffect-transitive.ts","identifierName":"g"},"message":"Avoid calling setState() in the top-level of an effect"}]}},"fnLoc":null}
{"kind":"CompileError","detail":{"options":{"category":"Calling setState synchronously within an effect can trigger cascading renders","description":"Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect)","severity":"InvalidReact","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":13,"column":4,"index":265},"end":{"line":13,"column":5,"index":266},"filename":"invalid-setState-in-useEffect-transitive.ts","identifierName":"g"},"message":"Avoid calling setState() directly within an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":92},"end":{"line":16,"column":1,"index":293},"filename":"invalid-setState-in-useEffect-transitive.ts"},"fnName":"Component","memoSlots":2,"memoBlocks":2,"memoValues":2,"prunedMemoBlocks":0,"prunedMemoValues":0}
```

View File

@@ -45,7 +45,7 @@ function _temp(s) {
## Logs
```
{"kind":"CompileError","detail":{"options":{"category":"Calling setState within an effect can trigger cascading renders","description":"Calling setState directly within a useEffect causes cascading renders that can hurt performance, and is not recommended. Consider alternatives to useEffect. (https://react.dev/learn/you-might-not-need-an-effect)","severity":"InvalidReact","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":7,"column":4,"index":180},"end":{"line":7,"column":12,"index":188},"filename":"invalid-setState-in-useEffect.ts","identifierName":"setState"},"message":"Avoid calling setState() in the top-level of an effect"}]}},"fnLoc":null}
{"kind":"CompileError","detail":{"options":{"category":"Calling setState synchronously within an effect can trigger cascading renders","description":"Effects are intended to synchronize state between React and external systems such as manually updating the DOM, state management libraries, or other platform APIs. In general, the body of an effect should do one or both of the following:\n* Update external systems with the latest state from React.\n* Subscribe for updates from some external system, calling setState in a callback function when external state changes.\n\nCalling setState synchronously within an effect body causes cascading renders that can hurt performance, and is not recommended. (https://react.dev/learn/you-might-not-need-an-effect)","severity":"InvalidReact","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":7,"column":4,"index":180},"end":{"line":7,"column":12,"index":188},"filename":"invalid-setState-in-useEffect.ts","identifierName":"setState"},"message":"Avoid calling setState() directly within an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":92},"end":{"line":10,"column":1,"index":225},"filename":"invalid-setState-in-useEffect.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
```

View File

@@ -3,7 +3,7 @@
```javascript
// @enableCustomTypeDefinitionForReanimated
import {useAnimatedProps} from 'react-native-reanimated';
import {useAnimatedProps, useSharedValue} from 'react-native-reanimated';
function Component() {
const radius = useSharedValue(50);
@@ -39,7 +39,7 @@ export const FIXTURE_ENTRYPOINT = {
```javascript
import { c as _c } from "react/compiler-runtime"; // @enableCustomTypeDefinitionForReanimated
import { useAnimatedProps } from "react-native-reanimated";
import { useAnimatedProps, useSharedValue } from "react-native-reanimated";
function Component() {
const $ = _c(2);
const radius = useSharedValue(50);

View File

@@ -1,5 +1,5 @@
// @enableCustomTypeDefinitionForReanimated
import {useAnimatedProps} from 'react-native-reanimated';
import {useAnimatedProps, useSharedValue} from 'react-native-reanimated';
function Component() {
const radius = useSharedValue(50);

View File

@@ -47,28 +47,32 @@ function useCustomRef() {
function _temp() {}
function Foo() {
const $ = _c(3);
const $ = _c(4);
const ref = useCustomRef();
let t0;
let t1;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
if ($[0] !== ref) {
t0 = () => {
ref.current?.click();
};
t1 = [];
$[0] = t0;
$[1] = t1;
$[0] = ref;
$[1] = t0;
} else {
t0 = $[0];
t1 = $[1];
t0 = $[1];
}
let t1;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t1 = [];
$[2] = t1;
} else {
t1 = $[2];
}
useEffect(t0, t1);
let t2;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
if ($[3] === Symbol.for("react.memo_cache_sentinel")) {
t2 = <div>foo</div>;
$[2] = t2;
$[3] = t2;
} else {
t2 = $[2];
t2 = $[3];
}
return t2;
}

View File

@@ -14,7 +14,7 @@ function Foo() {
const onClick = useCallback(() => {
ref.current?.click();
}, []);
}, [ref]);
return <button onClick={onClick} />;
}
@@ -47,24 +47,26 @@ function useCustomRef() {
function _temp() {}
function Foo() {
const $ = _c(2);
const $ = _c(4);
const ref = useCustomRef();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
if ($[0] !== ref) {
t0 = () => {
ref.current?.click();
};
$[0] = t0;
$[0] = ref;
$[1] = t0;
} else {
t0 = $[0];
t0 = $[1];
}
const onClick = t0;
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
if ($[2] !== onClick) {
t1 = <button onClick={onClick} />;
$[1] = t1;
$[2] = onClick;
$[3] = t1;
} else {
t1 = $[1];
t1 = $[3];
}
return t1;
}

View File

@@ -10,7 +10,7 @@ function Foo() {
const onClick = useCallback(() => {
ref.current?.click();
}, []);
}, [ref]);
return <button onClick={onClick} />;
}

View File

@@ -14,7 +14,7 @@ function Foo() {
const onClick = useCallback(() => {
customRef.current?.click();
}, []);
}, [customRef]);
return <button onClick={onClick} />;
}
@@ -47,24 +47,26 @@ function useCustomRef() {
function _temp() {}
function Foo() {
const $ = _c(2);
const $ = _c(4);
const customRef = useCustomRef();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
if ($[0] !== customRef) {
t0 = () => {
customRef.current?.click();
};
$[0] = t0;
$[0] = customRef;
$[1] = t0;
} else {
t0 = $[0];
t0 = $[1];
}
const onClick = t0;
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
if ($[2] !== onClick) {
t1 = <button onClick={onClick} />;
$[1] = t1;
$[2] = onClick;
$[3] = t1;
} else {
t1 = $[1];
t1 = $[3];
}
return t1;
}

View File

@@ -10,7 +10,7 @@ function Foo() {
const onClick = useCallback(() => {
customRef.current?.click();
}, []);
}, [customRef]);
return <button onClick={onClick} />;
}

View File

@@ -0,0 +1,114 @@
## Input
```javascript
// @flow
component Example() {
const fooRef = useRef();
function updateStyles() {
const foo = fooRef.current;
// The access of `barRef` here before its declaration causes it be hoisted...
if (barRef.current == null || foo == null) {
return;
}
foo.style.height = '100px';
}
// ...which previously meant that we didn't infer a type...
const barRef = useRef(null);
const resizeRef = useResizeObserver(
rect => {
const {width} = rect;
// ...which meant that we failed to ignore the mutation here...
barRef.current = width;
} // ...which caused this to fail with "can't freeze a mutable function"
);
useLayoutEffect(() => {
const observer = new ResizeObserver(_ => {
updateStyles();
});
return () => {
observer.disconnect();
};
}, []);
return <div ref={resizeRef} />;
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
function Example() {
const $ = _c(6);
const fooRef = useRef();
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = function updateStyles() {
const foo = fooRef.current;
if (barRef.current == null || foo == null) {
return;
}
foo.style.height = "100px";
};
$[0] = t0;
} else {
t0 = $[0];
}
const updateStyles = t0;
const barRef = useRef(null);
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = (rect) => {
const { width } = rect;
barRef.current = width;
};
$[1] = t1;
} else {
t1 = $[1];
}
const resizeRef = useResizeObserver(t1);
let t2;
let t3;
if ($[2] === Symbol.for("react.memo_cache_sentinel")) {
t2 = () => {
const observer = new ResizeObserver((_) => {
updateStyles();
});
return () => {
observer.disconnect();
};
};
t3 = [];
$[2] = t2;
$[3] = t3;
} else {
t2 = $[2];
t3 = $[3];
}
useLayoutEffect(t2, t3);
let t4;
if ($[4] !== resizeRef) {
t4 = <div ref={resizeRef} />;
$[4] = resizeRef;
$[5] = t4;
} else {
t4 = $[5];
}
return t4;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,36 @@
// @flow
component Example() {
const fooRef = useRef();
function updateStyles() {
const foo = fooRef.current;
// The access of `barRef` here before its declaration causes it be hoisted...
if (barRef.current == null || foo == null) {
return;
}
foo.style.height = '100px';
}
// ...which previously meant that we didn't infer a type...
const barRef = useRef(null);
const resizeRef = useResizeObserver(
rect => {
const {width} = rect;
// ...which meant that we failed to ignore the mutation here...
barRef.current = width;
} // ...which caused this to fail with "can't freeze a mutable function"
);
useLayoutEffect(() => {
const observer = new ResizeObserver(_ => {
updateStyles();
});
return () => {
observer.disconnect();
};
}, []);
return <div ref={resizeRef} />;
}

View File

@@ -0,0 +1,77 @@
## Input
```javascript
import {identity, makeObject_Primitives, Stringify} from 'shared-runtime';
function Example(props) {
const object = props.object;
const f = () => {
// The argument maybe-aliases into the return
const obj = identity(object);
obj.property = props.value;
return obj;
};
const obj = f();
return <Stringify obj={obj} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{object: makeObject_Primitives(), value: 42}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, makeObject_Primitives, Stringify } from "shared-runtime";
function Example(props) {
const $ = _c(7);
const object = props.object;
let t0;
if ($[0] !== object || $[1] !== props.value) {
t0 = () => {
const obj = identity(object);
obj.property = props.value;
return obj;
};
$[0] = object;
$[1] = props.value;
$[2] = t0;
} else {
t0 = $[2];
}
const f = t0;
let t1;
if ($[3] !== f) {
t1 = f();
$[3] = f;
$[4] = t1;
} else {
t1 = $[4];
}
const obj_0 = t1;
let t2;
if ($[5] !== obj_0) {
t2 = <Stringify obj={obj_0} />;
$[5] = obj_0;
$[6] = t2;
} else {
t2 = $[6];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{ object: makeObject_Primitives(), value: 42 }],
};
```
### Eval output
(kind: ok) <div>{"obj":{"a":0,"b":"value1","c":true,"property":42}}</div>

View File

@@ -0,0 +1,18 @@
import {identity, makeObject_Primitives, Stringify} from 'shared-runtime';
function Example(props) {
const object = props.object;
const f = () => {
// The argument maybe-aliases into the return
const obj = identity(object);
obj.property = props.value;
return obj;
};
const obj = f();
return <Stringify obj={obj} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{object: makeObject_Primitives(), value: 42}],
};

View File

@@ -0,0 +1,77 @@
## Input
```javascript
import {makeObject_Primitives, Stringify} from 'shared-runtime';
function Example(props) {
const object = props.object;
const f = () => {
// The receiver maybe-aliases into the return
const obj = object.makeObject();
obj.property = props.value;
return obj;
};
const obj = f();
return <Stringify obj={obj} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{object: {makeObject: makeObject_Primitives}, value: 42}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { makeObject_Primitives, Stringify } from "shared-runtime";
function Example(props) {
const $ = _c(7);
const object = props.object;
let t0;
if ($[0] !== object || $[1] !== props.value) {
t0 = () => {
const obj = object.makeObject();
obj.property = props.value;
return obj;
};
$[0] = object;
$[1] = props.value;
$[2] = t0;
} else {
t0 = $[2];
}
const f = t0;
let t1;
if ($[3] !== f) {
t1 = f();
$[3] = f;
$[4] = t1;
} else {
t1 = $[4];
}
const obj_0 = t1;
let t2;
if ($[5] !== obj_0) {
t2 = <Stringify obj={obj_0} />;
$[5] = obj_0;
$[6] = t2;
} else {
t2 = $[6];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{ object: { makeObject: makeObject_Primitives }, value: 42 }],
};
```
### Eval output
(kind: ok) <div>{"obj":{"a":0,"b":"value1","c":true,"property":42}}</div>

View File

@@ -0,0 +1,18 @@
import {makeObject_Primitives, Stringify} from 'shared-runtime';
function Example(props) {
const object = props.object;
const f = () => {
// The receiver maybe-aliases into the return
const obj = object.makeObject();
obj.property = props.value;
return obj;
};
const obj = f();
return <Stringify obj={obj} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{object: {makeObject: makeObject_Primitives}, value: 42}],
};

View File

@@ -0,0 +1,57 @@
## Input
```javascript
import {makeObject_Primitives, Stringify} from 'shared-runtime';
function Example(props) {
const obj = props.object.makeObject();
obj.property = props.value;
return <Stringify obj={obj} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{object: {makeObject: makeObject_Primitives}, value: 42}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { makeObject_Primitives, Stringify } from "shared-runtime";
function Example(props) {
const $ = _c(5);
let obj;
if ($[0] !== props.object || $[1] !== props.value) {
obj = props.object.makeObject();
obj.property = props.value;
$[0] = props.object;
$[1] = props.value;
$[2] = obj;
} else {
obj = $[2];
}
let t0;
if ($[3] !== obj) {
t0 = <Stringify obj={obj} />;
$[3] = obj;
$[4] = t0;
} else {
t0 = $[4];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{ object: { makeObject: makeObject_Primitives }, value: 42 }],
};
```
### Eval output
(kind: ok) <div>{"obj":{"a":0,"b":"value1","c":true,"property":42}}</div>

View File

@@ -0,0 +1,12 @@
import {makeObject_Primitives, Stringify} from 'shared-runtime';
function Example(props) {
const obj = props.object.makeObject();
obj.property = props.value;
return <Stringify obj={obj} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{object: {makeObject: makeObject_Primitives}, value: 42}],
};

View File

@@ -3647,7 +3647,7 @@ function initializeIOInfo(response: Response, ioInfo: ReactIOInfo): void {
// $FlowFixMe[cannot-write]
ioInfo.end += response._timeOrigin;
if (response._replayConsole) {
if (enableComponentPerformanceTrack && response._replayConsole) {
const env = response._rootEnvironmentName;
const promise = ioInfo.value;
if (promise) {
@@ -4149,7 +4149,10 @@ function processFullStringRow(
return;
}
case 78 /* "N" */: {
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
// Track the time origin for future debug info. We track it relative
// to the current environment's time space.
const timeOrigin: number = +row;
@@ -4169,11 +4172,7 @@ function processFullStringRow(
// Fallthrough to share the error with Console entries.
}
case 74 /* "J" */: {
if (
enableProfilerTimer &&
enableComponentPerformanceTrack &&
enableAsyncDebugInfo
) {
if (enableProfilerTimer && enableAsyncDebugInfo) {
resolveIOInfo(response, id, row);
return;
}

View File

@@ -110,7 +110,7 @@ export function logComponentRender(
}
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
performance.measure.bind(performance, '\u200b' + entryName, {
start: startTime < 0 ? 0 : startTime,
end: childrenEndTime,
detail: {
@@ -125,7 +125,7 @@ export function logComponentRender(
);
} else {
console.timeStamp(
entryName,
'\u200b' + entryName,
startTime < 0 ? 0 : startTime,
childrenEndTime,
trackNames[trackIdx],
@@ -163,7 +163,7 @@ export function logComponentAborted(
if (componentInfo.props != null) {
addObjectToProperties(componentInfo.props, properties, 0, '');
}
performance.measure(entryName, {
performance.measure('\u200b' + entryName, {
start: startTime < 0 ? 0 : startTime,
end: childrenEndTime,
detail: {
@@ -220,7 +220,7 @@ export function logComponentErrored(
if (componentInfo.props != null) {
addObjectToProperties(componentInfo.props, properties, 0, '');
}
performance.measure(entryName, {
performance.measure('\u200b' + entryName, {
start: startTime < 0 ? 0 : startTime,
end: childrenEndTime,
detail: {
@@ -614,7 +614,7 @@ export function logIOInfoErrored(
getIOLongName(ioInfo, description, ioInfo.env, rootEnv) + ' Rejected';
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
performance.measure.bind(performance, '\u200b' + entryName, {
start: startTime < 0 ? 0 : startTime,
end: endTime,
detail: {
@@ -667,7 +667,7 @@ export function logIOInfo(
);
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, entryName, {
performance.measure.bind(performance, '\u200b' + entryName, {
start: startTime < 0 ? 0 : startTime,
end: endTime,
detail: {

View File

@@ -2898,7 +2898,7 @@ describe('ReactFlight', () => {
);
});
// @gate enableAsyncIterableChildren
// @gate enableAsyncIterableChildren && enableComponentPerformanceTrack
it('preserves debug info for server-to-server pass through of async iterables', async () => {
let resolve;
const iteratorPromise = new Promise(r => (resolve = r));
@@ -3727,7 +3727,7 @@ describe('ReactFlight', () => {
expect(caughtError.digest).toBe('digest("my-error")');
});
// @gate __DEV__ && enableComponentPerformanceTrack
// @gate __DEV__ && enableComponentPerformanceTrack
it('can render deep but cut off JSX in debug info', async () => {
function createDeepJSX(n) {
if (n <= 0) {

View File

@@ -26,7 +26,7 @@ import {
import {localStorageSetItem} from 'react-devtools-shared/src/storage';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
export type StatusTypes = 'server-connected' | 'devtools-connected' | 'error';
export type StatusListener = (message: string, status: StatusTypes) => void;
@@ -144,8 +144,8 @@ async function fetchFileWithCaching(url: string) {
}
function canViewElementSourceFunction(
_source: ReactFunctionLocation,
symbolicatedSource: ReactFunctionLocation | null,
_source: ReactFunctionLocation | ReactCallSite,
symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
): boolean {
if (symbolicatedSource == null) {
return false;
@@ -156,8 +156,8 @@ function canViewElementSourceFunction(
}
function viewElementSourceFunction(
_source: ReactFunctionLocation,
symbolicatedSource: ReactFunctionLocation | null,
_source: ReactFunctionLocation | ReactCallSite,
symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
): void {
if (symbolicatedSource == null) {
return;

View File

@@ -18,7 +18,12 @@ import {
LOCAL_STORAGE_TRACE_UPDATES_ENABLED_KEY,
} from 'react-devtools-shared/src/constants';
import {logEvent} from 'react-devtools-shared/src/Logger';
import {normalizeUrlIfValid} from 'react-devtools-shared/src/utils';
import {
getAlwaysOpenInEditor,
getOpenInEditorURL,
normalizeUrlIfValid,
} from 'react-devtools-shared/src/utils';
import {checkConditions} from 'react-devtools-shared/src/devtools/views/Editor/utils';
import {
setBrowserSelectionFromReact,
@@ -326,7 +331,7 @@ function createSourcesEditorPanel() {
editorPane = createdPane;
createdPane.setPage('panel.html');
createdPane.setHeight('42px');
createdPane.setHeight('75px');
createdPane.onShown.addListener(portal => {
editorPortalContainer = portal.container;
@@ -530,3 +535,45 @@ if (__IS_FIREFOX__) {
connectExtensionPort();
mountReactDevToolsWhenReactHasLoaded();
function onThemeChanged(themeName) {
// Rerender with the new theme
render();
}
if (chrome.devtools.panels.setThemeChangeHandler) {
// Chrome
chrome.devtools.panels.setThemeChangeHandler(onThemeChanged);
} else if (chrome.devtools.panels.onThemeChanged) {
// Firefox
chrome.devtools.panels.onThemeChanged.addListener(onThemeChanged);
}
// Firefox doesn't support resources handlers yet.
if (chrome.devtools.panels.setOpenResourceHandler) {
chrome.devtools.panels.setOpenResourceHandler(
(
resource,
lineNumber = 1,
// The column is a new feature so we have to specify a default if it doesn't exist
columnNumber = 1,
) => {
const alwaysOpenInEditor = getAlwaysOpenInEditor();
const editorURL = getOpenInEditorURL();
if (alwaysOpenInEditor && editorURL) {
const location = ['', resource.url, lineNumber, columnNumber];
const {url, shouldDisableButton} = checkConditions(editorURL, location);
if (!shouldDisableButton) {
window.open(url);
return;
}
}
// Otherwise fallback to the built-in behavior.
chrome.devtools.panels.openResource(
resource.url,
lineNumber - 1,
columnNumber - 1,
);
},
);
}

View File

@@ -34,17 +34,26 @@ export type ReactFunctionLocation = [
number, // enclosing line number
number, // enclosing column number
];
export type ReactCallSite = [
string, // function name
string, // file name TODO: model nested eval locations as nested arrays
number, // line number
number, // column number
number, // enclosing line number
number, // enclosing column number
boolean, // async resume
];
export type ViewElementSource = (
source: ReactFunctionLocation,
symbolicatedSource: ReactFunctionLocation | null,
source: ReactFunctionLocation | ReactCallSite,
symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
) => void;
export type ViewAttributeSource = (
id: number,
path: Array<string | number>,
) => void;
export type CanViewElementSource = (
source: ReactFunctionLocation,
symbolicatedSource: ReactFunctionLocation | null,
source: ReactFunctionLocation | ReactCallSite,
symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
) => boolean;
export type InitializationOptions = {

View File

@@ -15,8 +15,7 @@ export function test(maybeInspectedElement) {
hasOwnProperty('canEditFunctionProps') &&
hasOwnProperty('canEditHooks') &&
hasOwnProperty('canToggleSuspense') &&
hasOwnProperty('canToggleError') &&
hasOwnProperty('canViewSource')
hasOwnProperty('canToggleError')
);
}

View File

@@ -4374,8 +4374,6 @@ export function attach(
(fiber.alternate !== null &&
forceFallbackForFibers.has(fiber.alternate))),
// Can view component source location.
canViewSource,
source,
// Does the component have legacy context attached to it.
@@ -4416,7 +4414,6 @@ export function attach(
function inspectVirtualInstanceRaw(
virtualInstance: VirtualInstance,
): InspectedElement | null {
const canViewSource = true;
const source = getSourceForInstance(virtualInstance);
const componentInfo = virtualInstance.data;
@@ -4470,8 +4467,6 @@ export function attach(
canToggleSuspense: supportsTogglingSuspense && hasSuspenseBoundary,
// Can view component source location.
canViewSource,
source,
// Does the component have legacy context attached to it.

View File

@@ -830,8 +830,6 @@ export function attach(
// Suspense did not exist in legacy versions
canToggleSuspense: false,
// Can view component source location.
canViewSource: type === ElementTypeClass || type === ElementTypeFunction,
source: null,
// Only legacy context exists in legacy versions.

View File

@@ -264,9 +264,6 @@ export type InspectedElement = {
// Is this Suspense, and can its value be overridden now?
canToggleSuspense: boolean,
// Can view component source location.
canViewSource: boolean,
// Does the component have legacy context attached to it.
hasLegacyContext: boolean,

View File

@@ -222,7 +222,6 @@ export function convertInspectedElementBackendToFrontend(
canToggleError,
isErrored,
canToggleSuspense,
canViewSource,
hasLegacyContext,
id,
type,
@@ -252,7 +251,6 @@ export function convertInspectedElementBackendToFrontend(
canToggleError,
isErrored,
canToggleSuspense,
canViewSource,
hasLegacyContext,
id,
key,

View File

@@ -37,6 +37,8 @@ export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL =
'React::DevTools::openInEditorUrl';
export const LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET =
'React::DevTools::openInEditorUrlPreset';
export const LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR =
'React::DevTools::alwaysOpenInEditor';
export const LOCAL_STORAGE_PARSE_HOOK_NAMES_KEY =
'React::DevTools::parseHookNames';
export const SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY =

View File

@@ -18,13 +18,14 @@ import Toggle from '../Toggle';
import {ElementTypeSuspense} from 'react-devtools-shared/src/frontend/types';
import InspectedElementView from './InspectedElementView';
import {InspectedElementContext} from './InspectedElementContext';
import {getOpenInEditorURL} from '../../../utils';
import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants';
import {getAlwaysOpenInEditor} from '../../../utils';
import {LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR} from '../../../constants';
import FetchFileWithCachingContext from './FetchFileWithCachingContext';
import {symbolicateSourceWithCache} from 'react-devtools-shared/src/symbolicateSource';
import OpenInEditorButton from './OpenInEditorButton';
import InspectedElementViewSourceButton from './InspectedElementViewSourceButton';
import Skeleton from './Skeleton';
import useEditorURL from '../useEditorURL';
import styles from './InspectedElement.css';
@@ -118,18 +119,21 @@ export default function InspectedElementWrapper(_: Props): React.Node {
inspectedElement != null &&
inspectedElement.canToggleSuspense;
const editorURL = useSyncExternalStore(
function subscribe(callback) {
window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
const alwaysOpenInEditor = useSyncExternalStore(
useCallback(function subscribe(callback) {
window.addEventListener(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, callback);
return function unsubscribe() {
window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
window.removeEventListener(
LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
callback,
);
};
},
function getState() {
return getOpenInEditorURL();
},
}, []),
getAlwaysOpenInEditor,
);
const editorURL = useEditorURL();
const toggleErrored = useCallback(() => {
if (inspectedElement == null) {
return;
@@ -217,7 +221,8 @@ export default function InspectedElementWrapper(_: Props): React.Node {
</div>
</div>
{!!editorURL &&
{!alwaysOpenInEditor &&
!!editorURL &&
inspectedElement != null &&
inspectedElement.source != null &&
symbolicatedSourcePromise != null && (
@@ -271,8 +276,7 @@ export default function InspectedElementWrapper(_: Props): React.Node {
{!hideViewSourceAction && (
<InspectedElementViewSourceButton
canViewSource={inspectedElement?.canViewSource}
source={inspectedElement?.source}
source={inspectedElement ? inspectedElement.source : null}
symbolicatedSourcePromise={symbolicatedSourcePromise}
/>
)}

View File

@@ -8,7 +8,6 @@
*/
import * as React from 'react';
import {useCallback, useContext} from 'react';
import {copy} from 'clipboard-js';
import {toNormalUrl} from 'jsc-safe-url';
@@ -17,7 +16,7 @@ import ButtonIcon from '../ButtonIcon';
import Skeleton from './Skeleton';
import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
import ViewElementSourceContext from './ViewElementSourceContext';
import useOpenResource from '../useOpenResource';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import styles from './InspectedElementSourcePanel.css';
@@ -91,24 +90,11 @@ function CopySourceButton({source, symbolicatedSourcePromise}: Props) {
function FormattedSourceString({source, symbolicatedSourcePromise}: Props) {
const symbolicatedSource = React.use(symbolicatedSourcePromise);
const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
ViewElementSourceContext,
const [linkIsEnabled, viewSource] = useOpenResource(
source,
symbolicatedSource,
);
// In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
// To detect this case, we defer to an injected helper function (if present).
const linkIsEnabled =
viewElementSourceFunction != null &&
source != null &&
(canViewElementSourceFunction == null ||
canViewElementSourceFunction(source, symbolicatedSource));
const viewSource = useCallback(() => {
if (viewElementSourceFunction != null && source != null) {
viewElementSourceFunction(source, symbolicatedSource);
}
}, [source, symbolicatedSource]);
const [, sourceURL, line] =
symbolicatedSource == null ? source : symbolicatedSource;

View File

@@ -11,79 +11,48 @@ import * as React from 'react';
import ButtonIcon from '../ButtonIcon';
import Button from '../Button';
import ViewElementSourceContext from './ViewElementSourceContext';
import Skeleton from './Skeleton';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {
CanViewElementSource,
ViewElementSource,
} from 'react-devtools-shared/src/devtools/views/DevTools';
const {useCallback, useContext} = React;
import useOpenResource from '../useOpenResource';
type Props = {
canViewSource: ?boolean,
source: ?ReactFunctionLocation,
source: null | ReactFunctionLocation,
symbolicatedSourcePromise: Promise<ReactFunctionLocation | null> | null,
};
function InspectedElementViewSourceButton({
canViewSource,
source,
symbolicatedSourcePromise,
}: Props): React.Node {
const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
ViewElementSourceContext,
);
return (
<React.Suspense fallback={<Skeleton height={16} width={24} />}>
<ActualSourceButton
canViewSource={canViewSource}
source={source}
symbolicatedSourcePromise={symbolicatedSourcePromise}
canViewElementSourceFunction={canViewElementSourceFunction}
viewElementSourceFunction={viewElementSourceFunction}
/>
</React.Suspense>
);
}
type ActualSourceButtonProps = {
canViewSource: ?boolean,
source: ?ReactFunctionLocation,
source: null | ReactFunctionLocation,
symbolicatedSourcePromise: Promise<ReactFunctionLocation | null> | null,
canViewElementSourceFunction: CanViewElementSource | null,
viewElementSourceFunction: ViewElementSource | null,
};
function ActualSourceButton({
canViewSource,
source,
symbolicatedSourcePromise,
canViewElementSourceFunction,
viewElementSourceFunction,
}: ActualSourceButtonProps): React.Node {
const symbolicatedSource =
symbolicatedSourcePromise == null
? null
: React.use(symbolicatedSourcePromise);
// In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
// To detect this case, we defer to an injected helper function (if present).
const buttonIsEnabled =
!!canViewSource &&
viewElementSourceFunction != null &&
source != null &&
(canViewElementSourceFunction == null ||
canViewElementSourceFunction(source, symbolicatedSource));
const viewSource = useCallback(() => {
if (viewElementSourceFunction != null && source != null) {
viewElementSourceFunction(source, symbolicatedSource);
}
}, [source, symbolicatedSource]);
const [buttonIsEnabled, viewSource] = useOpenResource(
source,
symbolicatedSource,
);
return (
<Button
disabled={!buttonIsEnabled}

View File

@@ -51,22 +51,22 @@ import type {FetchFileWithCaching} from './Components/FetchFileWithCachingContex
import type {HookNamesModuleLoaderFunction} from 'react-devtools-shared/src/devtools/views/Components/HookNamesModuleLoaderContext';
import type {FrontendBridge} from 'react-devtools-shared/src/bridge';
import type {BrowserTheme} from 'react-devtools-shared/src/frontend/types';
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
import type {SourceSelection} from './Editor/EditorPane';
export type TabID = 'components' | 'profiler';
export type ViewElementSource = (
source: ReactFunctionLocation,
symbolicatedSource: ReactFunctionLocation | null,
source: ReactFunctionLocation | ReactCallSite,
symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
) => void;
export type ViewAttributeSource = (
id: number,
path: Array<string | number>,
) => void;
export type CanViewElementSource = (
source: ReactFunctionLocation,
symbolicatedSource: ReactFunctionLocation | null,
source: ReactFunctionLocation | ReactCallSite,
symbolicatedSource: ReactFunctionLocation | ReactCallSite | null,
) => boolean;
export type Props = {

View File

@@ -1,12 +1,9 @@
.EditorPane {
position: relative;
display: flex;
flex-direction: row;
display: block;
background-color: var(--color-background);
color: var(--color-text);
font-family: var(--font-family-sans);
align-items: center;
padding: 0.5rem;
}
.EditorPane, .EditorPane * {
@@ -14,6 +11,19 @@
-webkit-font-smoothing: var(--font-smoothing);
}
.EditorToolbar {
display: flex;
flex-direction: row;
align-items: center;
padding: 0.5rem;
border-bottom: 1px solid var(--color-border);
}
.EditorInfo {
padding: 0.5rem;
text-align: center;
}
.VRule {
height: 20px;
width: 1px;

View File

@@ -8,7 +8,7 @@
*/
import * as React from 'react';
import {useSyncExternalStore, useState, startTransition} from 'react';
import {useState, startTransition} from 'react';
import portaledContent from '../portaledContent';
@@ -18,10 +18,10 @@ import Button from 'react-devtools-shared/src/devtools/views/Button';
import ButtonIcon from 'react-devtools-shared/src/devtools/views/ButtonIcon';
import OpenInEditorButton from './OpenInEditorButton';
import {getOpenInEditorURL} from '../../../utils';
import {LOCAL_STORAGE_OPEN_IN_EDITOR_URL} from '../../../constants';
import useEditorURL from '../useEditorURL';
import EditorSettings from './EditorSettings';
import CodeEditorByDefault from '../Settings/CodeEditorByDefault';
export type SourceSelection = {
url: string,
@@ -36,22 +36,38 @@ export type Props = {selectedSource: ?SourceSelection};
function EditorPane({selectedSource}: Props) {
const [showSettings, setShowSettings] = useState(false);
const [showLinkInfo, setShowLinkInfo] = useState(false);
const editorURL = useSyncExternalStore(
function subscribe(callback) {
window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
return function unsubscribe() {
window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
};
},
function getState() {
return getOpenInEditorURL();
},
);
const editorURL = useEditorURL();
if (showSettings) {
if (showLinkInfo) {
return (
<div className={styles.EditorPane}>
<div className={styles.EditorToolbar}>
<div style={{display: 'flex', flex: '1 1 auto'}}>
To enable link handling in your browser's DevTools settings, look
for the option Extension -> Link Handling. Select "React Developer
Tools".
</div>
<div className={styles.VRule} />
<Button
onClick={() =>
startTransition(() => {
setShowLinkInfo(false);
setShowSettings(false);
})
}>
<ButtonIcon type="close" />
</Button>
</div>
</div>
);
}
let editorToolbar;
if (showSettings) {
editorToolbar = (
<div className={styles.EditorToolbar}>
<EditorSettings />
<div className={styles.VRule} />
<Button onClick={() => startTransition(() => setShowSettings(false))}>
@@ -59,24 +75,43 @@ function EditorPane({selectedSource}: Props) {
</Button>
</div>
);
} else {
editorToolbar = (
<div className={styles.EditorToolbar}>
<OpenInEditorButton
className={styles.WideButton}
editorURL={editorURL}
source={selectedSource}
/>
<div className={styles.VRule} />
<Button
onClick={() => startTransition(() => setShowSettings(true))}
// We don't use the title here because we don't have enough space to show it.
// Once we expand this pane we can add it.
// title="Configure code editor"
>
<ButtonIcon type="settings" />
</Button>
</div>
);
}
return (
<div className={styles.EditorPane}>
<OpenInEditorButton
className={styles.WideButton}
editorURL={editorURL}
source={selectedSource}
/>
<div className={styles.VRule} />
<Button
onClick={() => startTransition(() => setShowSettings(true))}
// We don't use the title here because we don't have enough space to show it.
// Once we expand this pane we can add it.
// title="Configure code editor"
>
<ButtonIcon type="settings" />
</Button>
{editorToolbar}
<div className={styles.EditorInfo}>
{editorURL ? (
<CodeEditorByDefault
onChange={alwaysOpenInEditor => {
if (alwaysOpenInEditor) {
startTransition(() => setShowLinkInfo(true));
}
}}
/>
) : (
'Configure an external editor to open local files.'
)}
</div>
</div>
);
}

View File

@@ -7,16 +7,16 @@
* @flow
*/
import type {ReactFunctionLocation} from 'shared/ReactTypes';
import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
export function checkConditions(
editorURL: string,
source: ReactFunctionLocation,
source: ReactFunctionLocation | ReactCallSite,
): {url: URL | null, shouldDisableButton: boolean} {
try {
const url = new URL(editorURL);
const [, sourceURL, line] = source;
const [, sourceURL, line, column] = source;
let filePath;
// Check if sourceURL is a correct URL, which has a protocol specified
@@ -47,12 +47,15 @@ export function checkConditions(
}
const lineNumberAsString = String(line);
const columnNumberAsString = String(column);
url.href = url.href
.replace('{path}', filePath)
.replace('{line}', lineNumberAsString)
.replace('{column}', columnNumberAsString)
.replace('%7Bpath%7D', filePath)
.replace('%7Bline%7D', lineNumberAsString);
.replace('%7Bline%7D', lineNumberAsString)
.replace('%7Bcolumn%7D', columnNumberAsString);
return {url, shouldDisableButton: false};
} catch (e) {

View File

@@ -12,7 +12,6 @@ import type {SchedulingEvent} from 'react-devtools-timeline/src/types';
import * as React from 'react';
import Button from '../Button';
import ButtonIcon from '../ButtonIcon';
import ViewElementSourceContext from '../Components/ViewElementSourceContext';
import {useContext} from 'react';
import {TimelineContext} from 'react-devtools-timeline/src/TimelineContext';
import {
@@ -22,6 +21,7 @@ import {
import {stackToComponentLocations} from 'react-devtools-shared/src/devtools/utils';
import {copy} from 'clipboard-js';
import {withPermissionsCheck} from 'react-devtools-shared/src/frontend/utils/withPermissionsCheck';
import useOpenResource from '../useOpenResource';
import styles from './SidebarEventInfo.css';
@@ -32,9 +32,6 @@ type SchedulingEventProps = {
};
function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
ViewElementSourceContext,
);
const {componentName, timestamp} = eventInfo;
const componentStack = eventInfo.componentStack || null;
@@ -79,15 +76,10 @@ function SchedulingEventInfo({eventInfo}: SchedulingEventProps) {
// TODO: We should support symbolication here as well, but
// symbolicating the whole stack can be expensive
const canViewSource =
canViewElementSourceFunction == null ||
canViewElementSourceFunction(location, null);
const viewSource =
!canViewSource || viewElementSourceFunction == null
? () => null
: () => viewElementSourceFunction(location, null);
const [canViewSource, viewSource] = useOpenResource(
location,
null,
);
return (
<li key={index}>
<Button

View File

@@ -0,0 +1,42 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import * as React from 'react';
import {LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR} from '../../../constants';
import {useLocalStorage} from '../hooks';
import styles from './SettingsShared.css';
export default function CodeEditorByDefault({
onChange,
}: {
onChange?: boolean => void,
}): React.Node {
const [alwaysOpenInEditor, setAlwaysOpenInEditor] = useLocalStorage<boolean>(
LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
false,
);
return (
<label className={styles.SettingRow}>
<input
type="checkbox"
checked={alwaysOpenInEditor}
onChange={({currentTarget}) => {
setAlwaysOpenInEditor(currentTarget.checked);
if (onChange) {
onChange(currentTarget.checked);
}
}}
className={styles.SettingRowCheckbox}
/>
Open local files directly in your code editor
</label>
);
}

View File

@@ -13,20 +13,21 @@ import {
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
} from '../../../constants';
import {useLocalStorage} from '../hooks';
import {getDefaultOpenInEditorURL} from 'react-devtools-shared/src/utils';
import {
getDefaultPreset,
getDefaultOpenInEditorURL,
} from 'react-devtools-shared/src/utils';
import styles from './SettingsShared.css';
const vscodeFilepath = 'vscode://file/{path}:{line}';
export default function ComponentsSettings({
export default function CodeEditorOptions({
environmentNames,
}: {
environmentNames: Promise<Array<string>>,
}): React.Node {
const [openInEditorURLPreset, setOpenInEditorURLPreset] = useLocalStorage<
'vscode' | 'custom',
>(LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, 'custom');
>(LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET, getDefaultPreset());
const [openInEditorURL, setOpenInEditorURL] = useLocalStorage<string>(
LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
@@ -40,11 +41,6 @@ export default function ComponentsSettings({
onChange={({currentTarget}) => {
const selectedValue = currentTarget.value;
setOpenInEditorURLPreset(selectedValue);
if (selectedValue === 'vscode') {
setOpenInEditorURL(vscodeFilepath);
} else if (selectedValue === 'custom') {
setOpenInEditorURL('');
}
}}>
<option value="vscode">VS Code</option>
<option value="custom">Custom</option>
@@ -53,7 +49,7 @@ export default function ComponentsSettings({
<input
className={styles.Input}
type="text"
placeholder={process.env.EDITOR_URL ? process.env.EDITOR_URL : ''}
placeholder={getDefaultOpenInEditorURL()}
value={openInEditorURL}
onChange={event => {
setOpenInEditorURL(event.target.value);

View File

@@ -13,10 +13,14 @@ import {SettingsContext} from './SettingsContext';
import {StoreContext} from '../context';
import {CHANGE_LOG_URL} from 'react-devtools-shared/src/devtools/constants';
import {isInternalFacebookBuild} from 'react-devtools-feature-flags';
import CodeEditorOptions from './CodeEditorOptions';
import styles from './SettingsShared.css';
import CodeEditorOptions from './CodeEditorOptions';
import CodeEditorByDefault from './CodeEditorByDefault';
import {LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR} from '../../../constants';
import {useLocalStorage} from '../hooks';
function getChangeLogUrl(version: ?string): string | null {
if (!version) {
return null;
@@ -46,6 +50,11 @@ export default function GeneralSettings(_: {}): React.Node {
const showBackendVersion =
backendVersion && backendVersion !== frontendVersion;
const [alwaysOpenInEditor] = useLocalStorage<boolean>(
LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
false,
);
return (
<div className={styles.SettingList}>
{isInternalFacebookBuild && (
@@ -84,6 +93,29 @@ export default function GeneralSettings(_: {}): React.Node {
</label>
</div>
<div className={styles.SettingWrapper}>
<CodeEditorByDefault />
{alwaysOpenInEditor && (__IS_CHROME__ || __IS_EDGE__) ? (
<div>
To enable link handling in your browser's DevTools settings, look
for the option Extension -> Link Handling. Select "React Developer
Tools".
</div>
) : null}
</div>
<div className={styles.SettingWrapper}>
<div className={styles.RadioLabel}>Display density</div>
<select
value={displayDensity}
onChange={({currentTarget}) =>
setDisplayDensity(currentTarget.value)
}>
<option value="compact">Compact</option>
<option value="comfortable">Comfortable</option>
</select>
</div>
{supportsTraceUpdates && (
<div className={styles.SettingWrapper}>
<label className={styles.SettingRow}>

View File

@@ -13,6 +13,7 @@ import {
useLayoutEffect,
useReducer,
useState,
useSyncExternalStore,
useContext,
} from 'react';
import {
@@ -162,14 +163,24 @@ export function useLocalStorage<T>(
}
}, [initialValue, key]);
const [storedValue, setStoredValue] = useState<any>(getValueFromLocalStorage);
const storedValue = useSyncExternalStore(
useCallback(
function subscribe(callback) {
window.addEventListener(key, callback);
return function unsubscribe() {
window.removeEventListener(key, callback);
};
},
[key],
),
getValueFromLocalStorage,
);
const setValue = useCallback(
(value: $FlowFixMe) => {
try {
const valueToStore =
value instanceof Function ? (value: any)(storedValue) : value;
setStoredValue(valueToStore);
localStorageSetItem(key, JSON.stringify(valueToStore));
// Notify listeners that this setting has changed.
@@ -197,7 +208,6 @@ export function useLocalStorage<T>(
};
window.addEventListener('storage', onStorage);
return () => {
window.removeEventListener('storage', onStorage);
};

View File

@@ -0,0 +1,39 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import {useCallback, useSyncExternalStore} from 'react';
import {getOpenInEditorURL} from '../../utils';
import {
LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
} from '../../constants';
const useEditorURL = (): string => {
const editorURL = useSyncExternalStore(
useCallback(function subscribe(callback) {
window.addEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
window.addEventListener(
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
callback,
);
return function unsubscribe() {
window.removeEventListener(LOCAL_STORAGE_OPEN_IN_EDITOR_URL, callback);
window.removeEventListener(
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
callback,
);
};
}, []),
getOpenInEditorURL,
);
return editorURL;
};
export default useEditorURL;

View File

@@ -0,0 +1,75 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {ReactFunctionLocation, ReactCallSite} from 'shared/ReactTypes';
import {useCallback, useContext, useSyncExternalStore} from 'react';
import ViewElementSourceContext from './Components/ViewElementSourceContext';
import {getAlwaysOpenInEditor} from '../../utils';
import useEditorURL from './useEditorURL';
import {LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR} from '../../constants';
import {checkConditions} from './Editor/utils';
const useOpenResource = (
source: null | ReactFunctionLocation | ReactCallSite,
symbolicatedSource: null | ReactFunctionLocation | ReactCallSite,
): [
boolean, // isEnabled
() => void, // Open Resource
] => {
const {canViewElementSourceFunction, viewElementSourceFunction} = useContext(
ViewElementSourceContext,
);
const editorURL = useEditorURL();
const alwaysOpenInEditor = useSyncExternalStore(
useCallback(function subscribe(callback) {
window.addEventListener(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR, callback);
return function unsubscribe() {
window.removeEventListener(
LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
callback,
);
};
}, []),
getAlwaysOpenInEditor,
);
// First check if this link is eligible for being open directly in the configured editor.
const openInEditor =
alwaysOpenInEditor && source !== null
? checkConditions(editorURL, symbolicatedSource || source)
: null;
// In some cases (e.g. FB internal usage) the standalone shell might not be able to view the source.
// To detect this case, we defer to an injected helper function (if present).
const linkIsEnabled =
(openInEditor !== null && !openInEditor.shouldDisableButton) ||
(viewElementSourceFunction != null &&
source != null &&
(canViewElementSourceFunction == null ||
canViewElementSourceFunction(source, symbolicatedSource)));
const viewSource = useCallback(() => {
if (openInEditor !== null && !openInEditor.shouldDisableButton) {
// If we have configured to always open in the code editor, we do so if we can.
// Otherwise, we fallback to open in the local editor if possible (e.g. non-file urls).
window.open(openInEditor.url);
} else if (viewElementSourceFunction != null && source != null) {
viewElementSourceFunction(source, symbolicatedSource);
}
}, [openInEditor, source, symbolicatedSource]);
return [linkIsEnabled, viewSource];
};
export default useOpenResource;

View File

@@ -227,9 +227,6 @@ export type InspectedElement = {
// Is this Suspense, and can its value be overridden now?
canToggleSuspense: boolean,
// Can view component source location.
canViewSource: boolean,
// Does the component have legacy context attached to it.
hasLegacyContext: boolean,

View File

@@ -35,6 +35,8 @@ import {
TREE_OPERATION_UPDATE_TREE_BASE_DURATION,
LOCAL_STORAGE_COMPONENT_FILTER_PREFERENCES_KEY,
LOCAL_STORAGE_OPEN_IN_EDITOR_URL,
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR,
SESSION_STORAGE_RELOAD_AND_PROFILE_KEY,
SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY,
SESSION_STORAGE_RECORD_TIMELINE_KEY,
@@ -384,14 +386,27 @@ export function filterOutLocationComponentFilters(
return componentFilters.filter(f => f.type !== ComponentFilterLocation);
}
const vscodeFilepath = 'vscode://file/{path}:{line}:{column}';
export function getDefaultPreset(): 'custom' | 'vscode' {
return typeof process.env.EDITOR_URL === 'string' ? 'custom' : 'vscode';
}
export function getDefaultOpenInEditorURL(): string {
return typeof process.env.EDITOR_URL === 'string'
? process.env.EDITOR_URL
: '';
: vscodeFilepath;
}
export function getOpenInEditorURL(): string {
try {
const rawPreset = localStorageGetItem(
LOCAL_STORAGE_OPEN_IN_EDITOR_URL_PRESET,
);
switch (rawPreset) {
case '"vscode"':
return vscodeFilepath;
}
const raw = localStorageGetItem(LOCAL_STORAGE_OPEN_IN_EDITOR_URL);
if (raw != null) {
return JSON.parse(raw);
@@ -400,6 +415,14 @@ export function getOpenInEditorURL(): string {
return getDefaultOpenInEditorURL();
}
export function getAlwaysOpenInEditor(): boolean {
try {
const raw = localStorageGetItem(LOCAL_STORAGE_ALWAYS_OPEN_IN_EDITOR);
return raw === 'true';
} catch (error) {}
return false;
}
type ParseElementDisplayNameFromBackendReturn = {
formattedDisplayName: string | null,
hocDisplayNames: Array<string> | null,

View File

@@ -272,7 +272,7 @@ export function logComponentRender(
// $FlowFixMe[method-unbinding]
performance.measure.bind(
performance,
name,
'\u200b' + name,
reusableComponentOptions,
),
);
@@ -369,10 +369,10 @@ export function logComponentErrored(
if (__DEV__ && debugTask) {
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, name, options),
performance.measure.bind(performance, '\u200b' + name, options),
);
} else {
performance.measure(name, options);
performance.measure('\u200b' + name, options);
}
} else {
console.timeStamp(
@@ -436,10 +436,10 @@ function logComponentEffectErrored(
if (debugTask) {
debugTask.run(
// $FlowFixMe[method-unbinding]
performance.measure.bind(performance, name, options),
performance.measure.bind(performance, '\u200b' + name, options),
);
} else {
performance.measure(name, options);
performance.measure('\u200b' + name, options);
}
} else {
console.timeStamp(

View File

@@ -863,4 +863,46 @@ describe('ReactFlightDOMNode', () => {
expect(ownerStack).toBeNull();
}
});
// @gate experimental
// @gate enableHalt
it('can handle an empty prelude when prerendering', async () => {
function App() {
return null;
}
const serverAbortController = new AbortController();
serverAbortController.abort();
const errors = [];
const {pendingResult} = await serverAct(async () => {
// destructure trick to avoid the act scope from awaiting the returned value
return {
pendingResult: ReactServerDOMStaticServer.unstable_prerender(
ReactServer.createElement(App, null),
webpackMap,
{
signal: serverAbortController.signal,
onError(error) {
errors.push(error);
},
},
),
};
});
expect(errors).toEqual([]);
const {prelude} = await pendingResult;
const reader = prelude.getReader();
while (true) {
const {done} = await reader.read();
if (done) {
break;
}
}
// We don't really have an assertion other than to make sure
// the stream doesn't hang.
});
});

View File

@@ -731,7 +731,10 @@ function RequestInstance(
}
let timeOrigin: number;
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
// We start by serializing the time origin. Any future timestamps will be
// emitted relatively to this origin. Instead of using performance.timeOrigin
// as this origin, we use the timestamp at the start of the request.
@@ -978,7 +981,10 @@ function serializeThenable(
task.keyPath, // the server component sequence continues through Promise-as-a-child.
task.implicitSlot,
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? task.time
: 0,
__DEV__ ? task.debugOwner : null,
__DEV__ ? task.debugStack : null,
__DEV__ ? task.debugTask : null,
@@ -1048,7 +1054,10 @@ function serializeThenable(
},
reason => {
if (newTask.status === PENDING) {
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
// If this is async we need to time when this task finishes.
newTask.timed = true;
}
@@ -1094,7 +1103,10 @@ function serializeReadableStream(
task.keyPath,
task.implicitSlot,
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? task.time
: 0,
__DEV__ ? task.debugOwner : null,
__DEV__ ? task.debugStack : null,
__DEV__ ? task.debugTask : null,
@@ -1186,7 +1198,10 @@ function serializeAsyncIterable(
task.keyPath,
task.implicitSlot,
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? task.time
: 0,
__DEV__ ? task.debugOwner : null,
__DEV__ ? task.debugStack : null,
__DEV__ ? task.debugTask : null,
@@ -1616,7 +1631,10 @@ function renderFunctionComponent<Props>(
outlineComponentInfo(request, componentDebugInfo);
// Track when we started rendering this component.
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
advanceTaskTime(request, task, performance.now());
}
@@ -1686,12 +1704,7 @@ function renderFunctionComponent<Props>(
throw null;
}
if (
__DEV__ ||
(enableProfilerTimer &&
enableComponentPerformanceTrack &&
enableAsyncDebugInfo)
) {
if (__DEV__ || (enableProfilerTimer && enableAsyncDebugInfo)) {
// Forward any debug information for any Promises that we use():ed during the render.
// We do this at the end so that we don't keep doing this for each retry.
const trackedThenables = getTrackedThenablesAfterRendering();
@@ -2016,7 +2029,10 @@ function deferTask(request: Request, task: Task): ReactJSONValue {
task.keyPath, // unlike outlineModel this one carries along context
task.implicitSlot,
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? task.time
: 0,
__DEV__ ? task.debugOwner : null,
__DEV__ ? task.debugStack : null,
__DEV__ ? task.debugTask : null,
@@ -2033,7 +2049,10 @@ function outlineTask(request: Request, task: Task): ReactJSONValue {
task.keyPath, // unlike outlineModel this one carries along context
task.implicitSlot,
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack ? task.time : 0,
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? task.time
: 0,
__DEV__ ? task.debugOwner : null,
__DEV__ ? task.debugStack : null,
__DEV__ ? task.debugTask : null,
@@ -2482,7 +2501,10 @@ function emitAsyncSequence(
}
function pingTask(request: Request, task: Task): void {
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
// If this was async we need to emit the time when it completes.
task.timed = true;
}
@@ -2587,7 +2609,10 @@ function createTask(
| 'debugStack'
| 'debugTask',
>): any);
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
task.timed = false;
task.time = lastTimestamp;
}
@@ -2795,7 +2820,8 @@ function outlineModel(request: Request, value: ReactClientValue): number {
null, // The way we use outlining is for reusing an object.
false, // It makes no sense for that use case to be contextual.
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? performance.now() // TODO: This should really inherit the time from the task.
: 0,
null, // TODO: Currently we don't associate any debug information with
@@ -3041,7 +3067,8 @@ function serializeBlob(request: Request, blob: Blob): string {
null,
false,
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? performance.now() // TODO: This should really inherit the time from the task.
: 0,
null, // TODO: Currently we don't associate any debug information with
@@ -3177,7 +3204,8 @@ function renderModel(
task.keyPath,
task.implicitSlot,
request.abortableTasks,
enableProfilerTimer && enableComponentPerformanceTrack
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
? task.time
: 0,
__DEV__ ? task.debugOwner : null,
@@ -5130,11 +5158,7 @@ function forwardDebugInfoFromThenable(
forwardDebugInfo(request, task, debugInfo);
}
}
if (
enableProfilerTimer &&
enableComponentPerformanceTrack &&
enableAsyncDebugInfo
) {
if (enableProfilerTimer && enableAsyncDebugInfo) {
const sequence = getAsyncSequenceFromPromise(thenable);
if (sequence !== null) {
emitAsyncSequence(request, task, sequence, debugInfo, owner, stack);
@@ -5155,11 +5179,7 @@ function forwardDebugInfoFromCurrentContext(
forwardDebugInfo(request, task, debugInfo);
}
}
if (
enableProfilerTimer &&
enableComponentPerformanceTrack &&
enableAsyncDebugInfo
) {
if (enableProfilerTimer && enableAsyncDebugInfo) {
const sequence = getCurrentAsyncSequence();
if (sequence !== null) {
emitAsyncSequence(request, task, sequence, debugInfo, null, null);
@@ -5182,11 +5202,7 @@ function forwardDebugInfoFromAbortedTask(request: Request, task: Task): void {
forwardDebugInfo(request, task, debugInfo);
}
}
if (
enableProfilerTimer &&
enableComponentPerformanceTrack &&
enableAsyncDebugInfo
) {
if (enableProfilerTimer && enableAsyncDebugInfo) {
let thenable: null | Thenable<any> = null;
if (typeof model.then === 'function') {
thenable = (model: any);
@@ -5262,7 +5278,10 @@ function advanceTaskTime(
task: Task,
timestamp: number,
): void {
if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
if (
!enableProfilerTimer ||
(!enableComponentPerformanceTrack && !enableAsyncDebugInfo)
) {
return;
}
// Emits a timing chunk, if the new timestamp is higher than the previous timestamp of this task.
@@ -5278,7 +5297,10 @@ function advanceTaskTime(
}
function markOperationEndTime(request: Request, task: Task, timestamp: number) {
if (!enableProfilerTimer || !enableComponentPerformanceTrack) {
if (
!enableProfilerTimer ||
(!enableComponentPerformanceTrack && !enableAsyncDebugInfo)
) {
return;
}
// This is like advanceTaskTime() but always emits a timing chunk even if it doesn't advance.
@@ -5384,7 +5406,10 @@ function emitChunk(
}
function erroredTask(request: Request, task: Task, error: mixed): void {
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
if (task.timed) {
markOperationEndTime(request, task, performance.now());
}
@@ -5467,7 +5492,10 @@ function retryTask(request: Request, task: Task): void {
}
}
// We've finished rendering. Log the end time.
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
if (task.timed) {
markOperationEndTime(request, task, performance.now());
}
@@ -5605,7 +5633,10 @@ function finishAbortedTask(
}
forwardDebugInfoFromAbortedTask(request, task);
// Track when we aborted this task as its end time.
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
if (task.timed) {
markOperationEndTime(request, task, request.abortTime);
}
@@ -5762,6 +5793,7 @@ function flushCompletedChunks(request: Request): void {
// TODO: If this destination is not currently flowing we'll not close it when it resumes flowing.
// We should keep a separate status for this.
if (request.destination !== null) {
request.status = CLOSED;
close(request.destination);
request.destination = null;
}
@@ -5779,8 +5811,8 @@ function flushCompletedChunks(request: Request): void {
);
request.cacheController.abort(abortReason);
}
request.status = CLOSED;
if (request.destination !== null) {
request.status = CLOSED;
close(request.destination);
request.destination = null;
}
@@ -5920,7 +5952,10 @@ export function abort(request: Request, reason: mixed): void {
}
try {
request.status = ABORTING;
if (enableProfilerTimer && enableComponentPerformanceTrack) {
if (
enableProfilerTimer &&
(enableComponentPerformanceTrack || enableAsyncDebugInfo)
) {
request.abortTime = performance.now();
}
request.cacheController.abort(reason);

View File

@@ -610,7 +610,7 @@ describe('ReactFlightAsyncDebugInfo', () => {
expect(entries).toMatchInlineSnapshot(`
[
{
"name": "Component",
"name": "\u200bComponent",
},
{
"name": "await getData (…/pulls)",

View File

@@ -247,7 +247,7 @@ export const enableProfilerCommitHooks = __PROFILE__;
// Phase param passed to onRender callback differentiates between an "update" and a "cascading-update".
export const enableProfilerNestedUpdatePhase = __PROFILE__;
export const enableAsyncDebugInfo = __EXPERIMENTAL__;
export const enableAsyncDebugInfo = true;
// Track which Fiber(s) schedule render work.
export const enableUpdaterTracking = __PROFILE__;