Compare commits

..

10 Commits

Author SHA1 Message Date
Jorge Cabiedes Acosta
8fb9d46c73 [compiler] Prevent innaccurate derivation recording on FunctionExpressions on no-derived-computation-in-effects
Summary:
The operands of a function expression are the elements passed as context. This means that it doesn't make sense to record mutations for them.

The relevant mutations will happen in the function body, so we need to prevent FunctionExpression type instruction from running the logic for effect mutations.

This was also causing some values to depend on themselves in some cases triggering an infinite loop. Also added n invariant to prevent this issue

Test Plan:
Added fixture test
2025-11-20 10:19:46 -08:00
Sebastian "Sebbie" Silbermann
8ac5f4eb36 Fix form status reset when component state is updated (#34075)
Co-authored-by: Vordgi <sasha2822222@gmail.com>
2025-11-19 18:22:07 +01:00
Sebastian Markbåge
eb89912ee5 Add expertimental optimisticKey behind a flag (#35162)
When dealing with optimistic state, a common problem is not knowing the
id of the thing we're waiting on. Items in lists need keys (and single
items should often have keys too to reset their state). As a result you
have to generate fake keys. It's a pain to manage those and when the
real item comes in, you often end up rendering that with a different
`key` which resets the state of the component tree. That in turns works
against the grain of React and a lot of negatives fall out of it.

This adds a special `optimisticKey` symbol that can be used in place of
a `string` key.

```js
import {optimisticKey} from 'react';
...
const [optimisticItems, setOptimisticItems] = useOptimistic([]);
const children = savedItems.concat(
  optimisticItems.map(item =>
    <Item key={optimisticKey} item={item} />
  )
);
return <div>{children}</div>;
```

The semantics of this `optimisticKey` is that the assumption is that the
newly saved item will be rendered in the same slot as the previous
optimistic items. State is transferred into whatever real key ends up in
the same slot.

This might lead to some incorrect transferring of state in some cases
where things don't end up lining up - but it's worth it for simplicity
in many cases since dealing with true matching of optimistic state is
often very complex for something that only lasts a blink of an eye.

If a new item matches a `key` elsewhere in the set, then that's favored
over reconciling against the old slot.

One quirk with the current algorithm is if the `savedItems` has items
removed, then the slots won't line up by index anymore and will be
skewed. We might be able to add something where the optimistic set is
always reconciled against the end. However, it's probably better to just
assume that the set will line up perfectly and otherwise it's just best
effort that can lead to weird artifacts.

An `optimisticKey` will match itself for updates to the same slot, but
it will not match any existing slot that is not an `optimisticKey`. So
it's not an `any`, which I originally called it, because it doesn't
match existing real keys against new optimistic keys. Only one
direction.
2025-11-18 16:29:18 -05:00
Ricky
0972e23908 [compiler] Consider setter from useOptimistic non-reactive (#35141)
Closes https://github.com/facebook/react/issues/35138
2025-11-18 10:50:43 -05:00
Sebastian "Sebbie" Silbermann
194c12d949 [DevTools] Name root "Transition" when focusing on Activity (#35108) 2025-11-18 10:16:58 +01:00
Sebastian "Sebbie" Silbermann
7f1a085b28 [DevTools] Show list of named Activities in Suspense tab (#35092) 2025-11-18 09:52:44 +01:00
Joseph Savona
ea4899e13f [compiler][snap] Support pattern of files to test as CLI argument (#35148)
I've been trying out LLM agents for compiler development, and one thing
i found is that the agent naturally wants to run `yarn snap <pattern>`
to test a specific fixture, and I want to be able to tell it (directly
or in rules/skills) to do this in order to get the debug output from all
the compiler passes. Agents can figure out our current testfilter.txt
file system but that's just tedious. So here we add support for `yarn
snap -p <pattern>`. If you pass in a pattern with an extension, we
target that extension specifically. If you pass in a .expect.md file, we
look at that specific fixture. And if the pattern doesn't have
extensions, we search for `<pattern>{.js,.jsx,.ts,.tsx}`. When patterns
are enabled we automatically log as in debug mode (if there is a single
match), and disable watch mode.

Open to feedback!
2025-11-17 12:09:09 -08:00
Joseph Savona
b946a249b5 [compiler] Improve setState-in-effects rule to account for ref-gated conditionals (#35147)
Conditionally calling setState in an effect is sometimes necessary, but
should generally follow the pattern of using a "previous vaue" ref to
manually compare and ensure that the setState is idempotent. See fixture
for an example.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35147).
* #35148
* __->__ #35147
2025-11-17 12:07:43 -08:00
Joseph Savona
d6b1a0573b [compiler] Extract reusable logic for control dominators (#35146)
The next PR needs to check if a block is controlled by a value derived
from a ref.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35146).
* #35148
* #35147
* __->__ #35146
2025-11-17 12:05:52 -08:00
Joseph Savona
b315a0f713 [compiler] Fix for destructuring with mixed declaration/reassignment (#35144)
Destructing statements that start off as declarations can end up
becoming reassignments if the variable is a scope declaration, so we
have existing logic to handle cases where some parts of a destructure
need to be converted into new locals, with a reassignment to the hoisted
scope variable afterwards. However, there is an edge case where all of
the values are reassigned, in which case we don't need to rewrite and
can just set the instruction kind to reassign.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/35144).
* #35148
* #35147
* #35146
* __->__ #35144
2025-11-17 11:34:49 -08:00
57 changed files with 1733 additions and 335 deletions

View File

@@ -672,9 +672,14 @@ export const EnvironmentConfigSchema = z.object({
validateNoDynamicallyCreatedComponentsOrHooks: z.boolean().default(false),
/**
* When enabled, allows setState calls in effects when the value being set is
* derived from a ref. This is useful for patterns where initial layout measurements
* from refs need to be stored in state during mount.
* When enabled, allows setState calls in effects based on valid patterns involving refs:
* - Allow setState where the value being set is derived from a ref. This is useful where
* state needs to take into account layer information, and a layout effect reads layout
* data from a ref and sets state.
* - Allow conditionally calling setState after manually comparing previous/new values
* for changes via a ref. Relying on effect deps is insufficient for non-primitive values,
* so a ref is generally required to manually track previous values and compare prev/next
* for meaningful changes before setting state.
*/
enableAllowSetStateFromRefsInEffects: z.boolean().default(true),

View File

@@ -23,6 +23,7 @@ import {
BuiltInUseInsertionEffectHookId,
BuiltInUseLayoutEffectHookId,
BuiltInUseOperatorId,
BuiltInUseOptimisticId,
BuiltInUseReducerId,
BuiltInUseRefId,
BuiltInUseStateId,
@@ -818,6 +819,18 @@ const REACT_APIS: Array<[string, BuiltInType]> = [
returnValueKind: ValueKind.Frozen,
}),
],
[
'useOptimistic',
addHook(DEFAULT_SHAPES, {
positionalParams: [],
restParam: Effect.Freeze,
returnType: {kind: 'Object', shapeId: BuiltInUseOptimisticId},
calleeEffect: Effect.Read,
hookKind: 'useOptimistic',
returnValueKind: ValueKind.Frozen,
returnValueReason: ValueReason.State,
}),
],
[
'use',
addFunction(

View File

@@ -1887,6 +1887,18 @@ export function isStartTransitionType(id: Identifier): boolean {
);
}
export function isUseOptimisticType(id: Identifier): boolean {
return (
id.type.kind === 'Object' && id.type.shapeId === 'BuiltInUseOptimistic'
);
}
export function isSetOptimisticType(id: Identifier): boolean {
return (
id.type.kind === 'Function' && id.type.shapeId === 'BuiltInSetOptimistic'
);
}
export function isSetActionStateType(id: Identifier): boolean {
return (
id.type.kind === 'Function' && id.type.shapeId === 'BuiltInSetActionState'
@@ -1920,7 +1932,8 @@ export function isStableType(id: Identifier): boolean {
isSetActionStateType(id) ||
isDispatcherType(id) ||
isUseRefType(id) ||
isStartTransitionType(id)
isStartTransitionType(id) ||
isSetOptimisticType(id)
);
}
@@ -1931,8 +1944,9 @@ export function isStableTypeContainer(id: Identifier): boolean {
}
return (
isUseStateType(id) || // setState
type_.shapeId === 'BuiltInUseActionState' || // setActionState
isUseActionStateType(id) || // setActionState
isUseReducerType(id) || // dispatcher
isUseOptimisticType(id) || // setOptimistic
type_.shapeId === 'BuiltInUseTransition' // startTransition
);
}
@@ -1952,6 +1966,7 @@ export function evaluatesToStableTypeOrContainer(
case 'useActionState':
case 'useRef':
case 'useTransition':
case 'useOptimistic':
return true;
}
}

View File

@@ -304,6 +304,7 @@ export type HookKind =
| 'useTransition'
| 'useImperativeHandle'
| 'useEffectEvent'
| 'useOptimistic'
| 'Custom';
/*
@@ -399,6 +400,8 @@ export const BuiltInUseReducerId = 'BuiltInUseReducer';
export const BuiltInDispatchId = 'BuiltInDispatch';
export const BuiltInUseContextHookId = 'BuiltInUseContextHook';
export const BuiltInUseTransitionId = 'BuiltInUseTransition';
export const BuiltInUseOptimisticId = 'BuiltInUseOptimistic';
export const BuiltInSetOptimisticId = 'BuiltInSetOptimistic';
export const BuiltInStartTransitionId = 'BuiltInStartTransition';
export const BuiltInFireId = 'BuiltInFire';
export const BuiltInFireFunctionId = 'BuiltInFireFunction';
@@ -1186,6 +1189,25 @@ addObject(BUILTIN_SHAPES, BuiltInUseTransitionId, [
],
]);
addObject(BUILTIN_SHAPES, BuiltInUseOptimisticId, [
['0', {kind: 'Poly'}],
[
'1',
addFunction(
BUILTIN_SHAPES,
[],
{
positionalParams: [],
restParam: Effect.Freeze,
returnType: PRIMITIVE_TYPE,
calleeEffect: Effect.Read,
returnValueKind: ValueKind.Primitive,
},
BuiltInSetOptimisticId,
),
],
]);
addObject(BUILTIN_SHAPES, BuiltInUseActionStateId, [
['0', {kind: 'Poly'}],
[

View File

@@ -0,0 +1,114 @@
/**
* 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.
*/
import {BlockId, computePostDominatorTree, HIRFunction, Place} from '../HIR';
import {PostDominator} from '../HIR/Dominator';
export type ControlDominators = (id: BlockId) => boolean;
/**
* Returns an object that lazily calculates whether particular blocks are controlled
* by values of interest. Which values matter are up to the caller.
*/
export function createControlDominators(
fn: HIRFunction,
isControlVariable: (place: Place) => boolean,
): ControlDominators {
const postDominators = computePostDominatorTree(fn, {
includeThrowsAsExitNode: false,
});
const postDominatorFrontierCache = new Map<BlockId, Set<BlockId>>();
function isControlledBlock(id: BlockId): boolean {
let controlBlocks = postDominatorFrontierCache.get(id);
if (controlBlocks === undefined) {
controlBlocks = postDominatorFrontier(fn, postDominators, id);
postDominatorFrontierCache.set(id, controlBlocks);
}
for (const blockId of controlBlocks) {
const controlBlock = fn.body.blocks.get(blockId)!;
switch (controlBlock.terminal.kind) {
case 'if':
case 'branch': {
if (isControlVariable(controlBlock.terminal.test)) {
return true;
}
break;
}
case 'switch': {
if (isControlVariable(controlBlock.terminal.test)) {
return true;
}
for (const case_ of controlBlock.terminal.cases) {
if (case_.test !== null && isControlVariable(case_.test)) {
return true;
}
}
break;
}
}
}
return false;
}
return isControlledBlock;
}
/*
* Computes the post-dominator frontier of @param block. These are immediate successors of nodes that
* post-dominate @param targetId and from which execution may not reach @param block. Intuitively, these
* are the earliest blocks from which execution branches such that it may or may not reach the target block.
*/
function postDominatorFrontier(
fn: HIRFunction,
postDominators: PostDominator<BlockId>,
targetId: BlockId,
): Set<BlockId> {
const visited = new Set<BlockId>();
const frontier = new Set<BlockId>();
const targetPostDominators = postDominatorsOf(fn, postDominators, targetId);
for (const blockId of [...targetPostDominators, targetId]) {
if (visited.has(blockId)) {
continue;
}
visited.add(blockId);
const block = fn.body.blocks.get(blockId)!;
for (const pred of block.preds) {
if (!targetPostDominators.has(pred)) {
// The predecessor does not always reach this block, we found an item on the frontier!
frontier.add(pred);
}
}
}
return frontier;
}
function postDominatorsOf(
fn: HIRFunction,
postDominators: PostDominator<BlockId>,
targetId: BlockId,
): Set<BlockId> {
const result = new Set<BlockId>();
const visited = new Set<BlockId>();
const queue = [targetId];
while (queue.length) {
const currentId = queue.shift()!;
if (visited.has(currentId)) {
continue;
}
visited.add(currentId);
const current = fn.body.blocks.get(currentId)!;
for (const pred of current.preds) {
const predPostDominator = postDominators.get(pred) ?? pred;
if (predPostDominator === targetId || result.has(predPostDominator)) {
result.add(pred);
}
queue.push(pred);
}
}
return result;
}

View File

@@ -7,7 +7,6 @@
import {CompilerError} from '..';
import {
BlockId,
Effect,
Environment,
HIRFunction,
@@ -15,14 +14,12 @@ import {
IdentifierId,
Instruction,
Place,
computePostDominatorTree,
evaluatesToStableTypeOrContainer,
getHookKind,
isStableType,
isStableTypeContainer,
isUseOperator,
} from '../HIR';
import {PostDominator} from '../HIR/Dominator';
import {
eachInstructionLValue,
eachInstructionOperand,
@@ -35,6 +32,7 @@ import {
} from '../ReactiveScopes/InferReactiveScopeVariables';
import DisjointSet from '../Utils/DisjointSet';
import {assertExhaustive} from '../Utils/utils';
import {createControlDominators} from './ControlDominators';
/**
* Side map to track and propagate sources of stability (i.e. hook calls such as
@@ -212,45 +210,9 @@ export function inferReactivePlaces(fn: HIRFunction): void {
reactiveIdentifiers.markReactive(place);
}
const postDominators = computePostDominatorTree(fn, {
includeThrowsAsExitNode: false,
});
const postDominatorFrontierCache = new Map<BlockId, Set<BlockId>>();
function isReactiveControlledBlock(id: BlockId): boolean {
let controlBlocks = postDominatorFrontierCache.get(id);
if (controlBlocks === undefined) {
controlBlocks = postDominatorFrontier(fn, postDominators, id);
postDominatorFrontierCache.set(id, controlBlocks);
}
for (const blockId of controlBlocks) {
const controlBlock = fn.body.blocks.get(blockId)!;
switch (controlBlock.terminal.kind) {
case 'if':
case 'branch': {
if (reactiveIdentifiers.isReactive(controlBlock.terminal.test)) {
return true;
}
break;
}
case 'switch': {
if (reactiveIdentifiers.isReactive(controlBlock.terminal.test)) {
return true;
}
for (const case_ of controlBlock.terminal.cases) {
if (
case_.test !== null &&
reactiveIdentifiers.isReactive(case_.test)
) {
return true;
}
}
break;
}
}
}
return false;
}
const isReactiveControlledBlock = createControlDominators(fn, place =>
reactiveIdentifiers.isReactive(place),
);
do {
for (const [, block] of fn.body.blocks) {
@@ -411,61 +373,6 @@ export function inferReactivePlaces(fn: HIRFunction): void {
propagateReactivityToInnerFunctions(fn, true);
}
/*
* Computes the post-dominator frontier of @param block. These are immediate successors of nodes that
* post-dominate @param targetId and from which execution may not reach @param block. Intuitively, these
* are the earliest blocks from which execution branches such that it may or may not reach the target block.
*/
function postDominatorFrontier(
fn: HIRFunction,
postDominators: PostDominator<BlockId>,
targetId: BlockId,
): Set<BlockId> {
const visited = new Set<BlockId>();
const frontier = new Set<BlockId>();
const targetPostDominators = postDominatorsOf(fn, postDominators, targetId);
for (const blockId of [...targetPostDominators, targetId]) {
if (visited.has(blockId)) {
continue;
}
visited.add(blockId);
const block = fn.body.blocks.get(blockId)!;
for (const pred of block.preds) {
if (!targetPostDominators.has(pred)) {
// The predecessor does not always reach this block, we found an item on the frontier!
frontier.add(pred);
}
}
}
return frontier;
}
function postDominatorsOf(
fn: HIRFunction,
postDominators: PostDominator<BlockId>,
targetId: BlockId,
): Set<BlockId> {
const result = new Set<BlockId>();
const visited = new Set<BlockId>();
const queue = [targetId];
while (queue.length) {
const currentId = queue.shift()!;
if (visited.has(currentId)) {
continue;
}
visited.add(currentId);
const current = fn.body.blocks.get(currentId)!;
for (const pred of current.preds) {
const predPostDominator = postDominators.get(pred) ?? pred;
if (predPostDominator === targetId || result.has(predPostDominator)) {
result.add(pred);
}
queue.push(pred);
}
}
return result;
}
class ReactivityMap {
hasChanges: boolean = false;
reactive: Set<IdentifierId> = new Set();

View File

@@ -52,6 +52,8 @@ type ValidationContext = {
readonly setStateUsages: Map<IdentifierId, Set<SourceLocation>>;
};
const MAX_FIXPOINT_ITERATIONS = 100;
class DerivationCache {
hasChanges: boolean = false;
cache: Map<IdentifierId, DerivationMetadata> = new Map();
@@ -224,6 +226,7 @@ export function validateNoDerivedComputationsInEffects_exp(
}
let isFirstPass = true;
let iterationCount = 0;
do {
context.derivationCache.takeSnapshot();
@@ -236,6 +239,19 @@ export function validateNoDerivedComputationsInEffects_exp(
context.derivationCache.checkForChanges();
isFirstPass = false;
iterationCount++;
CompilerError.invariant(iterationCount < MAX_FIXPOINT_ITERATIONS, {
reason:
'[ValidateNoDerivedComputationsInEffects] Fixpoint iteration failed to converge.',
description: `Fixpoint iteration exceeded ${MAX_FIXPOINT_ITERATIONS} iterations while tracking derivations. This suggests a cyclic dependency in the derivation cache.`,
details: [
{
kind: 'error',
loc: fn.loc,
message: `Exceeded ${MAX_FIXPOINT_ITERATIONS} iterations in ValidateNoDerivedComputationsInEffects`,
},
],
});
} while (context.derivationCache.snapshot());
for (const [, effect] of effectsCache) {
@@ -422,6 +438,14 @@ function recordInstructionDerivations(
);
}
if (value.kind === 'FunctionExpression') {
/*
* We don't want to record effect mutations of FunctionExpressions the mutations will happen in the
* function body and we will record them there.
*/
return;
}
for (const operand of eachInstructionOperand(instr)) {
switch (operand.effect) {
case Effect.Capture:
@@ -512,6 +536,19 @@ function buildTreeNode(
const namedSiblings: Set<string> = new Set();
for (const childId of sourceMetadata.sourcesIds) {
CompilerError.invariant(childId !== sourceId, {
reason:
'Unexpected self-reference: a value should not have itself as a source',
description: null,
details: [
{
kind: 'error',
loc: sourceMetadata.place.loc,
message: null,
},
],
});
const childNodes = buildTreeNode(
childId,
context,

View File

@@ -21,13 +21,17 @@ import {
isUseRefType,
isRefValueType,
Place,
Effect,
BlockId,
} from '../HIR';
import {
eachInstructionLValue,
eachInstructionValueOperand,
} from '../HIR/visitors';
import {createControlDominators} from '../Inference/ControlDominators';
import {isMutable} from '../ReactiveScopes/InferReactiveScopeVariables';
import {Result} from '../Utils/Result';
import {Iterable_some} from '../Utils/utils';
import {assertExhaustive, Iterable_some} from '../Utils/utils';
/**
* Validates against calling setState in the body of an effect (useEffect and friends),
@@ -140,6 +144,8 @@ function getSetStateCall(
setStateFunctions: Map<IdentifierId, Place>,
env: Environment,
): Place | null {
const enableAllowSetStateFromRefsInEffects =
env.config.enableAllowSetStateFromRefsInEffects;
const refDerivedValues: Set<IdentifierId> = new Set();
const isDerivedFromRef = (place: Place): boolean => {
@@ -150,9 +156,38 @@ function getSetStateCall(
);
};
const isRefControlledBlock: (id: BlockId) => boolean =
enableAllowSetStateFromRefsInEffects
? createControlDominators(fn, place => isDerivedFromRef(place))
: (): boolean => false;
for (const [, block] of fn.body.blocks) {
if (enableAllowSetStateFromRefsInEffects) {
for (const phi of block.phis) {
if (isDerivedFromRef(phi.place)) {
continue;
}
let isPhiDerivedFromRef = false;
for (const [, operand] of phi.operands) {
if (isDerivedFromRef(operand)) {
isPhiDerivedFromRef = true;
break;
}
}
if (isPhiDerivedFromRef) {
refDerivedValues.add(phi.place.identifier.id);
} else {
for (const [pred] of phi.operands) {
if (isRefControlledBlock(pred)) {
refDerivedValues.add(phi.place.identifier.id);
break;
}
}
}
}
}
for (const instr of block.instructions) {
if (env.config.enableAllowSetStateFromRefsInEffects) {
if (enableAllowSetStateFromRefsInEffects) {
const hasRefOperand = Iterable_some(
eachInstructionValueOperand(instr.value),
isDerivedFromRef,
@@ -162,6 +197,46 @@ function getSetStateCall(
for (const lvalue of eachInstructionLValue(instr)) {
refDerivedValues.add(lvalue.identifier.id);
}
// Ref-derived values can also propagate through mutation
for (const operand of eachInstructionValueOperand(instr.value)) {
switch (operand.effect) {
case Effect.Capture:
case Effect.Store:
case Effect.ConditionallyMutate:
case Effect.ConditionallyMutateIterator:
case Effect.Mutate: {
if (isMutable(instr, operand)) {
refDerivedValues.add(operand.identifier.id);
}
break;
}
case Effect.Freeze:
case Effect.Read: {
// no-op
break;
}
case Effect.Unknown: {
CompilerError.invariant(false, {
reason: 'Unexpected unknown effect',
description: null,
details: [
{
kind: 'error',
loc: operand.loc,
message: null,
},
],
suggestions: null,
});
}
default: {
assertExhaustive(
operand.effect,
`Unexpected effect kind \`${operand.effect}\``,
);
}
}
}
}
if (
@@ -203,7 +278,7 @@ function getSetStateCall(
isSetStateType(callee.identifier) ||
setStateFunctions.has(callee.identifier.id)
) {
if (env.config.enableAllowSetStateFromRefsInEffects) {
if (enableAllowSetStateFromRefsInEffects) {
const arg = instr.value.args.at(0);
if (
arg !== undefined &&
@@ -216,6 +291,8 @@ function getSetStateCall(
* be needed when initial layout measurements from refs need to be stored in state.
*/
return null;
} else if (isRefControlledBlock(block.id)) {
continue;
}
}
/*

View File

@@ -0,0 +1,115 @@
## Input
```javascript
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component() {
const [foo, setFoo] = useState({});
const [bar, setBar] = useState(new Set());
/*
* isChanged is considered context of the effect's function expression,
* if we don't bail out of effect mutation derivation tracking, isChanged
* will inherit the sources of the effect's function expression.
*
* This is innacurate and with the multiple passes ends up causing an infinite loop.
*/
useEffect(() => {
let isChanged = false;
const newData = foo.map(val => {
bar.someMethod(val);
isChanged = true;
});
if (isChanged) {
setFoo(newData);
}
}, [foo, bar]);
return (
<div>
{foo}, {bar}
</div>
);
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component() {
const $ = _c(9);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = {};
$[0] = t0;
} else {
t0 = $[0];
}
const [foo, setFoo] = useState(t0);
let t1;
if ($[1] === Symbol.for("react.memo_cache_sentinel")) {
t1 = new Set();
$[1] = t1;
} else {
t1 = $[1];
}
const [bar] = useState(t1);
let t2;
let t3;
if ($[2] !== bar || $[3] !== foo) {
t2 = () => {
let isChanged = false;
const newData = foo.map((val) => {
bar.someMethod(val);
isChanged = true;
});
if (isChanged) {
setFoo(newData);
}
};
t3 = [foo, bar];
$[2] = bar;
$[3] = foo;
$[4] = t2;
$[5] = t3;
} else {
t2 = $[4];
t3 = $[5];
}
useEffect(t2, t3);
let t4;
if ($[6] !== bar || $[7] !== foo) {
t4 = (
<div>
{foo}, {bar}
</div>
);
$[6] = bar;
$[7] = foo;
$[8] = t4;
} else {
t4 = $[8];
}
return t4;
}
```
## Logs
```
{"kind":"CompileError","detail":{"options":{"description":"Using an effect triggers an additional render which can hurt performance and user experience, potentially briefly showing stale values to the user\n\nThis setState call is setting a derived value that depends on the following reactive sources:\n\nState: [foo, bar]\n\nData Flow Tree:\n└── newData\n ├── foo (State)\n └── bar (State)\n\nSee: https://react.dev/learn/you-might-not-need-an-effect#updating-state-based-on-props-or-state","category":"EffectDerivationsOfState","reason":"You might not need an effect. Derive values in render, not effects.","details":[{"kind":"error","loc":{"start":{"line":23,"column":6,"index":663},"end":{"line":23,"column":12,"index":669},"filename":"function-expression-mutation-edge-case.ts","identifierName":"setFoo"},"message":"This should be computed during render, not in an effect"}]}},"fnLoc":null}
{"kind":"CompileSuccess","fnLoc":{"start":{"line":3,"column":0,"index":64},"end":{"line":32,"column":1,"index":762},"filename":"function-expression-mutation-edge-case.ts"},"fnName":"Component","memoSlots":9,"memoBlocks":4,"memoValues":5,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -0,0 +1,32 @@
// @validateNoDerivedComputationsInEffects_exp @loggerTestOnly
function Component() {
const [foo, setFoo] = useState({});
const [bar, setBar] = useState(new Set());
/*
* isChanged is considered context of the effect's function expression,
* if we don't bail out of effect mutation derivation tracking, isChanged
* will inherit the sources of the effect's function expression.
*
* This is innacurate and with the multiple passes ends up causing an infinite loop.
*/
useEffect(() => {
let isChanged = false;
const newData = foo.map(val => {
bar.someMethod(val);
isChanged = true;
});
if (isChanged) {
setFoo(newData);
}
}, [foo, bar]);
return (
<div>
{foo}, {bar}
</div>
);
}

View File

@@ -0,0 +1,91 @@
## Input
```javascript
// @validatePreserveExistingMemoizationGuarantees
import {
useCallback,
useTransition,
useState,
useOptimistic,
useActionState,
useRef,
useReducer,
} from 'react';
function useFoo() {
const [s, setState] = useState();
const ref = useRef(null);
const [t, startTransition] = useTransition();
const [u, addOptimistic] = useOptimistic();
const [v, dispatch] = useReducer(() => {}, null);
const [isPending, dispatchAction] = useActionState(() => {}, null);
return useCallback(() => {
dispatch();
startTransition(() => {});
addOptimistic();
setState(null);
dispatchAction();
ref.current = true;
}, []);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees
import {
useCallback,
useTransition,
useState,
useOptimistic,
useActionState,
useRef,
useReducer,
} from "react";
function useFoo() {
const $ = _c(1);
const [, setState] = useState();
const ref = useRef(null);
const [, startTransition] = useTransition();
const [, addOptimistic] = useOptimistic();
const [, dispatch] = useReducer(_temp, null);
const [, dispatchAction] = useActionState(_temp2, null);
let t0;
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
t0 = () => {
dispatch();
startTransition(_temp3);
addOptimistic();
setState(null);
dispatchAction();
ref.current = true;
};
$[0] = t0;
} else {
t0 = $[0];
}
return t0;
}
function _temp3() {}
function _temp2() {}
function _temp() {}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};
```
### Eval output
(kind: ok) "[[ function params=0 ]]"

View File

@@ -0,0 +1,33 @@
// @validatePreserveExistingMemoizationGuarantees
import {
useCallback,
useTransition,
useState,
useOptimistic,
useActionState,
useRef,
useReducer,
} from 'react';
function useFoo() {
const [s, setState] = useState();
const ref = useRef(null);
const [t, startTransition] = useTransition();
const [u, addOptimistic] = useOptimistic();
const [v, dispatch] = useReducer(() => {}, null);
const [isPending, dispatchAction] = useActionState(() => {}, null);
return useCallback(() => {
dispatch();
startTransition(() => {});
addOptimistic();
setState(null);
dispatchAction();
ref.current = true;
}, []);
}
export const FIXTURE_ENTRYPOINT = {
fn: useFoo,
params: [],
};

View File

@@ -0,0 +1,117 @@
## Input
```javascript
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
import {useState, useRef, useEffect} from 'react';
function Component({x, y}) {
const previousXRef = useRef(null);
const previousYRef = useRef(null);
const [data, setData] = useState(null);
useEffect(() => {
const previousX = previousXRef.current;
previousXRef.current = x;
const previousY = previousYRef.current;
previousYRef.current = y;
if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
const data = load({x, y});
setData(data);
}
}, [x, y]);
return data;
}
function areEqual(a, b) {
return a === b;
}
function load({x, y}) {
return x * y;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 0, y: 0}],
sequentialRenders: [
{x: 0, y: 0},
{x: 1, y: 0},
{x: 1, y: 1},
],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime"; // @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
import { useState, useRef, useEffect } from "react";
function Component(t0) {
const $ = _c(4);
const { x, y } = t0;
const previousXRef = useRef(null);
const previousYRef = useRef(null);
const [data, setData] = useState(null);
let t1;
let t2;
if ($[0] !== x || $[1] !== y) {
t1 = () => {
const previousX = previousXRef.current;
previousXRef.current = x;
const previousY = previousYRef.current;
previousYRef.current = y;
if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
const data_0 = load({ x, y });
setData(data_0);
}
};
t2 = [x, y];
$[0] = x;
$[1] = y;
$[2] = t1;
$[3] = t2;
} else {
t1 = $[2];
t2 = $[3];
}
useEffect(t1, t2);
return data;
}
function areEqual(a, b) {
return a === b;
}
function load({ x, y }) {
return x * y;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: 0, y: 0 }],
sequentialRenders: [
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
],
};
```
## Logs
```
{"kind":"CompileSuccess","fnLoc":{"start":{"line":4,"column":0,"index":163},"end":{"line":22,"column":1,"index":631},"filename":"valid-setState-in-useEffect-controlled-by-ref-value.ts"},"fnName":"Component","memoSlots":4,"memoBlocks":1,"memoValues":2,"prunedMemoBlocks":0,"prunedMemoValues":0}
```
### Eval output
(kind: ok) 0
0
1

View File

@@ -0,0 +1,40 @@
// @validateNoSetStateInEffects @enableAllowSetStateFromRefsInEffects @loggerTestOnly @compilationMode:"infer"
import {useState, useRef, useEffect} from 'react';
function Component({x, y}) {
const previousXRef = useRef(null);
const previousYRef = useRef(null);
const [data, setData] = useState(null);
useEffect(() => {
const previousX = previousXRef.current;
previousXRef.current = x;
const previousY = previousYRef.current;
previousYRef.current = y;
if (!areEqual(x, previousX) || !areEqual(y, previousY)) {
const data = load({x, y});
setData(data);
}
}, [x, y]);
return data;
}
function areEqual(a, b) {
return a === b;
}
function load({x, y}) {
return x * y;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 0, y: 0}],
sequentialRenders: [
{x: 0, y: 0},
{x: 1, y: 0},
{x: 1, y: 1},
],
};

View File

@@ -44,6 +44,21 @@ function stripExtension(filename: string, extensions: Array<string>): string {
return filename;
}
/**
* Strip all extensions from a filename
* e.g., "foo.expect.md" -> "foo"
*/
function stripAllExtensions(filename: string): string {
let result = filename;
while (true) {
const extension = path.extname(result);
if (extension === '') {
return result;
}
result = path.basename(result, extension);
}
}
export async function readTestFilter(): Promise<TestFilter | null> {
if (!(await exists(FILTER_PATH))) {
throw new Error(`testfilter file not found at \`${FILTER_PATH}\``);
@@ -111,11 +126,25 @@ async function readInputFixtures(
} else {
inputFiles = (
await Promise.all(
filter.paths.map(pattern =>
glob.glob(`${pattern}{${INPUT_EXTENSIONS.join(',')}}`, {
filter.paths.map(pattern => {
// If the pattern already has an extension other than .expect.md,
// search for the pattern directly. Otherwise, search for the
// pattern with the expected input extensions added.
// Eg
// `alias-while` => search for `alias-while{.js,.jsx,.ts,.tsx}`
// `alias-while.js` => search as-is
// `alias-while.expect.md` => search for `alias-while{.js,.jsx,.ts,.tsx}`
const basename = path.basename(pattern);
const basenameWithoutExt = stripAllExtensions(basename);
const hasExtension = basename !== basenameWithoutExt;
const globPattern =
hasExtension && !pattern.endsWith(SNAPSHOT_EXTENSION)
? pattern
: `${basenameWithoutExt}{${INPUT_EXTENSIONS.join(',')}}`;
return glob.glob(globPattern, {
cwd: rootDir,
}),
),
});
}),
)
).flat();
}
@@ -150,11 +179,13 @@ async function readOutputFixtures(
} else {
outputFiles = (
await Promise.all(
filter.paths.map(pattern =>
glob.glob(`${pattern}${SNAPSHOT_EXTENSION}`, {
filter.paths.map(pattern => {
// Strip all extensions and find matching .expect.md files
const basenameWithoutExt = stripAllExtensions(pattern);
return glob.glob(`${basenameWithoutExt}${SNAPSHOT_EXTENSION}`, {
cwd: rootDir,
}),
),
});
}),
)
).flat();
}

View File

@@ -35,6 +35,7 @@ type RunnerOptions = {
watch: boolean;
filter: boolean;
update: boolean;
pattern?: string;
};
const opts: RunnerOptions = yargs
@@ -62,9 +63,15 @@ const opts: RunnerOptions = yargs
'Only run fixtures which match the contents of testfilter.txt',
)
.default('filter', false)
.string('pattern')
.alias('p', 'pattern')
.describe(
'pattern',
'Optional glob pattern to filter fixtures (e.g., "error.*", "use-memo")',
)
.help('help')
.strict()
.parseSync(hideBin(process.argv));
.parseSync(hideBin(process.argv)) as RunnerOptions;
/**
* Do a test run and return the test results
@@ -171,7 +178,13 @@ export async function main(opts: RunnerOptions): Promise<void> {
worker.getStderr().pipe(process.stderr);
worker.getStdout().pipe(process.stdout);
if (opts.watch) {
// If pattern is provided, force watch mode off and use pattern filter
const shouldWatch = opts.watch && opts.pattern == null;
if (opts.watch && opts.pattern != null) {
console.warn('NOTE: --watch is ignored when a --pattern is supplied');
}
if (shouldWatch) {
makeWatchRunner(state => onChange(worker, state), opts.filter);
if (opts.filter) {
/**
@@ -216,7 +229,18 @@ export async function main(opts: RunnerOptions): Promise<void> {
try {
execSync('yarn build', {cwd: PROJECT_ROOT});
console.log('Built compiler successfully with tsup');
const testFilter = opts.filter ? await readTestFilter() : null;
// Determine which filter to use
let testFilter: TestFilter | null = null;
if (opts.pattern) {
testFilter = {
debug: true,
paths: [opts.pattern],
};
} else if (opts.filter) {
testFilter = await readTestFilter();
}
const results = await runFixtures(worker, testFilter, 0);
if (opts.update) {
update(results);

View File

@@ -3884,4 +3884,19 @@ describe('ReactFlight', () => {
</main>,
);
});
// @gate enableOptimisticKey
it('collapses optimistic keys to an optimistic key', async () => {
function Bar({text}) {
return <div />;
}
function Foo() {
return <Bar key={ReactServer.optimisticKey} />;
}
const transport = ReactNoopFlightServer.render({
element: <Foo key="Outer Key" />,
});
const model = await ReactNoopFlightClient.read(transport);
expect(model.element.key).toBe(React.optimisticKey);
});
});

View File

@@ -120,6 +120,7 @@ import {
MEMO_SYMBOL_STRING,
SERVER_CONTEXT_SYMBOL_STRING,
LAZY_SYMBOL_STRING,
REACT_OPTIMISTIC_KEY,
} from '../shared/ReactSymbols';
import {enableStyleXFeatures} from 'react-devtools-feature-flags';
@@ -4849,7 +4850,10 @@ export function attach(
}
let previousSiblingOfBestMatch = null;
let bestMatch = remainingReconcilingChildren;
if (componentInfo.key != null) {
if (
componentInfo.key != null &&
componentInfo.key !== REACT_OPTIMISTIC_KEY
) {
// If there is a key try to find a matching key in the set.
bestMatch = remainingReconcilingChildren;
while (bestMatch !== null) {
@@ -6145,7 +6149,7 @@ export function attach(
return {
displayName: getDisplayNameForFiber(fiber) || 'Anonymous',
id: instance.id,
key: fiber.key,
key: fiber.key === REACT_OPTIMISTIC_KEY ? null : fiber.key,
env: null,
stack:
fiber._debugOwner == null || fiber._debugStack == null
@@ -6158,7 +6162,11 @@ export function attach(
return {
displayName: componentInfo.name || 'Anonymous',
id: instance.id,
key: componentInfo.key == null ? null : componentInfo.key,
key:
componentInfo.key == null ||
componentInfo.key === REACT_OPTIMISTIC_KEY
? null
: componentInfo.key,
env: componentInfo.env == null ? null : componentInfo.env,
stack:
componentInfo.owner == null || componentInfo.debugStack == null
@@ -7082,7 +7090,7 @@ export function attach(
// Does the component have legacy context attached to it.
hasLegacyContext,
key: key != null ? key : null,
key: key != null && key !== REACT_OPTIMISTIC_KEY ? key : null,
type: elementType,
@@ -8641,7 +8649,7 @@ export function attach(
}
return {
displayName,
key,
key: key === REACT_OPTIMISTIC_KEY ? null : key,
index,
};
}
@@ -8649,7 +8657,11 @@ export function attach(
function getVirtualPathFrame(virtualInstance: VirtualInstance): PathFrame {
return {
displayName: virtualInstance.data.name || '',
key: virtualInstance.data.key == null ? null : virtualInstance.data.key,
key:
virtualInstance.data.key == null ||
virtualInstance.data.key === REACT_OPTIMISTIC_KEY
? null
: virtualInstance.data.key,
index: -1, // We use -1 to indicate that this is a virtual path frame.
};
}

View File

@@ -72,3 +72,9 @@ export const SERVER_CONTEXT_DEFAULT_VALUE_NOT_LOADED_SYMBOL_STRING =
export const REACT_MEMO_CACHE_SENTINEL: symbol = Symbol.for(
'react.memo_cache_sentinel',
);
import type {ReactOptimisticKey} from 'shared/ReactTypes';
export const REACT_OPTIMISTIC_KEY: ReactOptimisticKey = (Symbol.for(
'react.optimistic_key',
): any);

View File

@@ -189,6 +189,8 @@ export default class Store extends EventEmitter<{
{errorCount: number, warningCount: number},
> = new Map();
_focusedTransition: 0 | Element['id'] = 0;
// At least one of the injected renderers contains (DEV only) owner metadata.
_hasOwnerMetadata: boolean = false;
@@ -935,10 +937,9 @@ export default class Store extends EventEmitter<{
}
/**
* @param rootID
* @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
*/
getSuspendableDocumentOrderSuspense(
getSuspendableDocumentOrderSuspenseInitialPaint(
uniqueSuspendersOnly: boolean,
): Array<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
@@ -990,6 +991,76 @@ export default class Store extends EventEmitter<{
return target;
}
_pushSuspenseChildrenInDocumentOrder(
children: Array<Element['id']>,
target: Array<SuspenseNode['id']>,
): void {
for (let i = 0; i < children.length; i++) {
const childID = children[i];
const suspense = this.getSuspenseByID(childID);
if (suspense !== null) {
target.push(suspense.id);
} else {
const childElement = this.getElementByID(childID);
if (childElement !== null) {
this._pushSuspenseChildrenInDocumentOrder(
childElement.children,
target,
);
}
}
}
}
getSuspenseChildren(id: Element['id']): Array<SuspenseNode['id']> {
const transitionChildren: Array<SuspenseNode['id']> = [];
const root = this._idToElement.get(id);
if (root === undefined) {
return transitionChildren;
}
this._pushSuspenseChildrenInDocumentOrder(
root.children,
transitionChildren,
);
return transitionChildren;
}
/**
* @param uniqueSuspendersOnly Filters out boundaries without unique suspenders
*/
getSuspendableDocumentOrderSuspenseTransition(
uniqueSuspendersOnly: boolean,
): Array<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
const focusedTransitionID = this._focusedTransition;
if (focusedTransitionID === null) {
return target;
}
target.push({
id: focusedTransitionID,
// TODO: Get environment for Activity
environment: null,
endTime: 0,
});
const transitionChildren = this.getSuspenseChildren(focusedTransitionID);
this.pushTimelineStepsInDocumentOrder(
transitionChildren,
target,
uniqueSuspendersOnly,
// TODO: Get environment for Activity
[],
0, // Don't pass a minimum end time at the root. The root is always first so doesn't matter.
);
return target;
}
pushTimelineStepsInDocumentOrder(
children: Array<SuspenseNode['id']>,
target: Array<SuspenseTimelineStep>,
@@ -1045,7 +1116,14 @@ export default class Store extends EventEmitter<{
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseTimelineStep> {
const timeline =
this.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
this._focusedTransition === 0
? this.getSuspendableDocumentOrderSuspenseInitialPaint(
uniqueSuspendersOnly,
)
: this.getSuspendableDocumentOrderSuspenseTransition(
uniqueSuspendersOnly,
);
if (timeline.length === 0) {
return timeline;
}
@@ -1058,6 +1136,33 @@ export default class Store extends EventEmitter<{
return timeline;
}
getActivities(): Array<{id: Element['id'], depth: number}> {
const target: Array<{id: Element['id'], depth: number}> = [];
// TODO: Keep a live tree in the backend so we don't need to recalculate
// this each time while also including filtered Activities.
this._pushActivitiesInDocumentOrder(this.roots, target, 0);
return target;
}
_pushActivitiesInDocumentOrder(
children: $ReadOnlyArray<Element['id']>,
target: Array<{id: Element['id'], depth: number}>,
depth: number,
): void {
for (let i = 0; i < children.length; i++) {
const child = this._idToElement.get(children[i]);
if (child === undefined) {
continue;
}
if (child.type === ElementTypeActivity && child.nameProp !== null) {
target.push({id: child.id, depth});
this._pushActivitiesInDocumentOrder(child.children, target, depth + 1);
} else {
this._pushActivitiesInDocumentOrder(child.children, target, depth);
}
}
}
getRendererIDForElement(id: number): number | null {
let current = this._idToElement.get(id);
while (current !== undefined) {
@@ -1244,7 +1349,7 @@ export default class Store extends EventEmitter<{
const removedElementIDs: Map<number, number> = new Map();
const removedSuspenseIDs: Map<SuspenseNode['id'], SuspenseNode['id']> =
new Map();
let nextActivitySliceID = null;
let nextActivitySliceID: Element['id'] | null = null;
let i = 2;
@@ -2119,6 +2224,10 @@ export default class Store extends EventEmitter<{
}
}
if (nextActivitySliceID !== null) {
this._focusedTransition = nextActivitySliceID;
}
this.emit('mutated', [
addedElementIDs,
removedElementIDs,

View File

@@ -59,6 +59,7 @@ export type StateContext = {
// Activity slice
activityID: Element['id'] | null,
activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
// Inspection element panel
inspectedElementID: number | null,
@@ -172,6 +173,7 @@ type State = {
// Activity slice
activityID: Element['id'] | null,
activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
// Inspection element panel
inspectedElementID: number | null,
@@ -809,6 +811,7 @@ function reduceActivityState(
case 'HANDLE_STORE_MUTATION':
let {activityID} = state;
const [, , activitySliceIDChange] = action.payload;
const activities = store.getActivities();
if (activitySliceIDChange === 0 && activityID !== null) {
activityID = null;
} else if (
@@ -817,10 +820,11 @@ function reduceActivityState(
) {
activityID = activitySliceIDChange;
}
if (activityID !== state.activityID) {
if (activityID !== state.activityID || activities !== state.activities) {
return {
...state,
activityID,
activities,
};
}
}
@@ -863,6 +867,7 @@ function getInitialState({
// Activity slice
activityID: null,
activities: store.getActivities(),
// Inspection element panel
inspectedElementID:

View File

@@ -1,20 +1,33 @@
.ActivityList {
.ActivityListContaier {
display: flex;
flex-direction: column;
}
.ActivityListHeader {
/* even if empty, provides layout alignment with the main view */
display: flex;
flex: 0 0 42px;
border-bottom: 1px solid var(--color-border);
}
.ActivityListList {
cursor: default;
list-style-type: none;
margin: 0;
padding: 0;
}
.ActivityList[data-pending-activity-slice-selection="true"] {
.ActivityListList[data-pending-activity-slice-selection="true"] {
cursor: wait;
}
.ActivityList:focus {
.ActivityListList:focus {
outline: none;
}
.ActivityListItem {
color: var(--color-component-name);
line-height: var(--line-height-data);
padding: 0 0.25rem;
user-select: none;
}
@@ -27,7 +40,7 @@
background-color: var(--color-background-inactive);
}
.ActivityList:focus .ActivityListItem[aria-selected="true"] {
.ActivityListList:focus .ActivityListItem[aria-selected="true"] {
background-color: var(--color-background-selected);
color: var(--color-text-selected);

View File

@@ -15,10 +15,14 @@ import typeof {
SyntheticMouseEvent,
SyntheticKeyboardEvent,
} from 'react-dom-bindings/src/events/SyntheticEvent';
import type Store from 'react-devtools-shared/src/devtools/store';
import * as React from 'react';
import {useContext, useTransition} from 'react';
import {ComponentFilterActivitySlice} from 'react-devtools-shared/src/frontend/types';
import {useContext, useMemo, useTransition} from 'react';
import {
ComponentFilterActivitySlice,
ElementTypeActivity,
} from 'react-devtools-shared/src/frontend/types';
import styles from './ActivityList.css';
import {
TreeStateContext,
@@ -26,6 +30,8 @@ import {
} from '../Components/TreeContext';
import {useHighlightHostInstance} from '../hooks';
import {StoreContext} from '../context';
import ButtonIcon from '../ButtonIcon';
import Button from '../Button';
export function useChangeActivitySliceAction(): (
id: Element['id'] | null,
@@ -62,15 +68,49 @@ export function useChangeActivitySliceAction(): (
return changeActivitySliceAction;
}
function findNearestActivityParentID(
elementID: Element['id'],
store: Store,
): Element['id'] | null {
let currentID: null | Element['id'] = elementID;
while (currentID !== null) {
const element = store.getElementByID(currentID);
if (element === null) {
return null;
}
if (element.type === ElementTypeActivity) {
return element.id;
}
currentID = element.parentID;
}
return currentID;
}
function useSelectedActivityID(): Element['id'] | null {
const {inspectedElementID} = useContext(TreeStateContext);
const store = useContext(StoreContext);
return useMemo(() => {
if (inspectedElementID === null) {
return null;
}
const nearestActivityID = findNearestActivityParentID(
inspectedElementID,
store,
);
return nearestActivityID;
}, [inspectedElementID, store]);
}
export default function ActivityList({
activities,
}: {
activities: $ReadOnlyArray<Element>,
activities: $ReadOnlyArray<{id: Element['id'], depth: number}>,
}): React$Node {
const {inspectedElementID} = useContext(TreeStateContext);
const {activityID, inspectedElementID} = useContext(TreeStateContext);
const treeDispatch = useContext(TreeDispatcherContext);
// TODO: Derive from inspected element
const selectedActivityID = inspectedElementID;
const store = useContext(StoreContext);
const selectedActivityID = useSelectedActivityID();
const {highlightHostInstance, clearHighlightHostInstance} =
useHighlightHostInstance();
@@ -79,8 +119,13 @@ export default function ActivityList({
const changeActivitySliceAction = useChangeActivitySliceAction();
function handleKeyDown(event: SyntheticKeyboardEvent) {
// TODO: Implement keyboard navigation
switch (event.key) {
case 'Escape':
startActivitySliceSelection(() => {
changeActivitySliceAction(null);
});
event.preventDefault();
break;
case 'Enter':
case ' ':
if (inspectedElementID !== null) {
@@ -149,25 +194,61 @@ export default function ActivityList({
}
return (
<ol
role="listbox"
className={styles.ActivityList}
data-pending-activity-slice-selection={isPendingActivitySliceSelection}
tabIndex={0}
onKeyDown={handleKeyDown}>
{activities.map(activity => (
<li
key={activity.id}
role="option"
aria-selected={activity.id === selectedActivityID ? 'true' : 'false'}
className={styles.ActivityListItem}
onClick={handleClick.bind(null, activity.id)}
onDoubleClick={handleDoubleClick}
onPointerOver={highlightHostInstance.bind(null, activity.id, false)}
onPointerLeave={clearHighlightHostInstance}>
{activity.nameProp}
</li>
))}
</ol>
<div className={styles.ActivityListContaier}>
<div className={styles.ActivityListHeader}>
{activityID !== null && (
// TODO: Obsolete once filtered Activities are included in this list.
<Button
onClick={startActivitySliceSelection.bind(
null,
changeActivitySliceAction.bind(null, null),
)}
title="Back to full tree view">
<ButtonIcon type="previous" />
</Button>
)}
</div>
<ol
role="listbox"
className={styles.ActivityListList}
data-pending-activity-slice-selection={isPendingActivitySliceSelection}
tabIndex={0}
onKeyDown={handleKeyDown}>
{activities.map(({id, depth}) => {
const activity = store.getElementByID(id);
if (activity === null) {
return null;
}
const name = activity.nameProp;
if (name === null) {
// This shouldn't actually happen. We only want to show activities with a name.
// And hide the whole list if no named Activities are present.
return null;
}
// TODO: Filtered Activities should have dedicated styles once we include
// filtered Activities in this list.
return (
<li
key={activity.id}
role="option"
aria-selected={
activity.id === selectedActivityID ? 'true' : 'false'
}
className={styles.ActivityListItem}
onClick={handleClick.bind(null, activity.id)}
onDoubleClick={handleDoubleClick}
onPointerOver={highlightHostInstance.bind(
null,
activity.id,
false,
)}
onPointerLeave={clearHighlightHostInstance}>
{'\u00A0'.repeat(depth) + name}
</li>
);
})}
</ol>
</div>
);
}

View File

@@ -12,7 +12,10 @@ import typeof {SyntheticMouseEvent} from 'react-dom-bindings/src/events/Syntheti
import * as React from 'react';
import {useContext} from 'react';
import {TreeDispatcherContext} from '../Components/TreeContext';
import {
TreeDispatcherContext,
TreeStateContext,
} from '../Components/TreeContext';
import {StoreContext} from '../context';
import {useHighlightHostInstance} from '../hooks';
import styles from './SuspenseBreadcrumbs.css';
@@ -23,6 +26,7 @@ import {
export default function SuspenseBreadcrumbs(): React$Node {
const store = useContext(StoreContext);
const {activityID} = useContext(TreeStateContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {selectedSuspenseID, lineage, roots} = useContext(
@@ -42,8 +46,8 @@ export default function SuspenseBreadcrumbs(): React$Node {
<ol className={styles.SuspenseBreadcrumbsList}>
{lineage === null ? null : lineage.length === 0 ? (
// We selected the root. This means that we're currently viewing the Transition
// that rendered the whole screen. In laymans terms this is really "Initial Paint".
// TODO: Once we add subtree selection, then the equivalent should be called
// that rendered the whole screen. In laymans terms this is really "Initial Paint" .
// When we're looking at a subtree selection, then the equivalent is a
// "Transition" since in that case it's really about a Transition within the page.
roots.length > 0 ? (
<li
@@ -51,9 +55,12 @@ export default function SuspenseBreadcrumbs(): React$Node {
aria-current="true">
<button
className={styles.SuspenseBreadcrumbsButton}
onClick={handleClick.bind(null, roots[0])}
onClick={handleClick.bind(
null,
activityID === null ? roots[0] : activityID,
)}
type="button">
Initial Paint
{activityID === null ? 'Initial Paint' : 'Transition'}
</button>
</li>
) : null

View File

@@ -9,6 +9,7 @@
import type Store from 'react-devtools-shared/src/devtools/store';
import type {
Element,
SuspenseNode,
Rect,
} from 'react-devtools-shared/src/frontend/types';
@@ -18,7 +19,7 @@ import typeof {
} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {createContext, useContext, useLayoutEffect} from 'react';
import {createContext, useContext, useLayoutEffect, useMemo} from 'react';
import {
TreeDispatcherContext,
TreeStateContext,
@@ -426,6 +427,30 @@ function SuspenseRectsRoot({rootID}: {rootID: SuspenseNode['id']}): React$Node {
});
}
function SuspenseRectsInitialPaint(): React$Node {
const {roots} = useContext(SuspenseTreeStateContext);
return roots.map(rootID => {
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
});
}
function SuspenseRectsTransition({id}: {id: Element['id']}): React$Node {
const store = useContext(StoreContext);
const children = useMemo(() => {
return store.getSuspenseChildren(id);
}, [id, store]);
return children.map(suspenseID => {
return (
<SuspenseRects
key={suspenseID}
suspenseID={suspenseID}
parentRects={null}
/>
);
});
}
const ViewBox = createContext<Rect>((null: any));
function SuspenseRectsContainer({
@@ -434,14 +459,25 @@ function SuspenseRectsContainer({
scaleRef: {current: number},
}): React$Node {
const store = useContext(StoreContext);
const {inspectedElementID} = useContext(TreeStateContext);
const {activityID, inspectedElementID} = useContext(TreeStateContext);
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, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
useContext(SuspenseTreeStateContext);
// TODO: bbox does not consider uniqueSuspendersOnly filter
const activityChildren: $ReadOnlyArray<SuspenseNode['id']> | null =
useMemo(() => {
if (activityID === null) {
return null;
}
return store.getSuspenseChildren(activityID);
}, [activityID, store]);
const transitionChildren =
activityChildren === null ? roots : activityChildren;
// We're using the bounding box of the entire document to anchor the Transition
// in the actual document.
const boundingBox = getDocumentBoundingRect(store, roots);
const boundingBoxWidth = boundingBox.width;
@@ -456,14 +492,18 @@ function SuspenseRectsContainer({
// Already clicked on an inner rect
return;
}
if (roots.length === 0) {
if (transitionChildren.length === 0) {
// Nothing to select
return;
}
const arbitraryRootID = roots[0];
const transitionRoot = activityID === null ? arbitraryRootID : activityID;
event.preventDefault();
treeDispatch({type: 'SELECT_ELEMENT_BY_ID', payload: arbitraryRootID});
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: transitionRoot,
});
suspenseTreeDispatch({
type: 'SET_SUSPENSE_LINEAGE',
payload: arbitraryRootID,
@@ -483,7 +523,8 @@ function SuspenseRectsContainer({
}
const isRootSelected = roots.includes(inspectedElementID);
const isRootHovered = hoveredTimelineIndex === 0;
// When we're focusing a Transition, the first timeline step will not be a root.
const isRootHovered = activityID === null && hoveredTimelineIndex === 0;
let hasRootSuspenders = false;
if (!uniqueSuspendersOnly) {
@@ -536,7 +577,13 @@ function SuspenseRectsContainer({
<div
className={
styles.SuspenseRectsContainer +
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
(hasRootSuspenders &&
// We don't want to draw attention to the root if we're looking at a Transition.
// TODO: Draw bounding rect of Transition and check if the Transition
// has unique suspenders.
activityID === null
? ' ' + styles.SuspenseRectsRoot
: '') +
(isRootSelected ? ' ' + styles.SuspenseRectsRootOutline : '') +
' ' +
getClassNameForEnvironment(rootEnvironment)
@@ -548,9 +595,11 @@ function SuspenseRectsContainer({
<div
className={styles.SuspenseRectsViewBox}
style={{aspectRatio, width}}>
{roots.map(rootID => {
return <SuspenseRectsRoot key={rootID} rootID={rootID} />;
})}
{activityID === null ? (
<SuspenseRectsInitialPaint />
) : (
<SuspenseRectsTransition id={activityID} />
)}
{selectedBoundingBox !== null ? (
<ScaledRect
className={

View File

@@ -12,13 +12,15 @@ import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/type
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
import {useRef} from 'react';
import {useContext, useRef} from 'react';
import {ElementTypeRoot} from 'react-devtools-shared/src/frontend/types';
import styles from './SuspenseScrubber.css';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
import Tooltip from '../Components/reach-ui/tooltip';
import {StoreContext} from '../context';
export default function SuspenseScrubber({
min,
@@ -43,6 +45,7 @@ export default function SuspenseScrubber({
onHoverSegment: (index: number) => void,
onHoverLeave: () => void,
}): React$Node {
const store = useContext(StoreContext);
const inputRef = useRef();
function handleChange(event: SyntheticEvent) {
const newValue = +event.currentTarget.value;
@@ -60,12 +63,16 @@ export default function SuspenseScrubber({
}
const steps = [];
for (let index = min; index <= max; index++) {
const environment = timeline[index].environment;
const step = timeline[index];
const environment = step.environment;
const element = store.getElementByID(step.id);
const label =
index === min
? // The first step in the timeline is always a Transition (Initial Paint).
'Initial Paint' +
(environment === null ? '' : ' (' + environment + ')')
element === null || element.type === ElementTypeRoot
? 'Initial Paint'
: 'Transition' +
(environment === null ? '' : ' (' + environment + ')')
: // TODO: Consider adding the name of this specific boundary if this step has only one.
environment === null
? 'Suspense'

View File

@@ -92,7 +92,7 @@
}
.ActivityList {
flex: 0 0 var(--horizontal-resize-tree-list-percentage);
flex: 0 0 var(--horizontal-resize-activity-list-percentage);;
border-right: 1px solid var(--color-border);
overflow: auto;
}

View File

@@ -6,14 +6,11 @@
*
* @flow
*/
import type {Element} from 'react-devtools-shared/src/frontend/types';
import * as React from 'react';
import {
useContext,
useEffect,
useLayoutEffect,
useMemo,
useReducer,
useRef,
Fragment,
@@ -44,12 +41,13 @@ import typeof {SyntheticPointerEvent} from 'react-dom-bindings/src/events/Synthe
import SettingsModal from 'react-devtools-shared/src/devtools/views/Settings/SettingsModal';
import SettingsModalContextToggle from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContextToggle';
import {SettingsModalContextController} from 'react-devtools-shared/src/devtools/views/Settings/SettingsModalContext';
import {TreeStateContext} from '../Components/TreeContext';
type Orientation = 'horizontal' | 'vertical';
type LayoutActionType =
| 'ACTION_SET_TREE_LIST_TOGGLE'
| 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION'
| 'ACTION_SET_ACTIVITY_LIST_TOGGLE'
| 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION'
| 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE'
| 'ACTION_SET_INSPECTED_ELEMENT_HORIZONTAL_FRACTION'
| 'ACTION_SET_INSPECTED_ELEMENT_VERTICAL_FRACTION';
@@ -59,8 +57,8 @@ type LayoutAction = {
};
type LayoutState = {
treeListHidden: boolean,
treeListHorizontalFraction: number,
activityListHidden: boolean,
activityListHorizontalFraction: number,
inspectedElementHidden: boolean,
inspectedElementHorizontalFraction: number,
inspectedElementVerticalFraction: number,
@@ -97,7 +95,7 @@ function ToggleUniqueSuspenders() {
);
}
function ToggleTreeList({
function ToggleActivityList({
dispatch,
state,
}: {
@@ -108,13 +106,15 @@ function ToggleTreeList({
<Button
onClick={() =>
dispatch({
type: 'ACTION_SET_TREE_LIST_TOGGLE',
type: 'ACTION_SET_ACTIVITY_LIST_TOGGLE',
payload: null,
})
}
title={state.treeListHidden ? 'Show Tree List' : 'Hide Tree List'}>
title={
state.activityListHidden ? 'Show Activity List' : 'Hide Activity List'
}>
<ButtonIcon
type={state.treeListHidden ? 'panel-left-open' : 'panel-left-close'}
type={state.activityListHidden ? 'panel-left-open' : 'panel-left-close'}
/>
</Button>
);
@@ -272,17 +272,6 @@ function SynchronizedScrollContainer({
);
}
// TODO: Get this from the store directly.
// The backend needs to keep a separate tree so that resuspending keeps Activity around.
function useActivities(): $ReadOnlyArray<Element> {
const activities = useMemo(() => {
const items: Array<Element> = [];
return items;
}, []);
return activities;
}
function SuspenseTab(_: {}) {
const store = useContext(StoreContext);
const {hideSettings} = useContext(OptionsContext);
@@ -292,14 +281,14 @@ function SuspenseTab(_: {}) {
initLayoutState,
);
const activities = useActivities();
const {activities} = useContext(TreeStateContext);
// If there are no named Activity boundaries, we don't have any tree list and we should hide
// both the panel and the button to toggle it.
const treeListDisabled = activities.length === 0;
const activityListDisabled = activities.length === 0;
const wrapperTreeRef = useRef<null | HTMLElement>(null);
const resizeTreeRef = useRef<null | HTMLElement>(null);
const resizeTreeListRef = useRef<null | HTMLElement>(null);
const resizeActivityListRef = useRef<null | HTMLElement>(null);
// TODO: We'll show the recently inspected element in this tab when it should probably
// switch to the nearest Suspense boundary when we switch into this tab.
@@ -308,8 +297,8 @@ function SuspenseTab(_: {}) {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
activityListHidden,
activityListHorizontalFraction,
} = state;
useLayoutEffect(() => {
@@ -328,12 +317,12 @@ function SuspenseTab(_: {}) {
inspectedElementVerticalFraction * 100,
);
const resizeTreeListElement = resizeTreeListRef.current;
const resizeActivityListElement = resizeActivityListRef.current;
setResizeCSSVariable(
resizeTreeListElement,
'tree-list',
resizeActivityListElement,
'activity-list',
'horizontal',
treeListHorizontalFraction * 100,
activityListHorizontalFraction * 100,
);
}, []);
useEffect(() => {
@@ -344,8 +333,8 @@ function SuspenseTab(_: {}) {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
activityListHidden,
activityListHorizontalFraction,
}),
);
}, 500);
@@ -355,8 +344,8 @@ function SuspenseTab(_: {}) {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
activityListHidden,
activityListHorizontalFraction,
]);
const onResizeStart = (event: SyntheticPointerEvent) => {
@@ -420,14 +409,14 @@ function SuspenseTab(_: {}) {
}
};
const onResizeTreeList = (event: SyntheticPointerEvent) => {
const onResizeActivityList = (event: SyntheticPointerEvent) => {
const element = event.currentTarget;
const isResizing = element.hasPointerCapture(event.pointerId);
if (!isResizing) {
return;
}
const resizeElement = resizeTreeListRef.current;
const resizeElement = resizeActivityListRef.current;
const wrapperElement = resizeTreeRef.current;
if (wrapperElement === null || resizeElement === null) {
@@ -443,11 +432,11 @@ function SuspenseTab(_: {}) {
const currentMousePosition =
orientation === 'horizontal' ? event.clientX - left : event.clientY - top;
const boundaryMin = MINIMUM_TREE_LIST_SIZE;
const boundaryMin = MINIMUM_ACTIVITY_LIST_SIZE;
const boundaryMax =
orientation === 'horizontal'
? width - MINIMUM_TREE_LIST_SIZE
: height - MINIMUM_TREE_LIST_SIZE;
? width - MINIMUM_ACTIVITY_LIST_SIZE
: height - MINIMUM_ACTIVITY_LIST_SIZE;
const isMousePositionInBounds =
currentMousePosition > boundaryMin && currentMousePosition < boundaryMax;
@@ -455,10 +444,15 @@ function SuspenseTab(_: {}) {
if (isMousePositionInBounds) {
const resizedElementDimension =
orientation === 'horizontal' ? width : height;
const actionType = 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION';
const actionType = 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION';
const percentage = (currentMousePosition / resizedElementDimension) * 100;
setResizeCSSVariable(resizeElement, 'tree-list', orientation, percentage);
setResizeCSSVariable(
resizeElement,
'activity-list',
orientation,
percentage,
);
dispatch({
type: actionType,
@@ -473,19 +467,21 @@ function SuspenseTab(_: {}) {
<SettingsModalContextController>
<div className={styles.SuspenseTab} ref={wrapperTreeRef}>
<div className={styles.TreeWrapper} ref={resizeTreeRef}>
{treeListDisabled ? null : (
{activityListDisabled ? null : (
<div
className={styles.ActivityList}
hidden={treeListHidden}
ref={resizeTreeListRef}>
hidden={activityListHidden}
ref={resizeActivityListRef}>
<ActivityList activities={activities} />
</div>
)}
{treeListDisabled ? null : (
<div className={styles.ResizeBarWrapper} hidden={treeListHidden}>
{activityListDisabled ? null : (
<div
className={styles.ResizeBarWrapper}
hidden={activityListHidden}>
<div
onPointerDown={onResizeStart}
onPointerMove={onResizeTreeList}
onPointerMove={onResizeActivityList}
onPointerUp={onResizeEnd}
className={styles.ResizeBar}
/>
@@ -493,10 +489,10 @@ function SuspenseTab(_: {}) {
)}
<div className={styles.TreeView}>
<header className={styles.SuspenseTreeViewHeader}>
{treeListDisabled ? (
{activityListDisabled ? (
<div />
) : (
<ToggleTreeList dispatch={dispatch} state={state} />
<ToggleActivityList dispatch={dispatch} state={state} />
)}
{store.supportsClickToInspect && (
<Fragment>
@@ -559,19 +555,19 @@ function SuspenseTab(_: {}) {
const LOCAL_STORAGE_KEY = 'React::DevTools::SuspenseTab::layout';
const VERTICAL_TREE_MODE_MAX_WIDTH = 600;
const MINIMUM_TREE_SIZE = 100;
const MINIMUM_TREE_LIST_SIZE = 100;
const MINIMUM_ACTIVITY_LIST_SIZE = 100;
function layoutReducer(state: LayoutState, action: LayoutAction): LayoutState {
switch (action.type) {
case 'ACTION_SET_TREE_LIST_TOGGLE':
case 'ACTION_SET_ACTIVITY_LIST_TOGGLE':
return {
...state,
treeListHidden: !state.treeListHidden,
activityListHidden: !state.activityListHidden,
};
case 'ACTION_SET_TREE_LIST_HORIZONTAL_FRACTION':
case 'ACTION_SET_ACTIVITY_LIST_HORIZONTAL_FRACTION':
return {
...state,
treeListHorizontalFraction: action.payload,
activityListHorizontalFraction: action.payload,
};
case 'ACTION_SET_INSPECTED_ELEMENT_TOGGLE':
return {
@@ -597,8 +593,8 @@ function initLayoutState(): LayoutState {
let inspectedElementHidden = false;
let inspectedElementHorizontalFraction = 0.65;
let inspectedElementVerticalFraction = 0.5;
let treeListHidden = false;
let treeListHorizontalFraction = 0.35;
let activityListHidden = false;
let activityListHorizontalFraction = 0.35;
try {
let data = localStorageGetItem(LOCAL_STORAGE_KEY);
@@ -608,8 +604,8 @@ function initLayoutState(): LayoutState {
inspectedElementHorizontalFraction =
data.inspectedElementHorizontalFraction;
inspectedElementVerticalFraction = data.inspectedElementVerticalFraction;
treeListHidden = data.treeListHidden;
treeListHorizontalFraction = data.treeListHorizontalFraction;
activityListHidden = data.activityListHidden;
activityListHorizontalFraction = data.activityListHorizontalFraction;
}
} catch (error) {}
@@ -617,8 +613,8 @@ function initLayoutState(): LayoutState {
inspectedElementHidden,
inspectedElementHorizontalFraction,
inspectedElementVerticalFraction,
treeListHidden,
treeListHorizontalFraction,
activityListHidden,
activityListHorizontalFraction,
};
}
@@ -634,7 +630,7 @@ function getTreeOrientation(
function setResizeCSSVariable(
resizeElement: null | HTMLElement,
name: 'tree' | 'tree-list',
name: 'tree' | 'activity-list',
orientation: null | Orientation,
percentage: number,
): void {

View File

@@ -204,7 +204,11 @@ export type Rect = {
};
export type SuspenseTimelineStep = {
id: SuspenseNode['id'], // TODO: Will become a group.
/**
* The first step is either a host root (initial paint) or Activity (Transition).
* Subsequent steps are always Suspense nodes.
*/
id: SuspenseNode['id'] | Element['id'], // TODO: Will become a group.
environment: null | string,
endTime: number,
};

View File

@@ -75,22 +75,26 @@ function Root({children}: {children: React.Node}): React.Node {
);
}
const dynamicData = deferred(10, 'Dynamic Data: 📈📉📊', 'dynamicData');
export default function Segments(): React.Node {
return (
<React.Activity name="/" mode="visible">
<Root>
<React.Activity name="/outer/" mode="visible">
<OuterSegment>
<React.Activity name="/outer/inner" mode="visible">
<InnerSegment>
<React.Activity name="/outer/inner/page" mode="visible">
<Page />
</React.Activity>
</InnerSegment>
</React.Activity>
</OuterSegment>
</React.Activity>
</Root>
</React.Activity>
<>
<p>{dynamicData}</p>
<React.Activity name="root" mode="visible">
<Root>
<React.Activity name="outer" mode="visible">
<OuterSegment>
<React.Activity name="inner" mode="visible">
<InnerSegment>
<React.Activity name="slot" mode="visible">
<Page />
</React.Activity>
</InnerSegment>
</React.Activity>
</OuterSegment>
</React.Activity>
</Root>
</React.Activity>
</>
);
}

View File

@@ -1111,4 +1111,64 @@ describe('ReactDOMFizzStaticBrowser', () => {
</div>,
);
});
// @gate enableHalt && enableOptimisticKey
it('can resume an optimistic keyed slot', async () => {
const errors = [];
let resolve;
const promise = new Promise(r => (resolve = r));
async function Component() {
await promise;
return 'Hi';
}
if (React.optimisticKey === undefined) {
throw new Error('optimisticKey missing');
}
function App() {
return (
<div>
<Suspense fallback="Loading">
<Component key={React.optimisticKey} />
</Suspense>
</div>
);
}
const controller = new AbortController();
const pendingResult = serverAct(() =>
ReactDOMFizzStatic.prerender(<App />, {
signal: controller.signal,
onError(x) {
errors.push(x.message);
},
}),
);
await serverAct(() => {
controller.abort();
});
const prerendered = await pendingResult;
const postponedState = JSON.stringify(prerendered.postponed);
await readIntoContainer(prerendered.prelude);
expect(getVisibleChildren(container)).toEqual(<div>Loading</div>);
expect(prerendered.postponed).not.toBe(null);
await resolve();
const dynamic = await serverAct(() =>
ReactDOMFizzServer.resume(<App />, JSON.parse(postponedState)),
);
await readIntoContainer(dynamic);
expect(getVisibleChildren(container)).toEqual(<div>Hi</div>);
});
});

View File

@@ -2281,4 +2281,64 @@ describe('ReactDOMForm', () => {
await submit(formRef.current);
assertLog(['stringified action']);
});
it('form actions should retain status when nested state changes', async () => {
const formRef = React.createRef();
let rerenderUnrelatedStatus;
function UnrelatedStatus() {
const {pending} = useFormStatus();
const [counter, setCounter] = useState(0);
rerenderUnrelatedStatus = () => setCounter(n => n + 1);
Scheduler.log(`[unrelated form] pending: ${pending}, state: ${counter}`);
}
let rerenderTargetStatus;
function TargetStatus() {
const {pending} = useFormStatus();
const [counter, setCounter] = useState(0);
Scheduler.log(`[target form] pending: ${pending}, state: ${counter}`);
rerenderTargetStatus = () => setCounter(n => n + 1);
}
function App() {
async function action() {
return new Promise(resolve => {
// never resolves
});
}
return (
<>
<form action={action} ref={formRef}>
<input type="submit" />
<TargetStatus />
</form>
<form>
<UnrelatedStatus />
</form>
</>
);
}
const root = ReactDOMClient.createRoot(container);
await act(() => root.render(<App />));
assertLog([
'[target form] pending: false, state: 0',
'[unrelated form] pending: false, state: 0',
]);
await submit(formRef.current);
assertLog(['[target form] pending: true, state: 0']);
await act(() => rerenderTargetStatus());
assertLog(['[target form] pending: true, state: 1']);
await act(() => rerenderUnrelatedStatus());
assertLog(['[unrelated form] pending: false, state: 1']);
});
});

View File

@@ -15,6 +15,8 @@ import type {
ReactDebugInfo,
ReactComponentInfo,
SuspenseListRevealOrder,
ReactKey,
ReactOptimisticKey,
} from 'shared/ReactTypes';
import type {Fiber} from './ReactInternalTypes';
import type {Lanes} from './ReactFiberLane';
@@ -37,6 +39,7 @@ import {
REACT_LAZY_TYPE,
REACT_CONTEXT_TYPE,
REACT_LEGACY_ELEMENT_TYPE,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import {
HostRoot,
@@ -50,6 +53,7 @@ import {
enableAsyncIterableChildren,
disableLegacyMode,
enableFragmentRefs,
enableOptimisticKey,
} from 'shared/ReactFeatureFlags';
import {
@@ -462,18 +466,33 @@ function createChildReconciler(
function mapRemainingChildren(
currentFirstChild: Fiber,
): Map<string | number, Fiber> {
): Map<string | number | ReactOptimisticKey, Fiber> {
// Add the remaining children to a temporary map so that we can find them by
// keys quickly. Implicit (null) keys get added to this set with their index
// instead.
const existingChildren: Map<string | number, Fiber> = new Map();
const existingChildren: Map<
| string
| number
// This type is only here for the case when enableOptimisticKey is disabled.
// Remove it after it ships.
| ReactOptimisticKey,
Fiber,
> = new Map();
let existingChild: null | Fiber = currentFirstChild;
while (existingChild !== null) {
if (existingChild.key !== null) {
existingChildren.set(existingChild.key, existingChild);
} else {
if (existingChild.key === null) {
existingChildren.set(existingChild.index, existingChild);
} else if (
enableOptimisticKey &&
existingChild.key === REACT_OPTIMISTIC_KEY
) {
// For optimistic keys, we store the negative index (minus one) to differentiate
// them from the regular indices. We'll look this up regardless of what the new
// key is, if there's no other match.
existingChildren.set(-existingChild.index - 1, existingChild);
} else {
existingChildren.set(existingChild.key, existingChild);
}
existingChild = existingChild.sibling;
}
@@ -636,6 +655,10 @@ function createChildReconciler(
} else {
// Update
const existing = useFiber(current, portal.children || []);
if (enableOptimisticKey) {
// If the old key was optimistic we need to now save the real one.
existing.key = portal.key;
}
existing.return = returnFiber;
if (__DEV__) {
existing._debugInfo = currentDebugInfo;
@@ -649,7 +672,7 @@ function createChildReconciler(
current: Fiber | null,
fragment: Iterable<React$Node>,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
if (current === null || current.tag !== Fragment) {
// Insert
@@ -670,6 +693,10 @@ function createChildReconciler(
} else {
// Update
const existing = useFiber(current, fragment);
if (enableOptimisticKey) {
// If the old key was optimistic we need to now save the real one.
existing.key = key;
}
existing.return = returnFiber;
if (__DEV__) {
existing._debugInfo = currentDebugInfo;
@@ -840,7 +867,13 @@ function createChildReconciler(
if (typeof newChild === 'object' && newChild !== null) {
switch (newChild.$$typeof) {
case REACT_ELEMENT_TYPE: {
if (newChild.key === key) {
if (
// If the old child was an optimisticKey, then we'd normally consider that a match,
// but instead, we'll bail to return null from the slot which will bail to slow path.
// That's to ensure that if the new key has a match elsewhere in the list, then that
// takes precedence over assuming the identity of an optimistic slot.
newChild.key === key
) {
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const updated = updateElement(
returnFiber,
@@ -855,7 +888,13 @@ function createChildReconciler(
}
}
case REACT_PORTAL_TYPE: {
if (newChild.key === key) {
if (
// If the old child was an optimisticKey, then we'd normally consider that a match,
// but instead, we'll bail to return null from the slot which will bail to slow path.
// That's to ensure that if the new key has a match elsewhere in the list, then that
// takes precedence over assuming the identity of an optimistic slot.
newChild.key === key
) {
return updatePortal(returnFiber, oldFiber, newChild, lanes);
} else {
return null;
@@ -939,7 +978,7 @@ function createChildReconciler(
}
function updateFromMap(
existingChildren: Map<string | number, Fiber>,
existingChildren: Map<string | number | ReactOptimisticKey, Fiber>,
returnFiber: Fiber,
newIdx: number,
newChild: any,
@@ -968,7 +1007,11 @@ function createChildReconciler(
const matchedFiber =
existingChildren.get(
newChild.key === null ? newIdx : newChild.key,
) || null;
) ||
(enableOptimisticKey &&
// If the existing child was an optimistic key, we may still match on the index.
existingChildren.get(-newIdx - 1)) ||
null;
const prevDebugInfo = pushDebugInfo(newChild._debugInfo);
const updated = updateElement(
returnFiber,
@@ -983,7 +1026,11 @@ function createChildReconciler(
const matchedFiber =
existingChildren.get(
newChild.key === null ? newIdx : newChild.key,
) || null;
) ||
(enableOptimisticKey &&
// If the existing child was an optimistic key, we may still match on the index.
existingChildren.get(-newIdx - 1)) ||
null;
return updatePortal(returnFiber, matchedFiber, newChild, lanes);
}
case REACT_LAZY_TYPE: {
@@ -1274,14 +1321,22 @@ function createChildReconciler(
);
}
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
const currentFiber = newFiber.alternate;
if (currentFiber !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
if (
enableOptimisticKey &&
currentFiber.key === REACT_OPTIMISTIC_KEY
) {
existingChildren.delete(-newIdx - 1);
} else {
existingChildren.delete(
currentFiber.key === null ? newIdx : currentFiber.key,
);
}
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
@@ -1568,14 +1623,22 @@ function createChildReconciler(
);
}
if (shouldTrackSideEffects) {
if (newFiber.alternate !== null) {
const currentFiber = newFiber.alternate;
if (currentFiber !== null) {
// The new fiber is a work in progress, but if there exists a
// current, that means that we reused the fiber. We need to delete
// it from the child list so that we don't add it to the deletion
// list.
existingChildren.delete(
newFiber.key === null ? newIdx : newFiber.key,
);
if (
enableOptimisticKey &&
currentFiber.key === REACT_OPTIMISTIC_KEY
) {
existingChildren.delete(-newIdx - 1);
} else {
existingChildren.delete(
currentFiber.key === null ? newIdx : currentFiber.key,
);
}
}
}
lastPlacedIndex = placeChild(newFiber, lastPlacedIndex, newIdx);
@@ -1642,12 +1705,19 @@ function createChildReconciler(
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (child.key === key) {
if (
child.key === key ||
(enableOptimisticKey && child.key === REACT_OPTIMISTIC_KEY)
) {
const elementType = element.type;
if (elementType === REACT_FRAGMENT_TYPE) {
if (child.tag === Fragment) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props.children);
if (enableOptimisticKey) {
// If the old key was optimistic we need to now save the real one.
existing.key = key;
}
if (enableFragmentRefs) {
coerceRef(existing, element);
}
@@ -1677,6 +1747,10 @@ function createChildReconciler(
) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, element.props);
if (enableOptimisticKey) {
// If the old key was optimistic we need to now save the real one.
existing.key = key;
}
coerceRef(existing, element);
existing.return = returnFiber;
if (__DEV__) {
@@ -1736,7 +1810,10 @@ function createChildReconciler(
while (child !== null) {
// TODO: If key === null and child.key === null, then this only applies to
// the first item in the list.
if (child.key === key) {
if (
child.key === key ||
(enableOptimisticKey && child.key === REACT_OPTIMISTIC_KEY)
) {
if (
child.tag === HostPortal &&
child.stateNode.containerInfo === portal.containerInfo &&
@@ -1744,6 +1821,10 @@ function createChildReconciler(
) {
deleteRemainingChildren(returnFiber, child.sibling);
const existing = useFiber(child, portal.children || []);
if (enableOptimisticKey) {
// If the old key was optimistic we need to now save the real one.
existing.key = key;
}
existing.return = returnFiber;
return existing;
} else {

View File

@@ -14,6 +14,7 @@ import type {
ReactScope,
ViewTransitionProps,
ActivityProps,
ReactKey,
} from 'shared/ReactTypes';
import type {Fiber} from './ReactInternalTypes';
import type {RootTag} from './ReactRootTags';
@@ -43,6 +44,7 @@ import {
enableObjectFiber,
enableViewTransition,
enableSuspenseyImages,
enableOptimisticKey,
} from 'shared/ReactFeatureFlags';
import {NoFlags, Placement, StaticMask} from './ReactFiberFlags';
import {ConcurrentRoot} from './ReactRootTags';
@@ -137,7 +139,7 @@ function FiberNode(
this: $FlowFixMe,
tag: WorkTag,
pendingProps: mixed,
key: null | string,
key: ReactKey,
mode: TypeOfMode,
) {
// Instance
@@ -224,7 +226,7 @@ function FiberNode(
function createFiberImplClass(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
key: ReactKey,
mode: TypeOfMode,
): Fiber {
// $FlowFixMe[invalid-constructor]: the shapes are exact here but Flow doesn't like constructors
@@ -234,7 +236,7 @@ function createFiberImplClass(
function createFiberImplObject(
tag: WorkTag,
pendingProps: mixed,
key: null | string,
key: ReactKey,
mode: TypeOfMode,
): Fiber {
const fiber: Fiber = {
@@ -364,6 +366,12 @@ export function createWorkInProgress(current: Fiber, pendingProps: any): Fiber {
workInProgress.subtreeFlags = NoFlags;
workInProgress.deletions = null;
if (enableOptimisticKey) {
// For optimistic keys, the Fibers can have different keys if one is optimistic
// and the other one is filled in.
workInProgress.key = current.key;
}
if (enableProfilerTimer) {
// We intentionally reset, rather than copy, actualDuration & actualStartTime.
// This prevents time from endlessly accumulating in new commits.
@@ -488,8 +496,15 @@ export function resetWorkInProgress(
workInProgress.memoizedState = current.memoizedState;
workInProgress.updateQueue = current.updateQueue;
// Needed because Blocks store data on type.
// TODO: Blocks don't exist anymore. Do we still need this?
workInProgress.type = current.type;
if (enableOptimisticKey) {
// For optimistic keys, the Fibers can have different keys if one is optimistic
// and the other one is filled in.
workInProgress.key = current.key;
}
// Clone the dependencies object. This is mutated during the render phase, so
// it cannot be shared with the current fiber.
const currentDependencies = current.dependencies;
@@ -545,7 +560,7 @@ export function createHostRootFiber(
// TODO: Get rid of this helper. Only createFiberFromElement should exist.
export function createFiberFromTypeAndProps(
type: any, // React$ElementType
key: null | string,
key: ReactKey,
pendingProps: any,
owner: null | ReactComponentInfo | Fiber,
mode: TypeOfMode,
@@ -747,7 +762,7 @@ export function createFiberFromFragment(
elements: ReactFragment,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
const fiber = createFiber(Fragment, elements, key, mode);
fiber.lanes = lanes;
@@ -759,7 +774,7 @@ function createFiberFromScope(
pendingProps: any,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
) {
const fiber = createFiber(ScopeComponent, pendingProps, key, mode);
fiber.type = scope;
@@ -772,7 +787,7 @@ function createFiberFromProfiler(
pendingProps: any,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
if (__DEV__) {
if (typeof pendingProps.id !== 'string') {
@@ -801,7 +816,7 @@ export function createFiberFromSuspense(
pendingProps: any,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
const fiber = createFiber(SuspenseComponent, pendingProps, key, mode);
fiber.elementType = REACT_SUSPENSE_TYPE;
@@ -813,7 +828,7 @@ export function createFiberFromSuspenseList(
pendingProps: any,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
const fiber = createFiber(SuspenseListComponent, pendingProps, key, mode);
fiber.elementType = REACT_SUSPENSE_LIST_TYPE;
@@ -825,7 +840,7 @@ export function createFiberFromOffscreen(
pendingProps: OffscreenProps,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
const fiber = createFiber(OffscreenComponent, pendingProps, key, mode);
fiber.lanes = lanes;
@@ -835,7 +850,7 @@ export function createFiberFromActivity(
pendingProps: ActivityProps,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
const fiber = createFiber(ActivityComponent, pendingProps, key, mode);
fiber.elementType = REACT_ACTIVITY_TYPE;
@@ -847,7 +862,7 @@ export function createFiberFromViewTransition(
pendingProps: ViewTransitionProps,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
if (!enableSuspenseyImages) {
// Render a ViewTransition component opts into SuspenseyImages mode even
@@ -871,7 +886,7 @@ export function createFiberFromLegacyHidden(
pendingProps: LegacyHiddenProps,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
const fiber = createFiber(LegacyHiddenComponent, pendingProps, key, mode);
fiber.elementType = REACT_LEGACY_HIDDEN_TYPE;
@@ -883,7 +898,7 @@ export function createFiberFromTracingMarker(
pendingProps: any,
mode: TypeOfMode,
lanes: Lanes,
key: null | string,
key: ReactKey,
): Fiber {
const fiber = createFiber(TracingMarkerComponent, pendingProps, key, mode);
fiber.elementType = REACT_TRACING_MARKER_TYPE;

View File

@@ -1974,7 +1974,7 @@ function updateHostComponent(
// If the transition state changed, propagate the change to all the
// descendents. We use Context as an implementation detail for this.
//
// This is intentionally set here instead of pushHostContext because
// We need to update it here because
// pushHostContext gets called before we process the state hook, to avoid
// a state mismatch in the event that something suspends.
//

View File

@@ -9,7 +9,11 @@
import type {Fiber} from './ReactInternalTypes';
import type {StackCursor} from './ReactFiberStack';
import type {Container, HostContext} from './ReactFiberConfig';
import type {
Container,
HostContext,
TransitionStatus,
} from './ReactFiberConfig';
import type {Hook} from './ReactFiberHooks';
import {
@@ -92,6 +96,21 @@ function getHostContext(): HostContext {
function pushHostContext(fiber: Fiber): void {
const stateHook: Hook | null = fiber.memoizedState;
if (stateHook !== null) {
// Propagate the current state to all the descendents.
// We use Context as an implementation detail for this.
//
// NOTE: This assumes that there cannot be nested transition providers,
// because the only renderer that implements this feature is React DOM,
// and forms cannot be nested. If we did support nested providers, then
// we would need to push a context value even for host fibers that
// haven't been upgraded yet.
const transitionStatus: TransitionStatus = stateHook.memoizedState;
if (isPrimaryRenderer) {
HostTransitionContext._currentValue = transitionStatus;
} else {
HostTransitionContext._currentValue2 = transitionStatus;
}
// Only provide context if this fiber has been upgraded by a host
// transition. We use the same optimization for regular host context below.
push(hostTransitionProviderCursor, fiber, fiber);

View File

@@ -17,6 +17,7 @@ import type {
Awaited,
ReactComponentInfo,
ReactDebugInfo,
ReactKey,
} from 'shared/ReactTypes';
import type {TransitionTypes} from 'react/src/ReactTransitionType';
import type {WorkTag} from './ReactWorkTags';
@@ -100,7 +101,7 @@ export type Fiber = {
tag: WorkTag,
// Unique identifier of this child.
key: null | string,
key: ReactKey,
// The value of element.type which is used to preserve the identity during
// reconciliation of this child.

View File

@@ -7,25 +7,37 @@
* @flow
*/
import {REACT_PORTAL_TYPE} from 'shared/ReactSymbols';
import {REACT_PORTAL_TYPE, REACT_OPTIMISTIC_KEY} from 'shared/ReactSymbols';
import {checkKeyStringCoercion} from 'shared/CheckStringCoercion';
import type {ReactNodeList, ReactPortal} from 'shared/ReactTypes';
import type {
ReactNodeList,
ReactPortal,
ReactOptimisticKey,
} from 'shared/ReactTypes';
export function createPortal(
children: ReactNodeList,
containerInfo: any,
// TODO: figure out the API for cross-renderer implementation.
implementation: any,
key: ?string = null,
key: ?string | ReactOptimisticKey = null,
): ReactPortal {
if (__DEV__) {
checkKeyStringCoercion(key);
let resolvedKey;
if (key == null) {
resolvedKey = null;
} else if (key === REACT_OPTIMISTIC_KEY) {
resolvedKey = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(key);
}
resolvedKey = '' + key;
}
return {
// This tag allow us to uniquely identify this as a React Portal
$$typeof: REACT_PORTAL_TYPE,
key: key == null ? null : '' + key,
key: resolvedKey,
children,
containerInfo,
implementation,

View File

@@ -1789,4 +1789,83 @@ describe('ReactAsyncActions', () => {
});
assertLog(['reportError: Oops']);
});
// @gate enableOptimisticKey
it('reconciles against new items when optimisticKey is used', async () => {
const startTransition = React.startTransition;
function Item({text}) {
const [initialText] = React.useState(text);
return <span>{initialText + '-' + text}</span>;
}
let addOptimisticItem;
function App({items}) {
const [optimisticItems, _addOptimisticItem] = useOptimistic(
items,
(canonicalItems, optimisticText) =>
canonicalItems.concat({
id: React.optimisticKey,
text: optimisticText,
}),
);
addOptimisticItem = _addOptimisticItem;
return (
<div>
{optimisticItems.map(item => (
<Item key={item.id} text={item.text} />
))}
</div>
);
}
const A = {
id: 'a',
text: 'A',
};
const B = {
id: 'b',
text: 'B',
};
const root = ReactNoop.createRoot();
await act(() => {
root.render(<App items={[A]} />);
});
expect(root).toMatchRenderedOutput(
<div>
<span>A-A</span>
</div>,
);
// Start an async action using the non-hook form of startTransition. The
// action includes an optimistic update.
await act(() => {
startTransition(async () => {
addOptimisticItem('b');
await getText('Yield before updating');
startTransition(() => root.render(<App items={[A, B]} />));
});
});
// Because the action hasn't finished yet, the optimistic UI is shown.
expect(root).toMatchRenderedOutput(
<div>
<span>A-A</span>
<span>b-b</span>
</div>,
);
// Finish the async action. The optimistic state is reverted and replaced by
// the canonical state. The state is transferred to the new row.
await act(() => {
resolveText('Yield before updating');
});
expect(root).toMatchRenderedOutput(
<div>
<span>A-A</span>
<span>b-B</span>
</div>,
);
});
});

View File

@@ -27,6 +27,7 @@ import type {
SuspenseProps,
SuspenseListProps,
SuspenseListRevealOrder,
ReactKey,
} from 'shared/ReactTypes';
import type {LazyComponent as LazyComponentType} from 'react/src/ReactLazy';
import type {
@@ -170,6 +171,7 @@ import {
REACT_SCOPE_TYPE,
REACT_VIEW_TRANSITION_TYPE,
REACT_ACTIVITY_TYPE,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import ReactSharedInternals from 'shared/ReactSharedInternals';
import {
@@ -3253,7 +3255,7 @@ function retryNode(request: Request, task: Task): void {
case REACT_ELEMENT_TYPE: {
const element: any = node;
const type = element.type;
const key = element.key;
const key: ReactKey = element.key;
const props = element.props;
// TODO: We should get the ref off the props object right before using
@@ -3265,7 +3267,11 @@ function retryNode(request: Request, task: Task): void {
const name = getComponentNameFromType(type);
const keyOrIndex =
key == null ? (childIndex === -1 ? 0 : childIndex) : key;
key == null || key === REACT_OPTIMISTIC_KEY
? childIndex === -1
? 0
: childIndex
: key;
const keyPath = [task.keyPath, name, keyOrIndex];
if (task.replay !== null) {
if (debugTask) {

View File

@@ -65,6 +65,7 @@ import type {
ReactFunctionLocation,
ReactErrorInfo,
ReactErrorInfoDev,
ReactKey,
} from 'shared/ReactTypes';
import type {ReactElement} from 'shared/ReactElementType';
import type {LazyComponent} from 'react/src/ReactLazy';
@@ -136,6 +137,7 @@ import {
REACT_LAZY_TYPE,
REACT_MEMO_TYPE,
ASYNC_ITERATOR,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import {
@@ -534,7 +536,7 @@ type Task = {
model: ReactClientValue,
ping: () => void,
toJSON: (key: string, value: ReactClientValue) => ReactJSONValue,
keyPath: null | string, // parent server component keys
keyPath: ReactKey, // parent server component keys
implicitSlot: boolean, // true if the root server component of this sequence had a null key
formatContext: FormatContext, // an approximate parent context from host components
thenableState: ThenableState | null,
@@ -1643,7 +1645,7 @@ function processServerComponentReturnValue(
function renderFunctionComponent<Props>(
request: Request,
task: Task,
key: null | string,
key: ReactKey,
Component: (p: Props, arg: void) => any,
props: Props,
validated: number, // DEV-only
@@ -1814,7 +1816,12 @@ function renderFunctionComponent<Props>(
if (key !== null) {
// Append the key to the path. Technically a null key should really add the child
// index. We don't do that to hold the payload small and implementation simple.
task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key;
if (key === REACT_OPTIMISTIC_KEY || prevKeyPath === REACT_OPTIMISTIC_KEY) {
// The optimistic key is viral. It turns the whole key into optimistic if any part is.
task.keyPath = REACT_OPTIMISTIC_KEY;
} else {
task.keyPath = prevKeyPath === null ? key : prevKeyPath + ',' + key;
}
} else if (prevKeyPath === null) {
// This sequence of Server Components has no keys. This means that it was rendered
// in a slot that needs to assign an implicit key. Even if children below have
@@ -1830,7 +1837,7 @@ function renderFunctionComponent<Props>(
function warnForMissingKey(
request: Request,
key: null | string,
key: ReactKey,
componentDebugInfo: ReactComponentInfo,
debugTask: null | ConsoleTask,
): void {
@@ -2024,7 +2031,7 @@ function renderClientElement(
request: Request,
task: Task,
type: any,
key: null | string,
key: ReactKey,
props: any,
validated: number, // DEV-only
): ReactJSONValue {
@@ -2034,7 +2041,12 @@ function renderClientElement(
if (key === null) {
key = keyPath;
} else if (keyPath !== null) {
key = keyPath + ',' + key;
if (keyPath === REACT_OPTIMISTIC_KEY || key === REACT_OPTIMISTIC_KEY) {
// Optimistic key is viral and turns the whole key optimistic.
key = REACT_OPTIMISTIC_KEY;
} else {
key = keyPath + ',' + key;
}
}
let debugOwner = null;
let debugStack = null;
@@ -2161,7 +2173,7 @@ function renderElement(
request: Request,
task: Task,
type: any,
key: null | string,
key: ReactKey,
ref: mixed,
props: any,
validated: number, // DEV only
@@ -2667,7 +2679,7 @@ function pingTask(request: Request, task: Task): void {
function createTask(
request: Request,
model: ReactClientValue,
keyPath: null | string,
keyPath: ReactKey,
implicitSlot: boolean,
formatContext: FormatContext,
abortSet: Set<Task>,
@@ -3521,7 +3533,7 @@ function renderModelDestructive(
element._debugTask === undefined
) {
let key = '';
if (element.key !== null) {
if (element.key !== null && element.key !== REACT_OPTIMISTIC_KEY) {
key = ' key="' + element.key + '"';
}
@@ -3547,7 +3559,7 @@ function renderModelDestructive(
request,
task,
element.type,
// $FlowFixMe[incompatible-call] the key of an element is null | string
// $FlowFixMe[incompatible-call] the key of an element is null | string | ReactOptimisticKey
element.key,
ref,
props,

View File

@@ -29,6 +29,7 @@ export {
cache,
cacheSignal,
startTransition,
optimisticKey,
Activity,
unstable_getCacheForType,
unstable_SuspenseList,

View File

@@ -29,6 +29,7 @@ export {
cache,
cacheSignal,
startTransition,
optimisticKey,
Activity,
Activity as unstable_Activity,
unstable_getCacheForType,

View File

@@ -22,7 +22,9 @@ import {
REACT_ELEMENT_TYPE,
REACT_LAZY_TYPE,
REACT_PORTAL_TYPE,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import {enableOptimisticKey} from 'shared/ReactFeatureFlags';
import {checkKeyStringCoercion} from 'shared/CheckStringCoercion';
import {isValidElement, cloneAndReplaceKey} from './jsx/ReactJSXElement';
@@ -73,6 +75,13 @@ function getElementKey(element: any, index: number): string {
// Do some typechecking here since we call this blindly. We want to ensure
// that we don't block potential future ES APIs.
if (typeof element === 'object' && element !== null && element.key != null) {
if (enableOptimisticKey && element.key === REACT_OPTIMISTIC_KEY) {
// For React.Children purposes this is treated as just null.
if (__DEV__) {
console.error("React.Children helpers don't support optimisticKey.");
}
return index.toString(36);
}
// Explicit key
if (__DEV__) {
checkKeyStringCoercion(element.key);

View File

@@ -19,6 +19,7 @@ import {
REACT_SCOPE_TYPE,
REACT_TRACING_MARKER_TYPE,
REACT_VIEW_TRANSITION_TYPE,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import {Component, PureComponent} from './ReactBaseClasses';
@@ -127,6 +128,8 @@ export {
addTransitionType as addTransitionType,
// enableGestureTransition
startGestureTransition as unstable_startGestureTransition,
// enableOptimisticKey
REACT_OPTIMISTIC_KEY as optimisticKey,
// DEV-only
useId,
act,

View File

@@ -18,6 +18,7 @@ import {
REACT_SUSPENSE_LIST_TYPE,
REACT_VIEW_TRANSITION_TYPE,
REACT_ACTIVITY_TYPE,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import {
cloneElement,
@@ -82,5 +83,7 @@ export {
version,
// Experimental
REACT_SUSPENSE_LIST_TYPE as unstable_SuspenseList,
// enableOptimisticKey
REACT_OPTIMISTIC_KEY as optimisticKey,
captureOwnerStack, // DEV-only
};

View File

@@ -18,6 +18,7 @@ import {
REACT_SUSPENSE_LIST_TYPE,
REACT_VIEW_TRANSITION_TYPE,
REACT_ACTIVITY_TYPE,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import {
cloneElement,
@@ -81,4 +82,6 @@ export {
version,
// Experimental
REACT_SUSPENSE_LIST_TYPE as unstable_SuspenseList,
// enableOptimisticKey
REACT_OPTIMISTIC_KEY as optimisticKey,
};

View File

@@ -13,10 +13,11 @@ import {
REACT_ELEMENT_TYPE,
REACT_FRAGMENT_TYPE,
REACT_LAZY_TYPE,
REACT_OPTIMISTIC_KEY,
} from 'shared/ReactSymbols';
import {checkKeyStringCoercion} from 'shared/CheckStringCoercion';
import isArray from 'shared/isArray';
import {ownerStackLimit} from 'shared/ReactFeatureFlags';
import {ownerStackLimit, enableOptimisticKey} from 'shared/ReactFeatureFlags';
const createTask =
// eslint-disable-next-line react-internal/no-production-logging
@@ -297,17 +298,25 @@ export function jsxProd(type, config, maybeKey) {
// <div {...props} key="Hi" />, because we aren't currently able to tell if
// key is explicitly declared to be undefined or not.
if (maybeKey !== undefined) {
if (__DEV__) {
checkKeyStringCoercion(maybeKey);
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(maybeKey);
}
key = '' + maybeKey;
}
key = '' + maybeKey;
}
if (hasValidKey(config)) {
if (__DEV__) {
checkKeyStringCoercion(config.key);
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
key = '' + config.key;
}
let props;
@@ -536,17 +545,25 @@ function jsxDEVImpl(
// <div {...props} key="Hi" />, because we aren't currently able to tell if
// key is explicitly declared to be undefined or not.
if (maybeKey !== undefined) {
if (__DEV__) {
checkKeyStringCoercion(maybeKey);
if (enableOptimisticKey && maybeKey === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(maybeKey);
}
key = '' + maybeKey;
}
key = '' + maybeKey;
}
if (hasValidKey(config)) {
if (__DEV__) {
checkKeyStringCoercion(config.key);
if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
key = '' + config.key;
}
let props;
@@ -637,10 +654,14 @@ export function createElement(type, config, children) {
}
if (hasValidKey(config)) {
if (__DEV__) {
checkKeyStringCoercion(config.key);
if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
key = '' + config.key;
}
// Remaining properties are added to a new props object
@@ -769,10 +790,14 @@ export function cloneElement(element, config, children) {
owner = __DEV__ ? getOwner() : undefined;
}
if (hasValidKey(config)) {
if (__DEV__) {
checkKeyStringCoercion(config.key);
if (enableOptimisticKey && config.key === REACT_OPTIMISTIC_KEY) {
key = REACT_OPTIMISTIC_KEY;
} else {
if (__DEV__) {
checkKeyStringCoercion(config.key);
}
key = '' + config.key;
}
key = '' + config.key;
}
// Remaining properties override existing props

View File

@@ -98,6 +98,8 @@ export const enableHydrationChangeEvent = __EXPERIMENTAL__;
export const enableDefaultTransitionIndicator = __EXPERIMENTAL__;
export const enableOptimisticKey = __EXPERIMENTAL__;
/**
* Switches Fiber creation to a simple object instead of a constructor.
*/

View File

@@ -65,3 +65,12 @@ export function getIteratorFn(maybeIterable: ?any): ?() => ?Iterator<any> {
}
export const ASYNC_ITERATOR = Symbol.asyncIterator;
export const REACT_OPTIMISTIC_KEY: ReactOptimisticKey = (Symbol.for(
'react.optimistic_key',
): any);
// This is actually a symbol but Flow doesn't support comparison of symbols to refine.
// We use a boolean since in our code we often expect string (key) or number (index),
// so by pretending to be a boolean we cover a lot of cases that don't consider this case.
export type ReactOptimisticKey = true;

View File

@@ -7,6 +7,12 @@
* @flow
*/
import type {ReactOptimisticKey} from './ReactSymbols';
export type {ReactOptimisticKey};
export type ReactKey = null | string | ReactOptimisticKey;
export type ReactNode =
| React$Element<any>
| ReactPortal
@@ -26,7 +32,7 @@ export type ReactText = string | number;
export type ReactProvider<T> = {
$$typeof: symbol | number,
type: ReactContext<T>,
key: null | string,
key: ReactKey,
ref: null,
props: {
value: T,
@@ -42,7 +48,7 @@ export type ReactConsumerType<T> = {
export type ReactConsumer<T> = {
$$typeof: symbol | number,
type: ReactConsumerType<T>,
key: null | string,
key: ReactKey,
ref: null,
props: {
children: (value: T) => ReactNodeList,
@@ -66,7 +72,7 @@ export type ReactContext<T> = {
export type ReactPortal = {
$$typeof: symbol | number,
key: null | string,
key: ReactKey,
containerInfo: any,
children: ReactNodeList,
// TODO: figure out the API for cross-renderer implementation.
@@ -204,7 +210,7 @@ export type ReactFunctionLocation = [
export type ReactComponentInfo = {
+name: string,
+env?: string,
+key?: null | string,
+key?: ReactKey,
+owner?: null | ReactComponentInfo,
+stack?: null | ReactStackTrace,
+props?: null | {[name: string]: mixed},

View File

@@ -85,6 +85,7 @@ export const enableComponentPerformanceTrack: boolean =
export const enablePerformanceIssueReporting: boolean =
enableComponentPerformanceTrack;
export const enableInternalInstanceMap: boolean = false;
export const enableOptimisticKey: boolean = false;
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

View File

@@ -78,6 +78,8 @@ export const enableFragmentRefsInstanceHandles: boolean = false;
export const enableInternalInstanceMap: boolean = false;
export const enableOptimisticKey: boolean = false;
// Profiling Only
export const enableProfilerTimer: boolean = __PROFILE__;
export const enableProfilerCommitHooks: boolean = __PROFILE__;

View File

@@ -93,5 +93,7 @@ export const enableReactTestRendererWarning: boolean = true;
export const enableObjectFiber: boolean = false;
export const enableOptimisticKey: boolean = false;
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

View File

@@ -69,6 +69,7 @@ export const enableDefaultTransitionIndicator = true;
export const enableFragmentRefs = false;
export const enableFragmentRefsScrollIntoView = false;
export const ownerStackLimit = 1e4;
export const enableOptimisticKey = false;
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

View File

@@ -87,5 +87,7 @@ export const ownerStackLimit = 1e4;
export const enableInternalInstanceMap: boolean = false;
export const enableOptimisticKey: boolean = false;
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);

View File

@@ -114,5 +114,7 @@ export const ownerStackLimit = 1e4;
export const enableFragmentRefsInstanceHandles: boolean = true;
export const enableOptimisticKey: boolean = false;
// Flow magic to verify the exports of this file match the original version.
((((null: any): ExportsType): FeatureFlagsType): ExportsType);