From c35f6a3041816613e704772ca9dafb26568d9f89 Mon Sep 17 00:00:00 2001 From: Joseph Savona <6425824+josephsavona@users.noreply.github.com> Date: Fri, 17 Oct 2025 11:59:17 -0700 Subject: [PATCH 1/8] [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 ; } ``` 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 --- .../Inference/InferMutationAliasingEffects.ts | 164 +++++++++++++++++- .../nonmutated-spread-hook-return.expect.md | 63 +++++++ .../compiler/nonmutated-spread-hook-return.js | 13 ++ .../nonmutated-spread-props-jsx.expect.md | 57 ++++++ .../compiler/nonmutated-spread-props-jsx.js | 10 ++ ...d-spread-props-local-indirection.expect.md | 63 +++++++ ...nmutated-spread-props-local-indirection.js | 13 ++ .../nonmutated-spread-props.expect.md | 61 +++++++ .../compiler/nonmutated-spread-props.js | 12 ++ 9 files changed, 455 insertions(+), 1 deletion(-) create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.js create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.expect.md create mode 100644 compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.js diff --git a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts index d2f2ba61d695a..94be2100f57e8 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Inference/InferMutationAliasingEffects.ts @@ -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; + nonMutatingSpreads: Set; constructor( isFunctionExpression: boolean, fn: HIRFunction, hoistedContextDeclarations: Map, + nonMutatingSpreads: Set, ) { 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 ; + * } + * ``` + * + * 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 { + const knownFrozen = new Set(); + 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(); + 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(); + 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', diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.expect.md new file mode 100644 index 0000000000000..9a4f0c179f9f3 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.expect.md @@ -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 ; +} + +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 = ; + $[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)
{"x":"Hello","z":"World"}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.js new file mode 100644 index 0000000000000..c4447f7be6e44 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-hook-return.js @@ -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 ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{x: 'Hello', z: 'World'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.expect.md new file mode 100644 index 0000000000000..5335705c5d827 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.expect.md @@ -0,0 +1,57 @@ + +## Input + +```javascript +import {identity, Stringify} from 'shared-runtime'; + +function Component({x, ...rest}) { + return ; +} + +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 = ; + $[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)
{"z":"World","x":"Hello"}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.js new file mode 100644 index 0000000000000..d9f24264d69bc --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-jsx.js @@ -0,0 +1,10 @@ +import {identity, Stringify} from 'shared-runtime'; + +function Component({x, ...rest}) { + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{x: 'Hello', z: 'World'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.expect.md new file mode 100644 index 0000000000000..7a435adcaad76 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.expect.md @@ -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 ; +} + +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 = ; + $[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)
{"x":"Hello","z":"World"}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.js new file mode 100644 index 0000000000000..b1d26ab7f7206 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props-local-indirection.js @@ -0,0 +1,13 @@ +import {identity, Stringify} from 'shared-runtime'; + +function Component({x, ...rest}) { + const restAlias = rest; + const z = restAlias.z; + identity(z); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{x: 'Hello', z: 'World'}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.expect.md new file mode 100644 index 0000000000000..a8b1c2d747f0d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +import {identity, Stringify} from 'shared-runtime'; + +function Component({x, ...rest}) { + const z = rest.z; + identity(z); + return ; +} + +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 = ; + $[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)
{"x":"Hello","z":"World"}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.js new file mode 100644 index 0000000000000..d3e83d560c253 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/nonmutated-spread-props.js @@ -0,0 +1,12 @@ +import {identity, Stringify} from 'shared-runtime'; + +function Component({x, ...rest}) { + const z = rest.z; + identity(z); + return ; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{x: 'Hello', z: 'World'}], +}; From dc485c7303f0d7d10fdbd2ccd4a020574e679840 Mon Sep 17 00:00:00 2001 From: Hendrik Liebau Date: Fri, 17 Oct 2025 22:13:52 +0200 Subject: [PATCH 2/8] [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 --- .../src/__tests__/ReactFlightDOMNode-test.js | 39 +++++++++++++++++-- .../src/ReactServerStreamConfigEdge.js | 15 +++---- .../src/ReactServerStreamConfigNode.js | 6 ++- 3 files changed, 47 insertions(+), 13 deletions(-) diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js index f63f82333116e..d59f298f99496 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js @@ -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); + }); }); diff --git a/packages/react-server/src/ReactServerStreamConfigEdge.js b/packages/react-server/src/ReactServerStreamConfigEdge.js index dbe6d6f90cab1..90affdc6b8aca 100644 --- a/packages/react-server/src/ReactServerStreamConfigEdge.js +++ b/packages/react-server/src/ReactServerStreamConfigEdge.js @@ -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 { diff --git a/packages/react-server/src/ReactServerStreamConfigNode.js b/packages/react-server/src/ReactServerStreamConfigNode.js index 3fb698411721e..90609da2c45d6 100644 --- a/packages/react-server/src/ReactServerStreamConfigNode.js +++ b/packages/react-server/src/ReactServerStreamConfigNode.js @@ -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; From ef88c588d51366d16c5323dba4bd197aeb85e4ea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 17 Oct 2025 18:51:02 -0400 Subject: [PATCH 3/8] [DevTools] Tweak the rects design and create multi-environment color scheme (#34880) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshot 2025-10-16 at 2 20 46 PM 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. --- .../src/devtools/constants.js | 20 ++++++-- .../InspectedElementSharedStyles.css | 4 +- .../views/SuspenseTab/SuspenseRects.css | 51 +++++++++++++++---- .../views/SuspenseTab/SuspenseRects.js | 12 ++++- .../views/SuspenseTab/SuspenseScrubber.css | 10 ++-- 5 files changed, 74 insertions(+), 23 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/constants.js b/packages/react-devtools-shared/src/devtools/constants.js index cfb73713b089e..5c90501d70f5c 100644 --- a/packages/react-devtools-shared/src/devtools/constants.js +++ b/packages/react-devtools-shared/src/devtools/constants.js @@ -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': '#0088fa', + '--color-transition': '#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': '#61dafb', + '--color-transition': '#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). diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css index 413554008fa53..6c56aec689986 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSharedStyles.css @@ -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 { diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css index 591d908735efb..16d385d75231c 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css @@ -1,12 +1,20 @@ .SuspenseRectsContainer { padding: .25rem; cursor: pointer; - outline: 1px solid var(--color-component-name); + outline-color: var(--color-transition); + outline-style: solid; + outline-width: 1px; border-radius: 0.25rem; + background-color: color-mix(in srgb, var(--color-transition) 5%, transparent); +} + +.SuspenseRectsContainer:hover:not(:has(.SuspenseRectsBoundary:hover))[data-highlighted='false'] { + outline-width: 1px; } .SuspenseRectsContainer[data-highlighted='true'] { - background: var(--color-dimmest); + outline-style: solid; + outline-width: 4px; } .SuspenseRectsViewBox { @@ -15,6 +23,11 @@ .SuspenseRectsBoundary { pointer-events: all; + border-radius: 0.125rem; +} + +.SuspenseRectsBoundary[data-visible='false'] { + background-color: transparent; } .SuspenseRectsBoundaryChildren { @@ -28,15 +41,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 +60,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:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect { + background-color: color-mix(in srgb, var(--color-background) 50%, var(--color-suspense) 50%); + transition: background-color 0.2s ease-out; +} + +.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; } -.SuspenseRectsRect[data-highlighted='true'] { - background-color: var(--color-selected-tree-highlight-active); +.SuspenseRectsBoundary[data-selected='true'] > .SuspenseRectsRect { + box-shadow: none; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index c2a131916504c..4a45187d7fde3 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -36,6 +36,7 @@ function ScaledRect({ rect, visible, suspended, + selected, adjust, ...props }: { @@ -43,6 +44,7 @@ function ScaledRect({ rect: Rect, visible: boolean, suspended: boolean, + selected?: boolean, adjust?: boolean, ... }): React$Node { @@ -58,6 +60,7 @@ function ScaledRect({ className={styles.SuspenseRectsScaledRect + ' ' + className} data-visible={visible} data-suspended={suspended} + data-selected={selected} 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, @@ -152,6 +155,7 @@ function SuspenseRects({ rect={boundingBox} className={styles.SuspenseRectsBoundary} visible={visible} + selected={selected} suspended={suspense.isSuspended}> {visible && @@ -162,7 +166,6 @@ function SuspenseRects({ key={index} className={styles.SuspenseRectsRect} rect={rect} - data-highlighted={selected} adjust={true} onClick={handleClick} onDoubleClick={handleDoubleClick} @@ -182,6 +185,13 @@ function SuspenseRects({ })} )} + {selected ? ( + + ) : null} ); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css index 932a23103f1a3..93740531a2139 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseScrubber.css @@ -40,22 +40,22 @@ .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 { height: 0.75rem; + transition: all 0.3s ease-out; } From 724e7bfb40ad4f08b7f59f23db22e5fa7eda965a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 17 Oct 2025 18:52:07 -0400 Subject: [PATCH 4/8] [DevTools] Repeat the "name" if there's no short description in groups (#34894) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It looks weird when the row is blank when there's no short description for the entry in a group. Screenshot 2025-10-17 at 12 25 30 AM --- .../devtools/views/Components/InspectedElementSuspendedBy.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index fa2b6a95bed53..9078d3c3beabc 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -169,7 +169,7 @@ function SuspendedByRow({ type={isOpen ? 'expanded' : 'collapsed'} /> - {skipName ? shortDescription : name} + {skipName && shortDescription !== '' ? shortDescription : name} {skipName || shortDescription === '' ? null : ( <> From f970d5ff325b49d3e675c9e72025834ddc86879d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 17 Oct 2025 18:52:26 -0400 Subject: [PATCH 5/8] [DevTools] Highlight the rect when the corresponding timeline bean is hovered (#34881) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2025-10-16 at 3 11 17 PM --- .../views/SuspenseTab/SuspenseRects.css | 6 +++--- .../views/SuspenseTab/SuspenseRects.js | 19 +++++++++++++++---- .../views/SuspenseTab/SuspenseScrubber.css | 3 +-- .../views/SuspenseTab/SuspenseTimeline.js | 15 ++++++++++++--- 4 files changed, 31 insertions(+), 12 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css index 16d385d75231c..69a4022307ec0 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css @@ -8,8 +8,8 @@ background-color: color-mix(in srgb, var(--color-transition) 5%, transparent); } -.SuspenseRectsContainer:hover:not(:has(.SuspenseRectsBoundary:hover))[data-highlighted='false'] { - outline-width: 1px; +.SuspenseRectsContainer[data-hovered='true'] { + background-color: color-mix(in srgb, var(--color-transition) 15%, transparent); } .SuspenseRectsContainer[data-highlighted='true'] { @@ -65,7 +65,7 @@ } /* highlight this boundary */ -.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect { +.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; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index 4a45187d7fde3..fcbb5f5f74e01 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -37,6 +37,7 @@ function ScaledRect({ visible, suspended, selected, + hovered, adjust, ...props }: { @@ -45,6 +46,7 @@ function ScaledRect({ visible: boolean, suspended: boolean, selected?: boolean, + hovered?: boolean, adjust?: boolean, ... }): React$Node { @@ -61,6 +63,7 @@ function ScaledRect({ 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, @@ -80,7 +83,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); @@ -148,6 +153,9 @@ function SuspenseRects({ // TODO: Use the nearest Suspense boundary const selected = inspectedElementID === suspenseID; + const hovered = + hoveredTimelineIndex > -1 && timeline[hoveredTimelineIndex] === suspenseID; + const boundingBox = getBoundingBox(suspense.rects); return ( @@ -156,7 +164,8 @@ function SuspenseRects({ className={styles.SuspenseRectsBoundary} visible={visible} selected={selected} - suspended={suspense.isSuspended}> + suspended={suspense.isSuspended} + hovered={hovered}> {visible && suspense.rects !== null && @@ -317,7 +326,7 @@ 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, hoveredTimelineIndex} = useContext(SuspenseTreeStateContext); // TODO: bbox does not consider uniqueSuspendersOnly filter const boundingBox = getDocumentBoundingRect(store, roots); @@ -361,13 +370,15 @@ function SuspenseRectsContainer(): React$Node { } const isRootSelected = roots.includes(inspectedElementID); + const isRootHovered = hoveredTimelineIndex === 0; return (
+ data-highlighted={isRootSelected} + data-hovered={isRootHovered}>
.SuspenseScrubberBead, -.SuspenseScrubberStep:hover > .SuspenseScrubberBead { +.SuspenseScrubberStepHighlight > .SuspenseScrubberBead { height: 0.75rem; transition: all 0.3s ease-out; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index 4712397632c11..af50a8c689cbd 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -53,10 +53,19 @@ function SuspenseTimelineInput() { switchSuspenseNode(timelineIndex); } - function handleHoverSegment(hoveredValue: number) { - // TODO: Consider highlighting the rect instead. + function handleHoverSegment(hoveredIndex: number) { + const nextSelectedSuspenseID = timeline[hoveredIndex]; + 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]; From 423c44b88611afd9bf332fd1a91b5afdca8a48be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 17 Oct 2025 18:53:30 -0400 Subject: [PATCH 6/8] [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. --- .../views/SuspenseTab/SuspenseRects.css | 19 +++++++++------ .../views/SuspenseTab/SuspenseRects.js | 23 +++++++++++++++++-- 2 files changed, 33 insertions(+), 9 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css index 69a4022307ec0..db0a84d8d87c2 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.css @@ -1,22 +1,27 @@ .SuspenseRectsContainer { padding: .25rem; - cursor: pointer; - outline-color: var(--color-transition); + outline-color: transparent; outline-style: solid; outline-width: 1px; border-radius: 0.25rem; - background-color: color-mix(in srgb, var(--color-transition) 5%, transparent); -} - -.SuspenseRectsContainer[data-hovered='true'] { - background-color: color-mix(in srgb, var(--color-transition) 15%, transparent); } .SuspenseRectsContainer[data-highlighted='true'] { + 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 { position: relative; } diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index fcbb5f5f74e01..8b171ae31a44f 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -326,7 +326,9 @@ 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, hoveredTimelineIndex} = useContext(SuspenseTreeStateContext); + const {roots, hoveredTimelineIndex, uniqueSuspendersOnly} = useContext( + SuspenseTreeStateContext, + ); // TODO: bbox does not consider uniqueSuspendersOnly filter const boundingBox = getDocumentBoundingRect(store, roots); @@ -372,9 +374,26 @@ 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; + } + } + } + return (
Date: Fri, 17 Oct 2025 18:54:53 -0400 Subject: [PATCH 7/8] [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. --- .../src/devtools/store.js | 119 +++++++++++------- .../views/SuspenseTab/SuspenseRects.js | 3 +- .../views/SuspenseTab/SuspenseTimeline.js | 10 +- .../views/SuspenseTab/SuspenseTreeContext.js | 79 +++++++----- .../src/frontend/types.js | 6 + packages/react-devtools-shared/src/utils.js | 15 +++ 6 files changed, 155 insertions(+), 77 deletions(-) diff --git a/packages/react-devtools-shared/src/devtools/store.js b/packages/react-devtools-shared/src/devtools/store.js index f1aa61bfe9b86..b75e30d9c47ec 100644 --- a/packages/react-devtools-shared/src/devtools/store.js +++ b/packages/react-devtools-shared/src/devtools/store.js @@ -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 { + ): $ReadOnlyArray { + const target: Array = []; 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, + target: Array, + uniqueSuspendersOnly: boolean, + parentEnvironments: Array, + ): 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; diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js index 8b171ae31a44f..c19360567aebe 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseRects.js @@ -154,7 +154,8 @@ function SuspenseRects({ const selected = inspectedElementID === suspenseID; const hovered = - hoveredTimelineIndex > -1 && timeline[hoveredTimelineIndex] === suspenseID; + hoveredTimelineIndex > -1 && + timeline[hoveredTimelineIndex].id === suspenseID; const boundingBox = getBoundingBox(suspense.rects); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index af50a8c689cbd..f230cfb549a3f 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -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, @@ -54,7 +54,7 @@ function SuspenseTimelineInput() { } function handleHoverSegment(hoveredIndex: number) { - const nextSelectedSuspenseID = timeline[hoveredIndex]; + const nextSelectedSuspenseID = timeline[hoveredIndex].id; suspenseTreeDispatch({ type: 'HOVER_TIMELINE_FOR_ID', payload: nextSelectedSuspenseID, @@ -68,7 +68,7 @@ function SuspenseTimelineInput() { } function skipPrevious() { - const nextSelectedSuspenseID = timeline[timelineIndex - 1]; + const nextSelectedSuspenseID = timeline[timelineIndex - 1].id; treeDispatch({ type: 'SELECT_ELEMENT_BY_ID', payload: nextSelectedSuspenseID, @@ -80,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, @@ -106,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, }); diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js index 484a336c34959..b1ba98acfb55c 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTreeContext.js @@ -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 | null, roots: $ReadOnlyArray, selectedSuspenseID: SuspenseNode['id'] | null, - timeline: $ReadOnlyArray, + timeline: $ReadOnlyArray, 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, + $ReadOnlyArray, // 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, diff --git a/packages/react-devtools-shared/src/frontend/types.js b/packages/react-devtools-shared/src/frontend/types.js index 2a012ce33a17d..4eed49e6bac8f 100644 --- a/packages/react-devtools-shared/src/frontend/types.js +++ b/packages/react-devtools-shared/src/frontend/types.js @@ -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, hasUniqueSuspenders: boolean, isSuspended: boolean, + environments: Array, }; // Serialized version of ReactIOInfo diff --git a/packages/react-devtools-shared/src/utils.js b/packages/react-devtools-shared/src/utils.js index 29ff6d566bd6f..6d31888cd9d0c 100644 --- a/packages/react-devtools-shared/src/utils.js +++ b/packages/react-devtools-shared/src/utils.js @@ -1305,3 +1305,18 @@ export function onReloadAndProfileFlagsReset(): void { sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY); sessionStorageRemoveItem(SESSION_STORAGE_RECORD_TIMELINE_KEY); } + +export function unionOfTwoArrays(a: Array, b: Array): Array { + 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; +} From 3a669170e96a63a4ce3a44f78401fb9f4f803510 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Markb=C3=A5ge?= Date: Fri, 17 Oct 2025 19:03:15 -0400 Subject: [PATCH 8/8] [DevTools] Assign a different color and label based on environment (#34893) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. Screenshot 2025-10-17 at 12 18 56 AM --- .../src/devtools/constants.js | 8 ++--- .../Components/InspectedElementSuspendedBy.js | 23 ++++++++++++-- .../SuspenseTab/SuspenseEnvironmentColors.css | 14 +++++++++ .../SuspenseTab/SuspenseEnvironmentColors.js | 20 +++++++++++++ .../views/SuspenseTab/SuspenseRects.js | 28 +++++++++++++---- .../views/SuspenseTab/SuspenseScrubber.js | 30 ++++++++++++------- .../views/SuspenseTab/SuspenseTimeline.js | 1 + 7 files changed, 102 insertions(+), 22 deletions(-) create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.css create mode 100644 packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseEnvironmentColors.js diff --git a/packages/react-devtools-shared/src/devtools/constants.js b/packages/react-devtools-shared/src/devtools/constants.js index 5c90501d70f5c..d093a798bcd30 100644 --- a/packages/react-devtools-shared/src/devtools/constants.js +++ b/packages/react-devtools-shared/src/devtools/constants.js @@ -154,8 +154,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = { '--color-warning-text-color': '#ffffff', '--color-warning-text-color-inverted': '#fd4d69', - '--color-suspense': '#0088fa', - '--color-transition': '#6a51b2', + '--color-suspense-default': '#0088fa', + '--color-transition-default': '#6a51b2', '--color-suspense-server': '#62bc6a', '--color-transition-server': '#3f7844', '--color-suspense-other': '#f3ce49', @@ -315,8 +315,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = { '--color-warning-text-color': '#ffffff', '--color-warning-text-color-inverted': '#ee1638', - '--color-suspense': '#61dafb', - '--color-transition': '#6a51b2', + '--color-suspense-default': '#61dafb', + '--color-transition-default': '#6a51b2', '--color-suspense-server': '#62bc6a', '--color-transition-server': '#3f7844', '--color-suspense-other': '#f3ce49', diff --git a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js index 9078d3c3beabc..78c137deaf37c 100644 --- a/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js +++ b/packages/react-devtools-shared/src/devtools/views/Components/InspectedElementSuspendedBy.js @@ -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, @@ -181,7 +183,12 @@ function SuspendedByRow({ )}
-
+
{pluralizedName}
{isOpen ? null : ( -
+
-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 ( , 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( - +
diff --git a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js index f230cfb549a3f..89f349ae6ea7d 100644 --- a/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js +++ b/packages/react-devtools-shared/src/devtools/views/SuspenseTab/SuspenseTimeline.js @@ -173,6 +173,7 @@ function SuspenseTimelineInput() {