Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8fb9d46c73 | ||
|
|
8ac5f4eb36 | ||
|
|
eb89912ee5 | ||
|
|
0972e23908 | ||
|
|
194c12d949 | ||
|
|
7f1a085b28 | ||
|
|
ea4899e13f | ||
|
|
b946a249b5 | ||
|
|
d6b1a0573b | ||
|
|
b315a0f713 |
@@ -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),
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'}],
|
||||
[
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
/*
|
||||
|
||||
@@ -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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 ]]"
|
||||
@@ -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: [],
|
||||
};
|
||||
@@ -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
|
||||
@@ -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},
|
||||
],
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
117
packages/react-devtools-shared/src/devtools/store.js
vendored
117
packages/react-devtools-shared/src/devtools/store.js
vendored
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
123
packages/react-reconciler/src/ReactChildFiber.js
vendored
123
packages/react-reconciler/src/ReactChildFiber.js
vendored
@@ -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 {
|
||||
|
||||
43
packages/react-reconciler/src/ReactFiber.js
vendored
43
packages/react-reconciler/src/ReactFiber.js
vendored
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
24
packages/react-reconciler/src/ReactPortal.js
vendored
24
packages/react-reconciler/src/ReactPortal.js
vendored
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
10
packages/react-server/src/ReactFizzServer.js
vendored
10
packages/react-server/src/ReactFizzServer.js
vendored
@@ -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) {
|
||||
|
||||
32
packages/react-server/src/ReactFlightServer.js
vendored
32
packages/react-server/src/ReactFlightServer.js
vendored
@@ -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,
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
cache,
|
||||
cacheSignal,
|
||||
startTransition,
|
||||
optimisticKey,
|
||||
Activity,
|
||||
unstable_getCacheForType,
|
||||
unstable_SuspenseList,
|
||||
|
||||
@@ -29,6 +29,7 @@ export {
|
||||
cache,
|
||||
cacheSignal,
|
||||
startTransition,
|
||||
optimisticKey,
|
||||
Activity,
|
||||
Activity as unstable_Activity,
|
||||
unstable_getCacheForType,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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__;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user