Compare commits

..

10 Commits

Author SHA1 Message Date
Joe Savona
1895becd54 [compiler] Fix fbt for the ∞th time
We now do a single pass over the HIR, building up two data structures:
* One tracks values that are known macro tags or macro calls.
* One tracks operands of macro-related instructions so that we can later group them.

After building up these data structures, we do a pass over the latter structure. For each macro call instruction, we recursively traverse its operands to ensure they're in the same scope. Thus, something like `fbt('hello' + fbt.param(foo(), "..."))` will correctly merge the fbt call, the `+` binary expression, the `fbt.param()` call, and `foo()` into a single scope.
2025-10-15 16:12:00 -07:00
Sebastian Markbåge
903366b8b1 [DevTools] Don't select on hover (#34860)
We should only persist a selection once you click. Currently, we persist
the selection if you just hover which means you lose your selection
immediately when just starting to inspect. That's not what Chrome
Elements tab does - it selects on click.
2025-10-15 13:43:55 -04:00
Sebastian Markbåge
0fbb9b3683 [DevTools] Don't highlight on timeline (#34861)
I find it very frustrating that the highlight covers up the content that
I'm trying to review when stepping through the timeline. It also
triggered on keyboard navigation due to the focus which was annoying.

We could highlight something in the rects instead potentially.
2025-10-15 13:43:43 -04:00
Joseph Savona
e096403c59 [compiler] Infer types for properties after holes in array patterns (#34847)
In InferTypes when we infer types for properties during destructuring,
we were breaking out of the loop when we encounter a hole in the array.
Instead we should just skip that element and continue inferring later
properties.

Closes #34748

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34847).
* #34855
* __->__ #34847
2025-10-15 09:45:06 -07:00
Sebastian Markbåge
1873ad7960 [DevTools] The bridge event types should only be defined in one direction (#34859)
This revealed that a lot of the event types were defined on the wrong
end of the bridge.

It was also a problem that events with the same name couldn't have
different arguments.
2025-10-15 11:42:03 -04:00
Sebastian Markbåge
77b2f909f6 [DevTools] Attempt at a better "unique suspender" text (#34854)
Nobody knows what this terminology means.

Also, this tooltip component sucks:

<img width="634" height="137" alt="Screenshot 2025-10-15 at 12 04 49 AM"
src="https://github.com/user-attachments/assets/a1c33650-7c7d-441f-8f8b-0ea7ebea9351"
/>
2025-10-15 10:26:46 -04:00
Sebastian Markbåge
6773248311 [DevTools] Track whether a boundary is currently suspended and make transparent (#34853)
This makes the rects that are currently in a suspended state appear
ghostly so that you can see where along the timeline you are in the
rects screen.

<img width="451" height="407" alt="Screenshot 2025-10-14 at 11 43 20 PM"
src="https://github.com/user-attachments/assets/f89e362b-a0d5-46e3-8171-564909715cd1"
/>
2025-10-15 10:26:07 -04:00
Sebastian Markbåge
5747cadf44 [DevTools] Don't hide overflow rectangles (#34852)
I get the wish to click the shadow but not all child boundaries are
within the bounds of the outer Suspense boundary's node.

Sometimes they overflow naturally and if we make it overflow hidden we
hide the boundaries. Maybe it would be ok if they're actually clipped by
the real DOM but right now it covers up boundaries that should be there.

Additionally, there's also a common case where the parent boundary
shrinks when suspending the children. That then causes the suspended
child boundaries to be clipped so that you can't restore them. Maybe the
virtual boundary shouldn't shrink in this case.
2025-10-15 10:25:46 -04:00
Sebastian Markbåge
751edd6e2c [DevTools] Measure text nodes (#34851)
We can't measure Text nodes directly but we can measure a Range around
them.

This is useful since it's common, at least in examples, to use text
nodes as children of a Suspense boundary. Especially fallbacks.
2025-10-15 10:24:45 -04:00
Sebastian Markbåge
6cfc9c1ff3 [DevTools] Don't measure fallbacks when suspended (#34850)
We already do this in the update pass. That's what
`shouldMeasureSuspenseNode` does.

We also don't update measurements when we're inside an offscreen tree.

However, we didn't check if the boundary itself was in a suspended state
when in the `measureUnchangedSuspenseNodesRecursively` path.

This caused boundaries to disappear when their fallback didn't have a
rect (including their timeline entries).
2025-10-15 10:12:26 -04:00
24 changed files with 482 additions and 238 deletions

View File

@@ -7,14 +7,17 @@
import {
HIRFunction,
Identifier,
IdentifierId,
InstructionValue,
makeInstructionId,
MutableRange,
Place,
ReactiveValue,
ReactiveScope,
} from '../HIR';
import {Macro, MacroMethod} from '../HIR/Environment';
import {eachReactiveValueOperand} from './visitors';
import {eachInstructionValueOperand} from '../HIR/visitors';
import {Iterable_some} from '../Utils/utils';
/**
* This pass supports the `fbt` translation system (https://facebook.github.io/fbt/)
@@ -48,24 +51,49 @@ export function memoizeFbtAndMacroOperandsInSameScope(
...Array.from(FBT_TAGS).map((tag): Macro => [tag, []]),
...(fn.env.config.customMacros ?? []),
]);
const fbtValues: Set<IdentifierId> = new Set();
/**
* Set of all identifiers that load fbt or other macro functions or their nested
* properties, as well as values known to be the results of invoking macros
*/
const macroTagsCalls: Set<IdentifierId> = new Set();
/**
* Mapping of lvalue => list of operands for all expressions where either
* the lvalue is a known fbt/macro call and/or the operands transitively
* contain fbt/macro calls.
*
* This is the key data structure that powers the scope merging: we start
* at the lvalues and merge operands into the lvalue's scope.
*/
const macroValues: Map<Identifier, Array<Identifier>> = new Map();
// Tracks methods loaded from macros, like fbt.param or idx.foo
const macroMethods = new Map<IdentifierId, Array<Array<MacroMethod>>>();
while (true) {
let vsize = fbtValues.size;
let msize = macroMethods.size;
visit(fn, fbtMacroTags, fbtValues, macroMethods);
if (vsize === fbtValues.size && msize === macroMethods.size) {
break;
visit(fn, fbtMacroTags, macroTagsCalls, macroMethods, macroValues);
for (const root of macroValues.keys()) {
const scope = root.scope;
if (scope == null) {
continue;
}
// Merge the operands into the same scope if this is a known macro invocation
if (!macroTagsCalls.has(root.id)) {
continue;
}
mergeScopes(root, scope, macroValues, macroTagsCalls);
}
return fbtValues;
return macroTagsCalls;
}
export const FBT_TAGS: Set<string> = new Set([
'fbt',
'fbt:param',
'fbt:enum',
'fbt:plural',
'fbs',
'fbs:param',
'fbs:enum',
'fbs:plural',
]);
export const SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
'fbt:param',
@@ -75,10 +103,22 @@ export const SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
function visit(
fn: HIRFunction,
fbtMacroTags: Set<Macro>,
fbtValues: Set<IdentifierId>,
macroTagsCalls: Set<IdentifierId>,
macroMethods: Map<IdentifierId, Array<Array<MacroMethod>>>,
macroValues: Map<Identifier, Array<Identifier>>,
): void {
for (const [, block] of fn.body.blocks) {
for (const phi of block.phis) {
const macroOperands: Array<Identifier> = [];
for (const operand of phi.operands.values()) {
if (macroValues.has(operand.identifier)) {
macroOperands.push(operand.identifier);
}
}
if (macroOperands.length !== 0) {
macroValues.set(phi.place.identifier, macroOperands);
}
}
for (const instruction of block.instructions) {
const {lvalue, value} = instruction;
if (lvalue === null) {
@@ -93,13 +133,13 @@ function visit(
* We don't distinguish between tag names and strings, so record
* all `fbt` string literals in case they are used as a jsx tag.
*/
fbtValues.add(lvalue.identifier.id);
macroTagsCalls.add(lvalue.identifier.id);
} else if (
value.kind === 'LoadGlobal' &&
matchesExactTag(value.binding.name, fbtMacroTags)
) {
// Record references to `fbt` as a global
fbtValues.add(lvalue.identifier.id);
macroTagsCalls.add(lvalue.identifier.id);
} else if (
value.kind === 'LoadGlobal' &&
matchTagRoot(value.binding.name, fbtMacroTags) !== null
@@ -121,84 +161,66 @@ function visit(
if (method.length > 1) {
newMethods.push(method.slice(1));
} else {
fbtValues.add(lvalue.identifier.id);
macroTagsCalls.add(lvalue.identifier.id);
}
}
}
if (newMethods.length > 0) {
macroMethods.set(lvalue.identifier.id, newMethods);
}
} else if (isFbtCallExpression(fbtValues, value)) {
const fbtScope = lvalue.identifier.scope;
if (fbtScope === null) {
continue;
}
/*
* if the JSX element's tag was `fbt`, mark all its operands
* to ensure that they end up in the same scope as the jsx element
* itself.
*/
for (const operand of eachReactiveValueOperand(value)) {
operand.identifier.scope = fbtScope;
// Expand the jsx element's range to account for its operands
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
fbtValues.add(operand.identifier.id);
}
} else if (
isFbtJsxExpression(fbtMacroTags, fbtValues, value) ||
isFbtJsxChild(fbtValues, lvalue, value)
value.kind === 'PropertyLoad' &&
macroTagsCalls.has(value.object.identifier.id)
) {
const fbtScope = lvalue.identifier.scope;
if (fbtScope === null) {
continue;
}
/*
* if the JSX element's tag was `fbt`, mark all its operands
* to ensure that they end up in the same scope as the jsx element
* itself.
*/
for (const operand of eachReactiveValueOperand(value)) {
operand.identifier.scope = fbtScope;
// Expand the jsx element's range to account for its operands
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
/*
* NOTE: we add the operands as fbt values so that they are also
* grouped with this expression
*/
fbtValues.add(operand.identifier.id);
}
} else if (fbtValues.has(lvalue.identifier.id)) {
const fbtScope = lvalue.identifier.scope;
if (fbtScope === null) {
return;
}
for (const operand of eachReactiveValueOperand(value)) {
if (
operand.identifier.name !== null &&
operand.identifier.name.kind === 'named'
) {
/*
* named identifiers were already locals, we only have to force temporaries
* into the same scope
*/
continue;
macroTagsCalls.add(lvalue.identifier.id);
} else if (
isFbtJsxExpression(fbtMacroTags, macroTagsCalls, value) ||
isFbtJsxChild(macroTagsCalls, lvalue, value) ||
isFbtCallExpression(macroTagsCalls, value)
) {
macroTagsCalls.add(lvalue.identifier.id);
macroValues.set(
lvalue.identifier,
Array.from(
eachInstructionValueOperand(value),
operand => operand.identifier,
),
);
} else if (
Iterable_some(eachInstructionValueOperand(value), operand =>
macroValues.has(operand.identifier),
)
) {
const macroOperands: Array<Identifier> = [];
for (const operand of eachInstructionValueOperand(value)) {
if (macroValues.has(operand.identifier)) {
macroOperands.push(operand.identifier);
}
operand.identifier.scope = fbtScope;
// Expand the jsx element's range to account for its operands
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
}
macroValues.set(lvalue.identifier, macroOperands);
}
}
}
}
function mergeScopes(
root: Identifier,
scope: ReactiveScope,
macroValues: Map<Identifier, Array<Identifier>>,
macroTagsCalls: Set<IdentifierId>,
): void {
const operands = macroValues.get(root);
if (operands == null) {
return;
}
for (const operand of operands) {
operand.scope = scope;
expandFbtScopeRange(scope.range, operand.mutableRange);
macroTagsCalls.add(operand.id);
mergeScopes(operand, scope, macroValues, macroTagsCalls);
}
}
function matchesExactTag(s: string, tags: Set<Macro>): boolean {
return Array.from(tags).some(macro =>
typeof macro === 'string'
@@ -229,39 +251,40 @@ function matchTagRoot(
}
function isFbtCallExpression(
fbtValues: Set<IdentifierId>,
value: ReactiveValue,
macroTagsCalls: Set<IdentifierId>,
value: InstructionValue,
): boolean {
return (
(value.kind === 'CallExpression' &&
fbtValues.has(value.callee.identifier.id)) ||
(value.kind === 'MethodCall' && fbtValues.has(value.property.identifier.id))
macroTagsCalls.has(value.callee.identifier.id)) ||
(value.kind === 'MethodCall' &&
macroTagsCalls.has(value.property.identifier.id))
);
}
function isFbtJsxExpression(
fbtMacroTags: Set<Macro>,
fbtValues: Set<IdentifierId>,
value: ReactiveValue,
macroTagsCalls: Set<IdentifierId>,
value: InstructionValue,
): boolean {
return (
value.kind === 'JsxExpression' &&
((value.tag.kind === 'Identifier' &&
fbtValues.has(value.tag.identifier.id)) ||
macroTagsCalls.has(value.tag.identifier.id)) ||
(value.tag.kind === 'BuiltinTag' &&
matchesExactTag(value.tag.name, fbtMacroTags)))
);
}
function isFbtJsxChild(
fbtValues: Set<IdentifierId>,
macroTagsCalls: Set<IdentifierId>,
lvalue: Place | null,
value: ReactiveValue,
value: InstructionValue,
): boolean {
return (
(value.kind === 'JsxExpression' || value.kind === 'JsxFragment') &&
lvalue !== null &&
fbtValues.has(lvalue.identifier.id)
macroTagsCalls.has(lvalue.identifier.id)
);
}

View File

@@ -1,56 +0,0 @@
## Input
```javascript
import fbt from 'fbt';
import {Stringify} from 'shared-runtime';
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
* ---
* t3
* ---
*/
function Component({firstname, lastname}) {
'use memo';
return (
<Stringify>
{fbt(
[
'Name: ',
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
<Stringify key={0} name={lastname}>
{fbt('(inner fbt)', 'Inner fbt value')}
</Stringify>
),
],
'Name'
)}
</Stringify>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstname: 'first', lastname: 'last'}],
sequentialRenders: [{firstname: 'first', lastname: 'last'}],
};
```
## Error
```
Line 19 Column 11: fbt: unsupported babel node: Identifier
---
t3
---
```

View File

@@ -37,27 +37,31 @@ import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
function Foo(t0) {
const $ = _c(3);
const $ = _c(7);
const { name1, name2 } = t0;
let t1;
if ($[0] !== name1 || $[1] !== name2) {
let t2;
if ($[3] !== name1) {
t2 = <b>{name1}</b>;
$[3] = name1;
$[4] = t2;
} else {
t2 = $[4];
}
let t3;
if ($[5] !== name2) {
t3 = <b>{name2}</b>;
$[5] = name2;
$[6] = t3;
} else {
t3 = $[6];
}
t1 = fbt._(
"{user1} and {user2} accepted your PR!",
[
fbt._param(
"user1",
<span key={name1}>
<b>{name1}</b>
</span>,
),
fbt._param(
"user2",
<span key={name2}>
<b>{name2}</b>
</span>,
),
fbt._param("user1", <span key={name1}>{t2}</span>),
fbt._param("user2", <span key={name2}>{t3}</span>),
],
{ hk: "2PxMie" },
);

View File

@@ -0,0 +1,111 @@
## Input
```javascript
import fbt from 'fbt';
import {Stringify} from 'shared-runtime';
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
* ---
* t3
* ---
*/
function Component({firstname, lastname}) {
'use memo';
return (
<Stringify>
{fbt(
[
'Name: ',
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
<Stringify key={0} name={lastname}>
{fbt('(inner fbt)', 'Inner fbt value')}
</Stringify>
),
],
'Name'
)}
</Stringify>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstname: 'first', lastname: 'last'}],
sequentialRenders: [{firstname: 'first', lastname: 'last'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
import { Stringify } from "shared-runtime";
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
* ---
* t3
* ---
*/
function Component(t0) {
"use memo";
const $ = _c(5);
const { firstname, lastname } = t0;
let t1;
if ($[0] !== firstname || $[1] !== lastname) {
t1 = fbt._(
"Name: {firstname}, {lastname}",
[
fbt._param(
"firstname",
<Stringify key={0} name={firstname} />,
),
fbt._param(
"lastname",
<Stringify key={0} name={lastname}>
{fbt._("(inner fbt)", null, { hk: "36qNwF" })}
</Stringify>,
),
],
{ hk: "3AiIf8" },
);
$[0] = firstname;
$[1] = lastname;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
if ($[3] !== t1) {
t2 = <Stringify>{t1}</Stringify>;
$[3] = t1;
$[4] = t2;
} else {
t2 = $[4];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ firstname: "first", lastname: "last" }],
sequentialRenders: [{ firstname: "first", lastname: "last" }],
};
```
### Eval output
(kind: ok) <div>{"children":"Name: , "}</div>

View File

@@ -0,0 +1,78 @@
## Input
```javascript
import {fbt} from 'fbt';
import {useState} from 'react';
const MIN = 10;
function Component() {
const [count, setCount] = useState(0);
return fbt(
'Expected at least ' +
fbt.param('min', MIN, {number: true}) +
' items, but got ' +
fbt.param('count', count, {number: true}) +
' items.',
'Error description'
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { fbt } from "fbt";
import { useState } from "react";
const MIN = 10;
function Component() {
const $ = _c(2);
const [count] = useState(0);
let t0;
if ($[0] !== count) {
t0 = fbt._(
{ "*": { "*": "Expected at least {min} items, but got {count} items." } },
[
fbt._param(
"min",
MIN,
[0],
),
fbt._param(
"count",
count,
[0],
),
],
{ hk: "36gbz8" },
);
$[0] = count;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};
```
### Eval output
(kind: ok) Expected at least 10 items, but got 0 items.

View File

@@ -0,0 +1,22 @@
import {fbt} from 'fbt';
import {useState} from 'react';
const MIN = 10;
function Component() {
const [count, setCount] = useState(0);
return fbt(
'Expected at least ' +
fbt.param('min', MIN, {number: true}) +
' items, but got ' +
fbt.param('count', count, {number: true}) +
' items.',
'Error description'
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{}],
};

View File

@@ -73,7 +73,7 @@ function Component(props) {
const groupName4 = t3;
let t4;
if ($[8] !== props) {
t4 = idx.hello_world.b.c(props, _temp3);
t4 = idx.hello_world.b.c(props, (__3) => __3.group.label);
$[8] = props;
$[9] = t4;
} else {
@@ -108,9 +108,6 @@ function Component(props) {
}
return t5;
}
function _temp3(__3) {
return __3.group.label;
}
function _temp2(__0) {
return __0.group.label;
}

View File

@@ -49,7 +49,7 @@ function Component(props) {
const groupName2 = t1;
let t2;
if ($[4] !== props) {
t2 = idx.a.b(props, _temp2);
t2 = idx.a.b(props, (__1) => __1.group.label);
$[4] = props;
$[5] = t2;
} else {
@@ -74,9 +74,6 @@ function Component(props) {
}
return t3;
}
function _temp2(__1) {
return __1.group.label;
}
function _temp(_) {
return _.group.label;
}

View File

@@ -2139,8 +2139,8 @@ export function attach(
// Regular operations
pendingOperations.length +
// All suspender changes are batched in a single message.
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]]
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0),
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, isSuspended]]
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 3 : 0),
);
// Identify which renderer this update is coming from.
@@ -2225,6 +2225,14 @@ export function attach(
}
operations[i++] = fiberIdWithChanges;
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
const instance = suspense.instance;
const isSuspended =
// TODO: Track if other SuspenseNode like SuspenseList rows are suspended.
(instance.kind === FIBER_INSTANCE ||
instance.kind === FILTERED_FIBER_INSTANCE) &&
instance.data.tag === SuspenseComponent &&
instance.data.memoizedState !== null;
operations[i++] = isSuspended ? 1 : 0;
operations[i++] = suspense.environments.size;
suspense.environments.forEach((count, env) => {
operations[i++] = getStringID(env);
@@ -2251,7 +2259,10 @@ export function attach(
if (typeof instance !== 'object' || instance === null) {
return null;
}
if (typeof instance.getClientRects === 'function') {
if (
typeof instance.getClientRects === 'function' ||
instance.nodeType === 3
) {
// DOM
const doc = instance.ownerDocument;
if (instance === doc.documentElement) {
@@ -2273,7 +2284,21 @@ export function attach(
const win = doc && doc.defaultView;
const scrollX = win ? win.scrollX : 0;
const scrollY = win ? win.scrollY : 0;
const rects = instance.getClientRects();
let rects;
if (instance.nodeType === 3) {
// Text nodes cannot be measured directly but we can measure a Range.
if (typeof doc.createRange !== 'function') {
return null;
}
const range = doc.createRange();
if (typeof range.getClientRects !== 'function') {
return null;
}
range.selectNodeContents(instance);
rects = range.getClientRects();
} else {
rects = instance.getClientRects();
}
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
result.push({
@@ -2640,9 +2665,15 @@ export function attach(
const fiber = fiberInstance.data;
const props = fiber.memoizedProps;
// TODO: Compute a fallback name based on Owner, key etc.
const name = props === null ? null : props.name || null;
const name =
fiber.tag !== SuspenseComponent || props === null
? null
: props.name || null;
const nameStringID = getStringID(name);
const isSuspended =
fiber.tag === SuspenseComponent && fiber.memoizedState !== null;
if (__DEBUG__) {
console.log('recordSuspenseMount()', suspenseInstance);
}
@@ -2653,6 +2684,7 @@ export function attach(
pushOperation(fiberID);
pushOperation(parentID);
pushOperation(nameStringID);
pushOperation(isSuspended ? 1 : 0);
const rects = suspenseInstance.rects;
if (rects === null) {
@@ -3262,14 +3294,22 @@ export function attach(
// We don't update rects inside disconnected subtrees.
return;
}
const nextRects = measureInstance(suspenseNode.instance);
const prevRects = suspenseNode.rects;
if (areEqualRects(prevRects, nextRects)) {
return; // Unchanged
const instance = suspenseNode.instance;
const isSuspendedSuspenseComponent =
(instance.kind === FIBER_INSTANCE ||
instance.kind === FILTERED_FIBER_INSTANCE) &&
instance.data.tag === SuspenseComponent &&
instance.data.memoizedState !== null;
if (isSuspendedSuspenseComponent) {
// This boundary itself was suspended and we don't measure those since that would measure
// the fallback. We want to keep a ghost of the rectangle of the content not currently shown.
return;
}
// The rect has changed. While the bailed out root wasn't in a disconnected subtree,
// While this boundary wasn't suspended and the bailed out root and wasn't in a disconnected subtree,
// it's possible that this node was in one. So we need to check if we're offscreen.
let parent = suspenseNode.instance.parent;
let parent = instance.parent;
while (parent !== null) {
if (
(parent.kind === FIBER_INSTANCE ||
@@ -3285,6 +3325,13 @@ export function attach(
}
parent = parent.parent;
}
const nextRects = measureInstance(suspenseNode.instance);
const prevRects = suspenseNode.rects;
if (areEqualRects(prevRects, nextRects)) {
return; // Unchanged
}
// We changed inside a visible tree.
// Since this boundary changed, it's possible it also affected its children so lets
// measure them as well.
@@ -5006,15 +5053,24 @@ export function attach(
const nextIsSuspended = isSuspendedOffscreen(nextFiber);
if (isLegacySuspense) {
if (
fiberInstance !== null &&
fiberInstance.suspenseNode !== null &&
(prevFiber.stateNode === null) !== (nextFiber.stateNode === null)
) {
trackThrownPromisesFromRetryCache(
fiberInstance.suspenseNode,
nextFiber.stateNode,
);
if (fiberInstance !== null && fiberInstance.suspenseNode !== null) {
const suspenseNode = fiberInstance.suspenseNode;
if (
(prevFiber.stateNode === null) !==
(nextFiber.stateNode === null)
) {
trackThrownPromisesFromRetryCache(
suspenseNode,
nextFiber.stateNode,
);
}
if (
(prevFiber.memoizedState === null) !==
(nextFiber.memoizedState === null)
) {
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
}
}
}
// The logic below is inspired by the code paths in updateSuspenseComponent()
@@ -5162,6 +5218,14 @@ export function attach(
);
}
if (
(prevFiber.memoizedState === null) !==
(nextFiber.memoizedState === null)
) {
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
}
shouldMeasureSuspenseNode = false;
updateFlags |= updateSuspenseChildrenRecursively(
nextContentFiber,
@@ -5188,6 +5252,8 @@ export function attach(
}
trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode);
// Toggle suspended state.
recordSuspenseSuspenders(suspenseNode);
mountSuspenseChildrenRecursively(
nextContentFiber,

View File

@@ -417,6 +417,7 @@ export function attach(
pushOperation(id);
pushOperation(parentID);
pushOperation(getStringID(null)); // name
pushOperation(0); // isSuspended
// TODO: Measure rect of root
pushOperation(-1);
} else {

View File

@@ -187,10 +187,13 @@ export default class Overlay {
}
}
inspect(nodes: $ReadOnlyArray<HTMLElement>, name?: ?string) {
inspect(nodes: $ReadOnlyArray<HTMLElement | Text>, name?: ?string) {
// We can't get the size of text nodes or comment nodes. React as of v15
// heavily uses comment nodes to delimit text.
const elements = nodes.filter(node => node.nodeType === Node.ELEMENT_NODE);
// TODO: We actually can measure text nodes. We should.
const elements: $ReadOnlyArray<HTMLElement> = (nodes.filter(
node => node.nodeType === Node.ELEMENT_NODE,
): any);
while (this.rects.length > elements.length) {
const rect = this.rects.pop();

View File

@@ -366,8 +366,6 @@ export default function setupHighlighter(
// Don't pass the name explicitly.
// It will be inferred from DOM tag and Fiber owner.
showOverlay([target], null, agent, false);
selectElementForNode(target);
}
function onPointerUp(event: MouseEvent) {

View File

@@ -217,10 +217,15 @@ export type BackendEvents = {
selectElement: [number],
shutdown: [],
stopInspectingHost: [boolean],
syncSelectionFromBuiltinElementsPanel: [],
syncSelectionToBuiltinElementsPanel: [],
unsupportedRendererVersion: [],
extensionComponentsPanelShown: [],
extensionComponentsPanelHidden: [],
resumeElementPolling: [],
pauseElementPolling: [],
// React Native style editor plug-in.
isNativeStyleEditorSupported: [
{isSupported: boolean, validAttributes: ?$ReadOnlyArray<string>},
@@ -240,8 +245,6 @@ type FrontendEvents = {
clearWarningsForElementID: [ElementAndRendererID],
copyElementPath: [CopyElementPathParams],
deletePath: [DeletePath],
extensionComponentsPanelShown: [],
extensionComponentsPanelHidden: [],
getBackendVersion: [],
getBridgeProtocol: [],
getIfHasUnsupportedRendererVersion: [],
@@ -265,7 +268,7 @@ type FrontendEvents = {
shutdown: [],
startInspectingHost: [],
startProfiling: [StartProfilingParams],
stopInspectingHost: [boolean],
stopInspectingHost: [],
scrollToHostInstance: [ScrollToHostInstance],
stopProfiling: [],
storeAsGlobal: [StoreAsGlobalParams],
@@ -275,6 +278,8 @@ type FrontendEvents = {
viewAttributeSource: [ViewAttributeSourceParams],
viewElementSource: [ElementAndRendererID],
syncSelectionFromBuiltinElementsPanel: [],
// React Native style editor plug-in.
NativeStyleEditor_measure: [ElementAndRendererID],
NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams],
@@ -295,19 +300,13 @@ type FrontendEvents = {
overrideProps: [OverrideValue],
overrideState: [OverrideValue],
resumeElementPolling: [],
pauseElementPolling: [],
getHookSettings: [],
};
class Bridge<
OutgoingEvents: Object,
IncomingEvents: Object,
> extends EventEmitter<{
...IncomingEvents,
...OutgoingEvents,
}> {
> extends EventEmitter<IncomingEvents> {
_isShutdown: boolean = false;
_messageQueue: Array<any> = [];
_scheduledFlush: boolean = false;

View File

@@ -1552,7 +1552,8 @@ export default class Store extends EventEmitter<{
const id = operations[i + 1];
const parentID = operations[i + 2];
const nameStringID = operations[i + 3];
const numRects = ((operations[i + 4]: any): number);
const isSuspended = operations[i + 4] === 1;
const numRects = ((operations[i + 5]: any): number);
let name = stringTable[nameStringID];
if (this._idToSuspense.has(id)) {
@@ -1579,7 +1580,7 @@ export default class Store extends EventEmitter<{
}
}
i += 5;
i += 6;
let rects: SuspenseNode['rects'];
if (numRects === -1) {
rects = null;
@@ -1625,6 +1626,7 @@ export default class Store extends EventEmitter<{
name,
rects,
hasUniqueSuspenders: false,
isSuspended: isSuspended,
});
hasSuspenseTreeChanged = true;
@@ -1801,6 +1803,7 @@ export default class Store extends EventEmitter<{
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const id = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
const environmentNames = [];
for (
@@ -1832,6 +1835,7 @@ export default class Store extends EventEmitter<{
}
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
suspense.isSuspended = isSuspended;
// TODO: Recompute the environment names.
}

View File

@@ -26,7 +26,7 @@ export default function InspectHostNodesToggle(): React.Node {
logEvent({event_name: 'inspect-element-button-clicked'});
bridge.send('startInspectingHost');
} else {
bridge.send('stopInspectingHost', false);
bridge.send('stopInspectingHost');
}
},
[bridge],

View File

@@ -378,7 +378,8 @@ function updateTree(
const fiberID = operations[i + 1];
const parentID = operations[i + 2];
const nameStringID = operations[i + 3];
const numRects = operations[i + 4];
const isSuspended = operations[i + 4];
const numRects = operations[i + 5];
const name = stringTable[nameStringID];
if (__DEBUG__) {
@@ -388,16 +389,16 @@ function updateTree(
} else {
rects =
'[' +
operations.slice(i + 5, i + 5 + numRects * 4).join(',') +
operations.slice(i + 6, i + 6 + numRects * 4).join(',') +
']';
}
debug(
'Add suspense',
`node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID}`,
`node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
);
}
i += 5 + (numRects === -1 ? 0 : numRects * 4);
i += 6 + (numRects === -1 ? 0 : numRects * 4);
break;
}
@@ -459,12 +460,13 @@ function updateTree(
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const suspenseNodeId = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
i += environmentNamesLength;
if (__DEBUG__) {
debug(
'Suspender changes',
`Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
`Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
);
}
}

View File

@@ -19,11 +19,6 @@
.SuspenseRectsBoundaryChildren {
pointer-events: none;
/**
* So that the shadow of Boundaries within is clipped off.
* Otherwise it would look like this boundary is further elevated.
*/
overflow: hidden;
}
.SuspenseRectsScaledRect[data-visible='false'] > .SuspenseRectsBoundaryChildren {
@@ -49,6 +44,10 @@
outline-width: 0;
}
.SuspenseRectsScaledRect[data-suspended='true'] {
opacity: 0.3;
}
/* highlight this boundary */
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
background-color: var(--color-background-hover);

View File

@@ -35,11 +35,13 @@ function ScaledRect({
className,
rect,
visible,
suspended,
...props
}: {
className: string,
rect: Rect,
visible: boolean,
suspended: boolean,
...
}): React$Node {
const viewBox = useContext(ViewBox);
@@ -53,6 +55,7 @@ function ScaledRect({
{...props}
className={styles.SuspenseRectsScaledRect + ' ' + className}
data-visible={visible}
data-suspended={suspended}
style={{
width,
height,
@@ -145,7 +148,8 @@ function SuspenseRects({
<ScaledRect
rect={boundingBox}
className={styles.SuspenseRectsBoundary}
visible={visible}>
visible={visible}
suspended={suspense.isSuspended}>
<ViewBox.Provider value={boundingBox}>
{visible &&
suspense.rects !== null &&

View File

@@ -31,9 +31,9 @@ export default function SuspenseScrubber({
max: number,
value: number,
highlight: number,
onBlur: () => void,
onBlur?: () => void,
onChange: (index: number) => void,
onFocus: () => void,
onFocus?: () => void,
onHoverSegment: (index: number) => void,
onHoverLeave: () => void,
}): React$Node {

View File

@@ -85,7 +85,9 @@ function ToggleUniqueSuspenders() {
<Toggle
isChecked={uniqueSuspendersOnly}
onChange={handleToggleUniqueSuspenders}
title={'Only include boundaries with unique suspenders'}>
title={
'Filter Suspense which does not suspend, or if the parent also suspend on the same.'
}>
<ButtonIcon type={uniqueSuspendersOnly ? 'filter-on' : 'filter-off'} />
</Toggle>
);

View File

@@ -11,7 +11,7 @@ import * as React from 'react';
import {useContext, useEffect} from 'react';
import {BridgeContext} from '../context';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
import {useScrollToHostInstance} from '../hooks';
import {
SuspenseTreeDispatcherContext,
SuspenseTreeStateContext,
@@ -25,8 +25,6 @@ function SuspenseTimelineInput() {
const bridge = useContext(BridgeContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
const scrollToHostInstance = useScrollToHostInstance();
const {timeline, timelineIndex, hoveredTimelineIndex, playing, autoScroll} =
@@ -37,7 +35,6 @@ function SuspenseTimelineInput() {
function switchSuspenseNode(nextTimelineIndex: number) {
const nextSelectedSuspenseID = timeline[nextTimelineIndex];
highlightHostInstance(nextSelectedSuspenseID);
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -52,23 +49,14 @@ function SuspenseTimelineInput() {
switchSuspenseNode(pendingTimelineIndex);
}
function handleBlur() {
clearHighlightHostInstance();
}
function handleFocus() {
switchSuspenseNode(timelineIndex);
}
function handleHoverSegment(hoveredValue: number) {
const suspenseID = timeline[hoveredValue];
if (suspenseID === undefined) {
throw new Error(
`Suspense node not found for value ${hoveredValue} in timeline.`,
);
}
highlightHostInstance(suspenseID);
// TODO: Consider highlighting the rect instead.
}
function handleUnhoverSegment() {}
function skipPrevious() {
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
@@ -180,11 +168,10 @@ function SuspenseTimelineInput() {
max={max}
value={timelineIndex}
highlight={hoveredTimelineIndex}
onBlur={handleBlur}
onChange={handleChange}
onFocus={handleFocus}
onHoverSegment={handleHoverSegment}
onHoverLeave={clearHighlightHostInstance}
onHoverLeave={handleUnhoverSegment}
/>
</div>
</>

View File

@@ -200,6 +200,7 @@ export type SuspenseNode = {
name: string | null,
rects: null | Array<Rect>,
hasUniqueSuspenders: boolean,
isSuspended: boolean,
};
// Serialized version of ReactIOInfo

View File

@@ -340,9 +340,10 @@ export function printOperationsArray(operations: Array<number>) {
const fiberID = operations[i + 1];
const parentID = operations[i + 2];
const nameStringID = operations[i + 3];
const numRects = operations[i + 4];
const isSuspended = operations[i + 4];
const numRects = operations[i + 5];
i += 5;
i += 6;
const name = stringTable[nameStringID];
let rects: string;
@@ -368,7 +369,7 @@ export function printOperationsArray(operations: Array<number>) {
}
logs.push(
`Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID}`,
`Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
);
break;
}
@@ -431,10 +432,11 @@ export function printOperationsArray(operations: Array<number>) {
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
const id = operations[i++];
const hasUniqueSuspenders = operations[i++] === 1;
const isSuspended = operations[i++] === 1;
const environmentNamesLength = operations[i++];
i += environmentNamesLength;
logs.push(
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
);
}