Compare commits

..

10 Commits

Author SHA1 Message Date
Joe Savona
48b52d896e [compiler] Fix false positive for useMemo reassigning context vars
Within a function expresssion local variables use StoreContext, not StoreLocal, so the reassignment check here was firing too often. We should only report an error for variables that are declared outside the function, ie part of its `context`.
2025-10-17 16:54:19 -07:00
Sebastian Markbåge
3a669170e9 [DevTools] Assign a different color and label based on environment (#34893)
Stacked on #34892.

In the timeline scrubber each timeline entry gets a label and color
assigned based on the environment computed for that step.

In the rects, we find the timeline step that this boundary is part of
and use that environment to assign a color. This is slightly different
than picking from the boundary itself since it takes into account parent
boundaries.

In the "suspended by" section we color each entry individually based on
the environment that spawned the I/O.

<img width="790" height="813" alt="Screenshot 2025-10-17 at 12 18 56 AM"
src="https://github.com/user-attachments/assets/c902b1fb-0992-4e24-8e94-a97ca8507551"
/>
2025-10-17 19:03:15 -04:00
Sebastian Markbåge
a083344699 [DevTools] Compute environment names for the timeline (#34892)
Stacked on #34885.

This refactors the timeline to store not just an id but a complex object
for each step. This will later represent a group of boundaries.

Each timeline step is assigned an environment name. We pick the last
environment name (assumed to have resolved last) from the union of the
parent and child environment names. I.e. a child step is considered to
be blocked by the parent so if a child isn't blocked on any environment
name it still gets marked as the parent's environment name.

In a follow up, I'd like to reorder the document order timeline based on
environment names to favor loading everything in one environment before
the next.
2025-10-17 18:54:53 -04:00
Sebastian Markbåge
423c44b886 [DevTools] Don't highlight the root rect if no roots has unique suspenders (#34885)
Stacked on #34881.

We don't paint suspense boundaries if there are no suspenders. This does
the same with the root. The root is still selectable so you can confirm
but there's no affordance drawing attention to click the root.

This could happen if you don't use the built-ins of React to load things
like scripts and css. It would never happen in something like Next.js
where code and CSS is loaded through React-native like RSC.

However, it could also happen in the Activity scoped case when all
resources are always loaded early.
2025-10-17 18:53:30 -04:00
Sebastian Markbåge
f970d5ff32 [DevTools] Highlight the rect when the corresponding timeline bean is hovered (#34881)
Stacked on #34880.

In #34861 I removed the highlight of the real view when hovering the
timeline since it was disruptive to stepping through the visuals.

This makes it so that when we hover the timeline we highlight the rect
with the subtle hover effect added in #34880.

We can now just use the one shared state for this and don't need the CSS
psuedo-selectors.

<img width="603" height="813" alt="Screenshot 2025-10-16 at 3 11 17 PM"
src="https://github.com/user-attachments/assets/a018b5ce-dd4d-4e77-ad47-b4ea068f1976"
/>
2025-10-17 18:52:26 -04:00
Sebastian Markbåge
724e7bfb40 [DevTools] Repeat the "name" if there's no short description in groups (#34894)
It looks weird when the row is blank when there's no short description
for the entry in a group.

<img width="328" height="436" alt="Screenshot 2025-10-17 at 12 25 30 AM"
src="https://github.com/user-attachments/assets/12f5c55f-a37f-4b6d-913e-f763cec6b211"
/>
2025-10-17 18:52:07 -04:00
Sebastian Markbåge
ef88c588d5 [DevTools] Tweak the rects design and create multi-environment color scheme (#34880)
<img width="1011" height="811" alt="Screenshot 2025-10-16 at 2 20 46 PM"
src="https://github.com/user-attachments/assets/6dea3962-d369-4823-b44f-2c62b566c8f1"
/>

The selection is now clearer with a wider outline which spans the
bounding box if there are multi rects.

The color now gets darked changes on hover with a slight animation.

The colors are now mixed from constants defined which are consistently
used in the rects, the time span in the "suspended by" side bar and the
scrubber. I also have constants defined for "server" and "other" debug
environments which will be used in a follow up.
2025-10-17 18:51:02 -04:00
Hendrik Liebau
dc485c7303 [Flight] Fix detached ArrayBuffer error when streaming typed arrays (#34849)
Using `renderToReadableStream` in Node.js with binary data from
`fs.readFileSync` (or `Buffer.allocUnsafe`) could cause downstream
consumers (like compression middleware) to fail with "Cannot perform
Construct on a detached ArrayBuffer".

The issue occurs because Node.js uses an 8192-byte Buffer pool for small
allocations (< 4KB). When React's `VIEW_SIZE` was 2KB, files between
~2KB and 4KB would be passed through as views of pooled buffers rather
than copied into `currentView`. ByteStreams (`type: 'bytes'`) detach
ArrayBuffers during transfer, which corrupts the shared Buffer pool and
causes subsequent Buffer operations to fail.

Increasing `VIEW_SIZE` from 2KB to 4KB ensures all chunks smaller than
4KB are copied into `currentView` (which uses a dedicated 4KB buffer
outside the pool), while chunks 4KB or larger don't use the pool anyway.
Thus no pooled buffers are ever exposed to ByteStream detachment.

This adds 2KB memory per active stream, copies chunks in the 2-4KB range
instead of passing them as views (small CPU cost), and buffers up to 2KB
more data before flushing. However, it avoids duplicating large binary
data (which copying everything would require, like the Edge entry point
currently does in `typedArrayToBinaryChunk`).

Related issues:

- https://github.com/vercel/next.js/issues/84753
- https://github.com/vercel/next.js/issues/84858
2025-10-17 22:13:52 +02:00
Joseph Savona
c35f6a3041 [compiler] Optimize props spread for common cases (#34900)
As part of the new inference model we updated to (correctly) treat
destructuring spread as creating a new mutable object. This had the
unfortunate side-effect of reducing precision on destructuring of props,
though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually
frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for
exploring this idea!). But during review it became clear that it was a
bit more complicated than I had thought. So this PR explores a more
conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params,
and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access
properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx
then we can be very confident the object is not mutated. We consider any
such objects to be frozen, even though technically spread creates a new
object.

See new fixtures for more examples.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34900).
* __->__ #34900
* #34887
2025-10-17 11:59:17 -07:00
Joseph Savona
adbc32de32 [compiler] More fbt compatibility (#34887)
In my previous PR I fixed some cases but broke others. So, new approach.
Two phase algorithm:

* First pass is forward data flow to determine all usages of macros.
This is necessary because many of Meta's macros have variants that can
be accessed via properties, eg you can do `macro(...)` but also
`macro.variant(...)`.
* Second pass is backwards data flow to find macro invocations (JSX and
calls) and then merge their operands into the same scope as the macro
call.

Note that this required updating PromoteUsedTemporaries to avoid
promoting macro calls that have interposing instructions between their
creation and usage. Macro calls in general are pure so it should be safe
to reorder them.

In addition, we're now more precise about `<fb:plural>`, `<fbt:param>`,
`fbt.plural()` and `fbt.param()`, which don't actually require all their
arguments to be inlined. The whole point is that the plural/param value
is an arbitrary value (along with a string name). So we no longer
transitively inline the arguments, we just make sure that they don't get
inadvertently promoted to named variables.

One caveat: we actually don't do anything to treat macro functions as
non-mutating, so `fbt.plural()` and friends (function form) may still
sometimes group arguments just due to mutability inference. In a
follow-up, i'll work to infer the types of nested macro functions as
non-mutating.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34887).
* #34900
* __->__ #34887
2025-10-17 11:37:28 -07:00
29 changed files with 952 additions and 152 deletions

View File

@@ -19,6 +19,7 @@ import {
Environment,
FunctionExpression,
GeneratedSource,
getHookKind,
HIRFunction,
Hole,
IdentifierId,
@@ -198,6 +199,7 @@ export function inferMutationAliasingEffects(
isFunctionExpression,
fn,
hoistedContextDeclarations,
findNonMutatedDestructureSpreads(fn),
);
let iterationCount = 0;
@@ -287,15 +289,18 @@ class Context {
isFuctionExpression: boolean;
fn: HIRFunction;
hoistedContextDeclarations: Map<DeclarationId, Place | null>;
nonMutatingSpreads: Set<IdentifierId>;
constructor(
isFunctionExpression: boolean,
fn: HIRFunction,
hoistedContextDeclarations: Map<DeclarationId, Place | null>,
nonMutatingSpreads: Set<IdentifierId>,
) {
this.isFuctionExpression = isFunctionExpression;
this.fn = fn;
this.hoistedContextDeclarations = hoistedContextDeclarations;
this.nonMutatingSpreads = nonMutatingSpreads;
}
cacheApplySignature(
@@ -322,6 +327,161 @@ class Context {
}
}
/**
* Finds objects created via ObjectPattern spread destructuring
* (`const {x, ...spread} = ...`) where a) the rvalue is known frozen and
* b) the spread value cannot possibly be directly mutated. The idea is that
* for this set of values, we can treat the spread object as frozen.
*
* The primary use case for this is props spreading:
*
* ```
* function Component({prop, ...otherProps}) {
* const transformedProp = transform(prop, otherProps.foo);
* // pass `otherProps` down:
* return <Foo {...otherProps} prop={transformedProp} />;
* }
* ```
*
* Here we know that since `otherProps` cannot be mutated, we don't have to treat
* it as mutable: `otherProps.foo` only reads a value that must be frozen, so it
* can be treated as frozen too.
*/
function findNonMutatedDestructureSpreads(fn: HIRFunction): Set<IdentifierId> {
const knownFrozen = new Set<IdentifierId>();
if (fn.fnType === 'Component') {
const [props] = fn.params;
if (props != null && props.kind === 'Identifier') {
knownFrozen.add(props.identifier.id);
}
} else {
for (const param of fn.params) {
if (param.kind === 'Identifier') {
knownFrozen.add(param.identifier.id);
}
}
}
// Map of temporaries to identifiers for spread objects
const candidateNonMutatingSpreads = new Map<IdentifierId, IdentifierId>();
for (const block of fn.body.blocks.values()) {
if (candidateNonMutatingSpreads.size !== 0) {
for (const phi of block.phis) {
for (const operand of phi.operands.values()) {
const spread = candidateNonMutatingSpreads.get(operand.identifier.id);
if (spread != null) {
candidateNonMutatingSpreads.delete(spread);
}
}
}
}
for (const instr of block.instructions) {
const {lvalue, value} = instr;
switch (value.kind) {
case 'Destructure': {
if (
!knownFrozen.has(value.value.identifier.id) ||
!(
value.lvalue.kind === InstructionKind.Let ||
value.lvalue.kind === InstructionKind.Const
) ||
value.lvalue.pattern.kind !== 'ObjectPattern'
) {
continue;
}
for (const item of value.lvalue.pattern.properties) {
if (item.kind !== 'Spread') {
continue;
}
candidateNonMutatingSpreads.set(
item.place.identifier.id,
item.place.identifier.id,
);
}
break;
}
case 'LoadLocal': {
const spread = candidateNonMutatingSpreads.get(
value.place.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.set(lvalue.identifier.id, spread);
}
break;
}
case 'StoreLocal': {
const spread = candidateNonMutatingSpreads.get(
value.value.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.set(lvalue.identifier.id, spread);
candidateNonMutatingSpreads.set(
value.lvalue.place.identifier.id,
spread,
);
}
break;
}
case 'JsxFragment':
case 'JsxExpression': {
// Passing objects created with spread to jsx can't mutate them
break;
}
case 'PropertyLoad': {
// Properties must be frozen since the original value was frozen
break;
}
case 'CallExpression':
case 'MethodCall': {
const callee =
value.kind === 'CallExpression' ? value.callee : value.property;
if (getHookKind(fn.env, callee.identifier) != null) {
// Hook calls have frozen arguments, and non-ref returns are frozen
if (!isRefOrRefValue(lvalue.identifier)) {
knownFrozen.add(lvalue.identifier.id);
}
} else {
// Non-hook calls check their operands, since they are potentially mutable
if (candidateNonMutatingSpreads.size !== 0) {
// Otherwise any reference to the spread object itself may mutate
for (const operand of eachInstructionValueOperand(value)) {
const spread = candidateNonMutatingSpreads.get(
operand.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.delete(spread);
}
}
}
}
break;
}
default: {
if (candidateNonMutatingSpreads.size !== 0) {
// Otherwise any reference to the spread object itself may mutate
for (const operand of eachInstructionValueOperand(value)) {
const spread = candidateNonMutatingSpreads.get(
operand.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.delete(spread);
}
}
}
}
}
}
}
const nonMutatingSpreads = new Set<IdentifierId>();
for (const [key, value] of candidateNonMutatingSpreads) {
if (key === value) {
nonMutatingSpreads.add(key);
}
}
return nonMutatingSpreads;
}
function inferParam(
param: Place | SpreadPattern,
initialState: InferenceState,
@@ -2054,7 +2214,9 @@ function computeSignatureForInstruction(
kind: 'Create',
into: place,
reason: ValueReason.Other,
value: ValueKind.Mutable,
value: context.nonMutatingSpreads.has(place.identifier.id)
? ValueKind.Frozen
: ValueKind.Mutable,
});
effects.push({
kind: 'Capture',

View File

@@ -184,25 +184,28 @@ function validateNoContextVariableAssignment(
fn: HIRFunction,
errors: CompilerError,
): void {
const context = new Set(fn.context.map(place => place.identifier.id));
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const value = instr.value;
switch (value.kind) {
case 'StoreContext': {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.UseMemo,
reason:
'useMemo() callbacks may not reassign variables declared outside of the callback',
description:
'useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function',
suggestions: null,
}).withDetails({
kind: 'error',
loc: value.lvalue.place.loc,
message: 'Cannot reassign variable',
}),
);
if (context.has(value.lvalue.place.identifier.id)) {
errors.pushDiagnostic(
CompilerDiagnostic.create({
category: ErrorCategory.UseMemo,
reason:
'useMemo() callbacks may not reassign variables declared outside of the callback',
description:
'useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function',
suggestions: null,
}).withDetails({
kind: 'error',
loc: value.lvalue.place.loc,
message: 'Cannot reassign variable',
}),
);
}
break;
}
}

View File

@@ -0,0 +1,63 @@
## Input
```javascript
import {identity, Stringify, useIdentity} from 'shared-runtime';
function Component(props) {
const {x, ...rest} = useIdentity(props);
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify, useIdentity } from "shared-runtime";
function Component(props) {
const $ = _c(6);
const t0 = useIdentity(props);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
const z = rest.z;
identity(z);
let t1;
if ($[3] !== x || $[4] !== z) {
t1 = <Stringify x={x} z={z} />;
$[3] = x;
$[4] = z;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"x":"Hello","z":"World"}</div>

View File

@@ -0,0 +1,13 @@
import {identity, Stringify, useIdentity} from 'shared-runtime';
function Component(props) {
const {x, ...rest} = useIdentity(props);
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -0,0 +1,57 @@
## Input
```javascript
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
return <Stringify {...rest} x={x} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify } from "shared-runtime";
function Component(t0) {
const $ = _c(6);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
let t1;
if ($[3] !== rest || $[4] !== x) {
t1 = <Stringify {...rest} x={x} />;
$[3] = rest;
$[4] = x;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"z":"World","x":"Hello"}</div>

View File

@@ -0,0 +1,10 @@
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
return <Stringify {...rest} x={x} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -0,0 +1,63 @@
## Input
```javascript
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const restAlias = rest;
const z = restAlias.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify } from "shared-runtime";
function Component(t0) {
const $ = _c(6);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
const restAlias = rest;
const z = restAlias.z;
identity(z);
let t1;
if ($[3] !== x || $[4] !== z) {
t1 = <Stringify x={x} z={z} />;
$[3] = x;
$[4] = z;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"x":"Hello","z":"World"}</div>

View File

@@ -0,0 +1,13 @@
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const restAlias = rest;
const z = restAlias.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -0,0 +1,61 @@
## Input
```javascript
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify } from "shared-runtime";
function Component(t0) {
const $ = _c(6);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
const z = rest.z;
identity(z);
let t1;
if ($[3] !== x || $[4] !== z) {
t1 = <Stringify x={x} z={z} />;
$[3] = x;
$[4] = z;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"x":"Hello","z":"World"}</div>

View File

@@ -0,0 +1,12 @@
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -0,0 +1,45 @@
## Input
```javascript
// @flow
export hook useItemLanguage(items) {
return useMemo(() => {
let language: ?string = null;
items.forEach(item => {
if (item.language != null) {
language = item.language;
}
});
return language;
}, [items]);
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
export function useItemLanguage(items) {
const $ = _c(2);
let language;
if ($[0] !== items) {
language = null;
items.forEach((item) => {
if (item.language != null) {
language = item.language;
}
});
$[0] = items;
$[1] = language;
} else {
language = $[1];
}
return language;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,12 @@
// @flow
export hook useItemLanguage(items) {
return useMemo(() => {
let language: ?string = null;
items.forEach(item => {
if (item.language != null) {
language = item.language;
}
});
return language;
}, [items]);
}

View File

@@ -136,8 +136,6 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-timeline-text-dim-color': '#ccc',
'--color-timeline-react-work-border': '#eeeeee',
'--color-timebar-background': '#f6f6f6',
'--color-timespan-background': '#62bc6a',
'--color-timespan-background-errored': '#d57066',
'--color-search-match': 'yellow',
'--color-search-match-current': '#f7923b',
'--color-selected-tree-highlight-active': 'rgba(0, 136, 250, 0.1)',
@@ -156,6 +154,14 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-warning-text-color': '#ffffff',
'--color-warning-text-color-inverted': '#fd4d69',
'--color-suspense-default': '#0088fa',
'--color-transition-default': '#6a51b2',
'--color-suspense-server': '#62bc6a',
'--color-transition-server': '#3f7844',
'--color-suspense-other': '#f3ce49',
'--color-transition-other': '#917b2c',
'--color-suspense-errored': '#d57066',
// The styles below should be kept in sync with 'root.css'
// They are repeated there because they're used by e.g. tooltips or context menus
// which get rendered outside of the DOM subtree (where normal theme/styles are written).
@@ -290,8 +296,6 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-timeline-text-dim-color': '#555b66',
'--color-timeline-react-work-border': '#3d424a',
'--color-timebar-background': '#1d2129',
'--color-timespan-background': '#62bc6a',
'--color-timespan-background-errored': '#d57066',
'--color-search-match': 'yellow',
'--color-search-match-current': '#f7923b',
'--color-selected-tree-highlight-active': 'rgba(23, 143, 185, 0.15)',
@@ -311,6 +315,14 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-warning-text-color': '#ffffff',
'--color-warning-text-color-inverted': '#ee1638',
'--color-suspense-default': '#61dafb',
'--color-transition-default': '#6a51b2',
'--color-suspense-server': '#62bc6a',
'--color-transition-server': '#3f7844',
'--color-suspense-other': '#f3ce49',
'--color-transition-other': '#917b2c',
'--color-suspense-errored': '#d57066',
// The styles below should be kept in sync with 'root.css'
// They are repeated there because they're used by e.g. tooltips or context menus
// which get rendered outside of the DOM subtree (where normal theme/styles are written).

View File

@@ -34,6 +34,7 @@ import {
shallowDiffers,
utfDecodeStringWithRanges,
parseElementDisplayNameFromBackend,
unionOfTwoArrays,
} from '../utils';
import {localStorageGetItem, localStorageSetItem} from '../storage';
import {__DEBUG__} from '../constants';
@@ -51,6 +52,7 @@ import type {
ComponentFilter,
ElementType,
SuspenseNode,
SuspenseTimelineStep,
Rect,
} from 'react-devtools-shared/src/frontend/types';
import type {
@@ -895,13 +897,10 @@ export default class Store extends EventEmitter<{
*/
getSuspendableDocumentOrderSuspense(
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseNode['id']> {
): $ReadOnlyArray<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
const roots = this.roots;
if (roots.length === 0) {
return [];
}
const list: SuspenseNode['id'][] = [];
let rootStep: null | SuspenseTimelineStep = null;
for (let i = 0; i < roots.length; i++) {
const rootID = roots[i];
const root = this.getElementByID(rootID);
@@ -912,44 +911,76 @@ export default class Store extends EventEmitter<{
const suspense = this.getSuspenseByID(rootID);
if (suspense !== null) {
if (list.length === 0) {
// start with an arbitrary root that will allow inspection of the Screen
list.push(suspense.id);
}
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
continue;
}
// Ignore any suspense boundaries that has no visual representation as this is not
// part of the visible loading sequence.
// TODO: Consider making visible meta data and other side-effects get virtual rects.
const hasRects =
current.rects !== null &&
current.rects.length > 0 &&
current.rects.some(isNonZeroRect);
if (
hasRects &&
(!uniqueSuspendersOnly || current.hasUniqueSuspenders) &&
// Roots are already included as part of the Screen
current.id !== rootID
) {
list.push(current.id);
}
// Add children in reverse order to maintain document order
for (let j = current.children.length - 1; j >= 0; j--) {
const childSuspense = this.getSuspenseByID(current.children[j]);
if (childSuspense !== null) {
stack.push(childSuspense);
}
}
const environments = suspense.environments;
const environmentName =
environments.length > 0
? environments[environments.length - 1]
: null;
if (rootStep === null) {
// Arbitrarily use the first root as the root step id.
rootStep = {
id: suspense.id,
environment: environmentName,
};
target.push(rootStep);
} else if (rootStep.environment === null) {
// If any root has an environment name, then let's use it.
rootStep.environment = environmentName;
}
this.pushTimelineStepsInDocumentOrder(
suspense.children,
target,
uniqueSuspendersOnly,
environments,
);
}
}
return list;
return target;
}
pushTimelineStepsInDocumentOrder(
children: Array<SuspenseNode['id']>,
target: Array<SuspenseTimelineStep>,
uniqueSuspendersOnly: boolean,
parentEnvironments: Array<string>,
): void {
for (let i = 0; i < children.length; i++) {
const child = this.getSuspenseByID(children[i]);
if (child === null) {
continue;
}
// Ignore any suspense boundaries that has no visual representation as this is not
// part of the visible loading sequence.
// TODO: Consider making visible meta data and other side-effects get virtual rects.
const hasRects =
child.rects !== null &&
child.rects.length > 0 &&
child.rects.some(isNonZeroRect);
const childEnvironments = child.environments;
// Since children are blocked on the parent, they're also blocked by the parent environments.
// Only if we discover a novel environment do we add that and it becomes the name we use.
const unionEnvironments = unionOfTwoArrays(
parentEnvironments,
childEnvironments,
);
const environmentName =
unionEnvironments.length > 0
? unionEnvironments[unionEnvironments.length - 1]
: null;
if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) {
target.push({
id: child.id,
environment: environmentName,
});
}
this.pushTimelineStepsInDocumentOrder(
child.children,
target,
uniqueSuspendersOnly,
unionEnvironments,
);
}
}
getRendererIDForElement(id: number): number | null {
@@ -1627,6 +1658,7 @@ export default class Store extends EventEmitter<{
rects,
hasUniqueSuspenders: false,
isSuspended: isSuspended,
environments: [],
});
hasSuspenseTreeChanged = true;
@@ -1812,7 +1844,10 @@ export default class Store extends EventEmitter<{
envIndex++
) {
const environmentNameStringID = operations[i++];
environmentNames.push(stringTable[environmentNameStringID]);
const environmentName = stringTable[environmentNameStringID];
if (environmentName != null) {
environmentNames.push(environmentName);
}
}
const suspense = this._idToSuspense.get(id);
@@ -1836,7 +1871,7 @@ export default class Store extends EventEmitter<{
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
suspense.isSuspended = isSuspended;
// TODO: Recompute the environment names.
suspense.environments = environmentNames;
}
hasSuspenseTreeChanged = true;

View File

@@ -128,13 +128,13 @@
.TimeBarSpan, .TimeBarSpanErrored {
position: absolute;
border-radius: 0.125rem;
background-color: var(--color-timespan-background);
background-color: var(--color-suspense);
width: 100%;
height: 100%;
}
.TimeBarSpanErrored {
background-color: var(--color-timespan-background-errored);
background-color: var(--color-suspense-errored);
}
.SmallHeader {

View File

@@ -22,6 +22,8 @@ import OwnerView from './OwnerView';
import {meta} from '../../../hydration';
import useInferredName from '../useInferredName';
import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js';
import type {
InspectedElement,
SerializedAsyncInfo,
@@ -169,7 +171,7 @@ function SuspendedByRow({
type={isOpen ? 'expanded' : 'collapsed'}
/>
<span className={styles.CollapsableHeaderTitle}>
{skipName ? shortDescription : name}
{skipName && shortDescription !== '' ? shortDescription : name}
</span>
{skipName || shortDescription === '' ? null : (
<>
@@ -181,7 +183,12 @@ function SuspendedByRow({
</>
)}
<div className={styles.CollapsableHeaderFiller} />
<div className={styles.TimeBarContainer}>
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(ioInfo.env)
}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -341,6 +348,7 @@ type GroupProps = {
inspectedElement: InspectedElement,
store: Store,
name: string,
environment: null | string,
suspendedBy: Array<{
index: number,
value: SerializedAsyncInfo,
@@ -355,6 +363,7 @@ function SuspendedByGroup({
inspectedElement,
store,
name,
environment,
suspendedBy,
minTime,
maxTime,
@@ -407,7 +416,12 @@ function SuspendedByGroup({
<span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
<div className={styles.CollapsableHeaderFiller} />
{isOpen ? null : (
<div className={styles.TimeBarContainer}>
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(environment)
}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -502,17 +516,21 @@ export default function InspectedElementSuspendedBy({
const groups = [];
let currentGroup = null;
let currentGroupName = null;
let currentGroupEnv = null;
for (let i = 0; i < sortedSuspendedBy.length; i++) {
const entry = sortedSuspendedBy[i];
const name = entry.value.awaited.name;
const env = entry.value.awaited.env;
if (
currentGroupName !== name ||
currentGroupEnv !== env ||
!name ||
name === 'Promise' ||
currentGroup === null
) {
// Create a new group.
currentGroupName = name;
currentGroupEnv = env;
currentGroup = [];
groups.push(currentGroup);
}
@@ -591,6 +609,7 @@ export default function InspectedElementSuspendedBy({
<SuspendedByGroup
key={entries[0].index}
name={entries[0].value.awaited.name}
environment={entries[0].value.awaited.env}
suspendedBy={entries}
bridge={bridge}
element={element}

View File

@@ -0,0 +1,14 @@
.SuspenseEnvironmentDefault {
--color-suspense: var(--color-suspense-default);
--color-transition: var(--color-transition-default);
}
.SuspenseEnvironmentServer {
--color-suspense: var(--color-suspense-server);
--color-transition: var(--color-transition-server);
}
.SuspenseEnvironmentOther {
--color-suspense: var(--color-suspense-other);
--color-transition: var(--color-transition-other);
}

View File

@@ -0,0 +1,20 @@
/**
* 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 styles from './SuspenseEnvironmentColors.css';
export function getClassNameForEnvironment(environment: null | string): string {
if (environment === null) {
return styles.SuspenseEnvironmentDefault;
}
if (environment === 'Server') {
return styles.SuspenseEnvironmentServer;
}
return styles.SuspenseEnvironmentOther;
}

View File

@@ -1,12 +1,25 @@
.SuspenseRectsContainer {
padding: .25rem;
cursor: pointer;
outline: 1px solid var(--color-component-name);
outline-color: transparent;
outline-style: solid;
outline-width: 1px;
border-radius: 0.25rem;
}
.SuspenseRectsContainer[data-highlighted='true'] {
background: var(--color-dimmest);
outline-color: var(--color-transition);
outline-style: solid;
outline-width: 4px;
}
.SuspenseRectsRoot {
cursor: pointer;
outline-color: var(--color-transition);
background-color: color-mix(in srgb, var(--color-transition) 5%, transparent);
}
.SuspenseRectsRoot[data-hovered='true'] {
background-color: color-mix(in srgb, var(--color-transition) 15%, transparent);
}
.SuspenseRectsViewBox {
@@ -15,6 +28,11 @@
.SuspenseRectsBoundary {
pointer-events: all;
border-radius: 0.125rem;
}
.SuspenseRectsBoundary[data-visible='false'] {
background-color: transparent;
}
.SuspenseRectsBoundaryChildren {
@@ -28,15 +46,18 @@
.SuspenseRectsRect {
box-shadow: var(--elevation-4);
pointer-events: all;
cursor: pointer;
border-radius: 0.125rem;
background-color: color-mix(in srgb, var(--color-background) 50%, var(--color-suspense) 25%);
backdrop-filter: grayscale(100%);
transition: background-color 0.2s ease-in;
outline-color: var(--color-suspense);
outline-style: solid;
outline-width: 1px;
border-radius: 0.125rem;
cursor: pointer;
}
.SuspenseRectsScaledRect {
position: absolute;
outline-color: var(--color-background-selected);
}
.SuspenseRectsScaledRect[data-visible='false'] {
@@ -44,15 +65,28 @@
outline-width: 0;
}
.SuspenseRectsScaledRect[data-suspended='true'] {
opacity: 0.3;
.SuspenseRectsBoundary[data-suspended='true'] {
opacity: 0.33;
}
/* highlight this boundary */
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
background-color: var(--color-background-hover);
.SuspenseRectsBoundary[data-hovered='true'] > .SuspenseRectsRect {
background-color: color-mix(in srgb, var(--color-background) 50%, var(--color-suspense) 50%);
transition: background-color 0.2s ease-out;
}
.SuspenseRectsRect[data-highlighted='true'] {
background-color: var(--color-selected-tree-highlight-active);
.SuspenseRectsBoundary[data-selected='true'] {
box-shadow: var(--elevation-4);
}
.SuspenseRectOutline {
outline-color: var(--color-suspense);
outline-style: solid;
outline-width: 4px;
border-radius: 0.125rem;
pointer-events: none;
}
.SuspenseRectsBoundary[data-selected='true'] > .SuspenseRectsRect {
box-shadow: none;
}

View File

@@ -30,12 +30,15 @@ import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
function ScaledRect({
className,
rect,
visible,
suspended,
selected,
hovered,
adjust,
...props
}: {
@@ -43,6 +46,8 @@ function ScaledRect({
rect: Rect,
visible: boolean,
suspended: boolean,
selected?: boolean,
hovered?: boolean,
adjust?: boolean,
...
}): React$Node {
@@ -58,6 +63,8 @@ function ScaledRect({
className={styles.SuspenseRectsScaledRect + ' ' + className}
data-visible={visible}
data-suspended={suspended}
data-selected={selected}
data-hovered={hovered}
style={{
// Shrink one pixel so that the bottom outline will line up with the top outline of the next one.
width: adjust ? 'calc(' + width + ' - 1px)' : width,
@@ -77,7 +84,9 @@ function SuspenseRects({
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
const {uniqueSuspendersOnly, timeline, hoveredTimelineIndex} = useContext(
SuspenseTreeStateContext,
);
const {inspectedElementID} = useContext(TreeStateContext);
@@ -145,14 +154,33 @@ function SuspenseRects({
// TODO: Use the nearest Suspense boundary
const selected = inspectedElementID === suspenseID;
const hovered =
hoveredTimelineIndex > -1 &&
timeline[hoveredTimelineIndex].id === suspenseID;
let environment: null | string = null;
for (let i = 0; i < timeline.length; i++) {
const timelineStep = timeline[i];
if (timelineStep.id === suspenseID) {
environment = timelineStep.environment;
break;
}
}
const boundingBox = getBoundingBox(suspense.rects);
return (
<ScaledRect
rect={boundingBox}
className={styles.SuspenseRectsBoundary}
className={
styles.SuspenseRectsBoundary +
' ' +
getClassNameForEnvironment(environment)
}
visible={visible}
suspended={suspense.isSuspended}>
selected={selected}
suspended={suspense.isSuspended}
hovered={hovered}>
<ViewBox.Provider value={boundingBox}>
{visible &&
suspense.rects !== null &&
@@ -162,7 +190,6 @@ function SuspenseRects({
key={index}
className={styles.SuspenseRectsRect}
rect={rect}
data-highlighted={selected}
adjust={true}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -182,6 +209,13 @@ function SuspenseRects({
})}
</ScaledRect>
)}
{selected ? (
<ScaledRect
className={styles.SuspenseRectOutline}
rect={boundingBox}
adjust={true}
/>
) : null}
</ViewBox.Provider>
</ScaledRect>
);
@@ -307,7 +341,8 @@ function SuspenseRectsContainer(): React$Node {
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {roots} = useContext(SuspenseTreeStateContext);
const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
useContext(SuspenseTreeStateContext);
// TODO: bbox does not consider uniqueSuspendersOnly filter
const boundingBox = getDocumentBoundingRect(store, roots);
@@ -351,13 +386,37 @@ function SuspenseRectsContainer(): React$Node {
}
const isRootSelected = roots.includes(inspectedElementID);
const isRootHovered = hoveredTimelineIndex === 0;
let hasRootSuspenders = false;
if (!uniqueSuspendersOnly) {
hasRootSuspenders = true;
} else {
for (let i = 0; i < roots.length; i++) {
const rootID = roots[i];
const root = store.getSuspenseByID(rootID);
if (root !== null && root.hasUniqueSuspenders) {
hasRootSuspenders = true;
break;
}
}
}
const rootEnvironment =
timeline.length === 0 ? null : timeline[0].environment;
return (
<div
className={styles.SuspenseRectsContainer}
className={
styles.SuspenseRectsContainer +
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
' ' +
getClassNameForEnvironment(rootEnvironment)
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
data-highlighted={isRootSelected}>
data-highlighted={isRootSelected}
data-hovered={isRootHovered}>
<ViewBox.Provider value={boundingBox}>
<div
className={styles.SuspenseRectsViewBox}

View File

@@ -40,22 +40,21 @@
.SuspenseScrubberBead {
flex: 1;
height: 0.5rem;
background: var(--color-background-selected);
border-radius: 0.5rem;
background: var(--color-selected-tree-highlight-active);
transition: all 0.3s ease-in-out;
background: color-mix(in srgb, var(--color-suspense) 10%, transparent);
transition: all 0.3s ease-in;
}
.SuspenseScrubberBeadSelected {
height: 1rem;
background: var(--color-background-selected);
background: var(--color-suspense);
}
.SuspenseScrubberBeadTransition {
background: var(--color-component-name);
background: var(--color-transition);
}
.SuspenseScrubberStepHighlight > .SuspenseScrubberBead,
.SuspenseScrubberStep:hover > .SuspenseScrubberBead {
.SuspenseScrubberStepHighlight > .SuspenseScrubberBead {
height: 0.75rem;
transition: all 0.3s ease-out;
}

View File

@@ -7,6 +7,8 @@
* @flow
*/
import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/types';
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
@@ -14,11 +16,14 @@ import {useRef} from 'react';
import styles from './SuspenseScrubber.css';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
import Tooltip from '../Components/reach-ui/tooltip';
export default function SuspenseScrubber({
min,
max,
timeline,
value,
highlight,
onBlur,
@@ -29,6 +34,7 @@ export default function SuspenseScrubber({
}: {
min: number,
max: number,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
value: number,
highlight: number,
onBlur?: () => void,
@@ -54,17 +60,18 @@ export default function SuspenseScrubber({
}
const steps = [];
for (let index = min; index <= max; index++) {
const environment = timeline[index].environment;
const label =
index === min
? // The first step in the timeline is always a Transition (Initial Paint).
'Initial Paint' +
(environment === null ? '' : ' (' + environment + ')')
: // TODO: Consider adding the name of this specific boundary if this step has only one.
environment === null
? 'Suspense'
: environment;
steps.push(
<Tooltip
key={index}
label={
index === min
? // The first step in the timeline is always a Transition (Initial Paint).
// TODO: Support multiple environments.
'Initial Paint'
: // TODO: Consider adding the name of this specific boundary if this step has only one.
'Suspense'
}>
<Tooltip key={index} label={label}>
<div
className={
styles.SuspenseScrubberStep +
@@ -79,9 +86,10 @@ export default function SuspenseScrubber({
styles.SuspenseScrubberBead +
(index === min
? // The first step in the timeline is always a Transition (Initial Paint).
// TODO: Support multiple environments.
' ' + styles.SuspenseScrubberBeadTransition
: '') +
' ' +
getClassNameForEnvironment(environment) +
(index <= value ? ' ' + styles.SuspenseScrubberBeadSelected : '')
}
/>

View File

@@ -34,7 +34,7 @@ function SuspenseTimelineInput() {
const max = timeline.length > 0 ? timeline.length - 1 : 0;
function switchSuspenseNode(nextTimelineIndex: number) {
const nextSelectedSuspenseID = timeline[nextTimelineIndex];
const nextSelectedSuspenseID = timeline[nextTimelineIndex].id;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -53,13 +53,22 @@ function SuspenseTimelineInput() {
switchSuspenseNode(timelineIndex);
}
function handleHoverSegment(hoveredValue: number) {
// TODO: Consider highlighting the rect instead.
function handleHoverSegment(hoveredIndex: number) {
const nextSelectedSuspenseID = timeline[hoveredIndex].id;
suspenseTreeDispatch({
type: 'HOVER_TIMELINE_FOR_ID',
payload: nextSelectedSuspenseID,
});
}
function handleUnhoverSegment() {
suspenseTreeDispatch({
type: 'HOVER_TIMELINE_FOR_ID',
payload: -1,
});
}
function handleUnhoverSegment() {}
function skipPrevious() {
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
const nextSelectedSuspenseID = timeline[timelineIndex - 1].id;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -71,7 +80,7 @@ function SuspenseTimelineInput() {
}
function skipForward() {
const nextSelectedSuspenseID = timeline[timelineIndex + 1];
const nextSelectedSuspenseID = timeline[timelineIndex + 1].id;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -97,7 +106,7 @@ function SuspenseTimelineInput() {
// anything suspended in the root. The step after that should have one less
// thing suspended. I.e. the first suspense boundary should be unsuspended
// when it's selected. This also lets you show everything in the last step.
const suspendedSet = timeline.slice(timelineIndex + 1);
const suspendedSet = timeline.slice(timelineIndex + 1).map(step => step.id);
bridge.send('overrideSuspenseMilestone', {
suspendedSet,
});
@@ -164,6 +173,7 @@ function SuspenseTimelineInput() {
<SuspenseScrubber
min={min}
max={max}
timeline={timeline}
value={timelineIndex}
highlight={hoveredTimelineIndex}
onChange={handleChange}

View File

@@ -7,7 +7,10 @@
* @flow
*/
import type {ReactContext} from 'shared/ReactTypes';
import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
import type {
SuspenseNode,
SuspenseTimelineStep,
} from 'react-devtools-shared/src/frontend/types';
import type Store from '../../store';
import * as React from 'react';
@@ -25,7 +28,7 @@ export type SuspenseTreeState = {
lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
roots: $ReadOnlyArray<SuspenseNode['id']>,
selectedSuspenseID: SuspenseNode['id'] | null,
timeline: $ReadOnlyArray<SuspenseNode['id']>,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
timelineIndex: number | -1,
hoveredTimelineIndex: number | -1,
uniqueSuspendersOnly: boolean,
@@ -49,7 +52,7 @@ type ACTION_SELECT_SUSPENSE_BY_ID = {
type ACTION_SET_SUSPENSE_TIMELINE = {
type: 'SET_SUSPENSE_TIMELINE',
payload: [
$ReadOnlyArray<SuspenseNode['id']>,
$ReadOnlyArray<SuspenseTimelineStep>,
// The next Suspense ID to select in the timeline
SuspenseNode['id'] | null,
// Whether this timeline includes only unique suspenders
@@ -111,7 +114,7 @@ function getInitialState(store: Store): SuspenseTreeState {
store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
const timelineIndex = timeline.length - 1;
const selectedSuspenseID =
timelineIndex === -1 ? null : timeline[timelineIndex];
timelineIndex === -1 ? null : timeline[timelineIndex].id;
const lineage =
selectedSuspenseID !== null
? store.getSuspenseLineage(selectedSuspenseID)
@@ -164,16 +167,18 @@ function SuspenseTreeContextController({children}: Props): React.Node {
selectedSuspenseID = null;
}
let selectedTimelineID =
state.timeline === null
const selectedTimelineStep =
state.timeline === null || state.timelineIndex === -1
? null
: state.timeline[state.timelineIndex];
while (
selectedTimelineID !== null &&
removedIDs.has(selectedTimelineID)
) {
// $FlowExpectedError[incompatible-type]
selectedTimelineID = removedIDs.get(selectedTimelineID);
let selectedTimelineID: null | number = null;
if (selectedTimelineStep !== null) {
selectedTimelineID = selectedTimelineStep.id;
// $FlowFixMe
while (removedIDs.has(selectedTimelineID)) {
// $FlowFixMe
selectedTimelineID = removedIDs.get(selectedTimelineID);
}
}
// TODO: Handle different timeline modes (e.g. random order)
@@ -181,20 +186,25 @@ function SuspenseTreeContextController({children}: Props): React.Node {
state.uniqueSuspendersOnly,
);
let nextTimelineIndex =
selectedTimelineID === null || nextTimeline.length === 0
? -1
: nextTimeline.indexOf(selectedTimelineID);
let nextTimelineIndex = -1;
if (selectedTimelineID !== null && nextTimeline.length !== 0) {
for (let i = 0; i < nextTimeline.length; i++) {
if (nextTimeline[i].id === selectedTimelineID) {
nextTimelineIndex = i;
break;
}
}
}
if (
nextTimeline.length > 0 &&
(nextTimelineIndex === -1 || state.autoSelect)
) {
nextTimelineIndex = nextTimeline.length - 1;
selectedSuspenseID = nextTimeline[nextTimelineIndex];
selectedSuspenseID = nextTimeline[nextTimelineIndex].id;
}
if (selectedSuspenseID === null && nextTimeline.length > 0) {
selectedSuspenseID = nextTimeline[nextTimeline.length - 1];
selectedSuspenseID = nextTimeline[nextTimeline.length - 1].id;
}
const nextLineage =
@@ -256,12 +266,12 @@ function SuspenseTreeContextController({children}: Props): React.Node {
nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
nextMilestoneIndex = nextTimeline.length - 1;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
} else if (nextRootID !== null) {
nextMilestoneIndex = nextTimeline.length - 1;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
@@ -276,7 +286,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
case 'SUSPENSE_SET_TIMELINE_INDEX': {
const nextTimelineIndex = action.payload;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -301,7 +311,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
) {
return state;
}
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -329,7 +339,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
) {
// If we're restarting at the end. Then loop around and start again from the beginning.
nextTimelineIndex = 0;
nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
@@ -352,7 +362,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
if (nextTimelineIndex > state.timeline.length - 1) {
return state;
}
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -369,8 +379,14 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
case 'TOGGLE_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
const timelineIndexForSuspenseID =
state.timeline.indexOf(suspenseID);
let timelineIndexForSuspenseID = -1;
for (let i = 0; i < state.timeline.length; i++) {
if (state.timeline[i].id === suspenseID) {
timelineIndexForSuspenseID = i;
break;
}
}
if (timelineIndexForSuspenseID === -1) {
// This boundary is no longer in the timeline.
return state;
@@ -387,7 +403,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
timelineIndexForSuspenseID
: // Otherwise, if we're currently showing it, jump to right before to hide it.
timelineIndexForSuspenseID - 1;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -403,8 +419,13 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
case 'HOVER_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
const timelineIndexForSuspenseID =
state.timeline.indexOf(suspenseID);
let timelineIndexForSuspenseID = -1;
for (let i = 0; i < state.timeline.length; i++) {
if (state.timeline[i].id === suspenseID) {
timelineIndexForSuspenseID = i;
break;
}
}
return {
...state,
hoveredTimelineIndex: timelineIndexForSuspenseID,

View File

@@ -193,6 +193,11 @@ export type Rect = {
height: number,
};
export type SuspenseTimelineStep = {
id: SuspenseNode['id'], // TODO: Will become a group.
environment: null | string,
};
export type SuspenseNode = {
id: Element['id'],
parentID: SuspenseNode['id'] | 0,
@@ -201,6 +206,7 @@ export type SuspenseNode = {
rects: null | Array<Rect>,
hasUniqueSuspenders: boolean,
isSuspended: boolean,
environments: Array<string>,
};
// Serialized version of ReactIOInfo

View File

@@ -1305,3 +1305,18 @@ export function onReloadAndProfileFlagsReset(): void {
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY);
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_TIMELINE_KEY);
}
export function unionOfTwoArrays<T>(a: Array<T>, b: Array<T>): Array<T> {
let result = a;
for (let i = 0; i < b.length; i++) {
const value = b[i];
if (a.indexOf(value) === -1) {
if (result === a) {
// Lazily copy
result = a.slice(0);
}
result.push(value);
}
}
return result;
}

View File

@@ -10,11 +10,11 @@
'use strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate';
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
let clientExports;
let webpackMap;
let webpackModules;
@@ -1136,4 +1136,37 @@ describe('ReactFlightDOMNode', () => {
'Switched to client rendering because the server rendering errored:\n\nssr-throw',
);
});
// This is a regression test for a specific issue where byte Web Streams are
// detaching ArrayBuffers, which caused downstream issues (e.g. "Cannot
// perform Construct on a detached ArrayBuffer") for chunks that are using
// Node's internal Buffer pool.
it('should not corrupt the Node.js Buffer pool by detaching ArrayBuffers when using Web Streams', async () => {
// Create a temp file smaller than 4KB to ensure it uses the Buffer pool.
const file = path.join(os.tmpdir(), 'test.bin');
fs.writeFileSync(file, Buffer.alloc(4095));
const fileChunk = fs.readFileSync(file);
fs.unlinkSync(file);
// Verify this chunk uses the Buffer pool (8192 bytes for files < 4KB).
expect(fileChunk.buffer.byteLength).toBe(8192);
const readable = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream(fileChunk, webpackMap),
);
// Create a Web Streams WritableStream that tries to use Buffer operations.
const writable = new WritableStream({
write(chunk) {
// Only write one byte to ensure Node.js is not creating a new Buffer
// pool. Typically, library code (e.g. a compression middleware) would
// call Buffer.from(chunk) or similar, instead of allocating a new
// Buffer directly. With that, the test file could only be ~2600 bytes.
Buffer.allocUnsafe(1);
},
});
// Must not throw an error.
await readable.pipeTo(writable);
});
});

View File

@@ -37,7 +37,11 @@ export function flushBuffered(destination: Destination) {
// transform streams. https://github.com/whatwg/streams/issues/960
}
const VIEW_SIZE = 2048;
// Chunks larger than VIEW_SIZE are written directly, without copying into the
// internal view buffer. This must be at least half of Node's internal Buffer
// pool size (8192) to avoid corrupting the pool when using
// renderToReadableStream, which uses a byte stream that detaches ArrayBuffers.
const VIEW_SIZE = 4096;
let currentView = null;
let writtenBytes = 0;
@@ -147,14 +151,7 @@ export function typedArrayToBinaryChunk(
// If we passed through this straight to enqueue we wouldn't have to convert it but since
// we need to copy the buffer in that case, we need to convert it to copy it.
// When we copy it into another array using set() it needs to be a Uint8Array.
const buffer = new Uint8Array(
content.buffer,
content.byteOffset,
content.byteLength,
);
// We clone large chunks so that we can transfer them when we write them.
// Others get copied into the target buffer.
return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer;
return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
}
export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {

View File

@@ -38,7 +38,11 @@ export function flushBuffered(destination: Destination) {
}
}
const VIEW_SIZE = 2048;
// Chunks larger than VIEW_SIZE are written directly, without copying into the
// internal view buffer. This must be at least half of Node's internal Buffer
// pool size (8192) to avoid corrupting the pool when using
// renderToReadableStream, which uses a byte stream that detaches ArrayBuffers.
const VIEW_SIZE = 4096;
let currentView = null;
let writtenBytes = 0;
let destinationHasCapacity = true;