diff --git a/compiler/packages/babel-plugin-react-compiler/package.json b/compiler/packages/babel-plugin-react-compiler/package.json index e1f474149004e..d152768b710d7 100644 --- a/compiler/packages/babel-plugin-react-compiler/package.json +++ b/compiler/packages/babel-plugin-react-compiler/package.json @@ -1,6 +1,6 @@ { "name": "babel-plugin-react-compiler", - "version": "0.0.0-experimental-179941d-20240614", + "version": "0.0.0-experimental-696af53-20240625", "description": "Babel plugin for React Compiler.", "main": "dist/index.js", "license": "MIT", diff --git a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts index 863eca5dcf351..762f1a4112ab0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Entrypoint/Pipeline.ts @@ -13,6 +13,7 @@ import { HIRFunction, ReactiveFunction, assertConsistentIdentifiers, + assertTerminalPredsExist, assertTerminalSuccessorsExist, assertValidBlockNesting, assertValidMutableRanges, @@ -95,6 +96,7 @@ import { validatePreservedManualMemoization, validateUseMemo, } from "../Validation"; +import { validateLocalsNotReassignedAfterRender } from "../Validation/ValidateLocalsNotReassignedAfterRender"; export type CompilerPipelineValue = | { kind: "ast"; name: string; value: CodegenFunction } @@ -201,6 +203,8 @@ function* runWithEnvironment( inferReferenceEffects(hir); yield log({ kind: "hir", name: "InferReferenceEffects", value: hir }); + validateLocalsNotReassignedAfterRender(hir); + // Note: Has to come after infer reference effects because "dead" code may still affect inference deadCodeElimination(hir); yield log({ kind: "hir", name: "DeadCodeElimination", value: hir }); @@ -303,6 +307,8 @@ function* runWithEnvironment( name: "FlattenScopesWithHooksOrUseHIR", value: hir, }); + assertTerminalSuccessorsExist(hir); + assertTerminalPredsExist(hir); } const reactiveFunction = buildReactiveFunction(hir); diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalSuccessorsExist.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalBlocksExist.ts similarity index 54% rename from compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalSuccessorsExist.ts rename to compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalBlocksExist.ts index 493ff54c03fec..e5dbb1b8dbfcc 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalSuccessorsExist.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/AssertTerminalBlocksExist.ts @@ -8,7 +8,7 @@ import { CompilerError } from "../CompilerError"; import { GeneratedSource, HIRFunction } from "./HIR"; import { printTerminal } from "./PrintHIR"; -import { mapTerminalSuccessors } from "./visitors"; +import { eachTerminalSuccessor, mapTerminalSuccessors } from "./visitors"; export function assertTerminalSuccessorsExist(fn: HIRFunction): void { for (const [, block] of fn.body.blocks) { @@ -25,3 +25,23 @@ export function assertTerminalSuccessorsExist(fn: HIRFunction): void { }); } } + +export function assertTerminalPredsExist(fn: HIRFunction): void { + for (const [, block] of fn.body.blocks) { + for (const pred of block.preds) { + const predBlock = fn.body.blocks.get(pred); + CompilerError.invariant(predBlock != null, { + reason: "Expected predecessor block to exist", + description: `Block ${block.id} references non-existent ${pred}`, + loc: GeneratedSource, + }); + CompilerError.invariant( + [...eachTerminalSuccessor(predBlock.terminal)].includes(block.id), + { + reason: "Terminal successor does not reference correct predecessor", + loc: GeneratedSource, + } + ); + } + } +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts index 1c471930f620d..ae7079a6c53e4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/Globals.ts @@ -363,7 +363,7 @@ const REACT_APIS: Array<[string, BuiltInType]> = [ restParam: Effect.Freeze, returnType: { kind: "Poly" }, calleeEffect: Effect.Read, - hookKind: "useLayoutEffect", + hookKind: "useInsertionEffect", returnValueKind: ValueKind.Frozen, }, BuiltInUseInsertionEffectHookId diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts index 0a3451023cd4c..4f71c661549eb 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/ObjectShape.ts @@ -123,6 +123,7 @@ export type HookKind = | "useRef" | "useEffect" | "useLayoutEffect" + | "useInsertionEffect" | "useMemo" | "useCallback" | "Custom"; @@ -218,6 +219,36 @@ addObject(BUILTIN_SHAPES, BuiltInPropsId, [ /* Built-in array shape */ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ + [ + "indexOf", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }), + ], + [ + "includes", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }), + ], + [ + "pop", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: null, + returnType: { kind: "Poly" }, + calleeEffect: Effect.Store, + returnValueKind: ValueKind.Mutable, + }), + ], [ "at", addFunction(BUILTIN_SHAPES, [], { @@ -237,7 +268,7 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ kind: "Object", shapeId: BuiltInArrayId, }, - calleeEffect: Effect.Read, + calleeEffect: Effect.Capture, returnValueKind: ValueKind.Mutable, }), ], @@ -252,6 +283,19 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ returnValueKind: ValueKind.Primitive, }), ], + [ + "slice", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { + kind: "Object", + shapeId: BuiltInArrayId, + }, + calleeEffect: Effect.Capture, + returnValueKind: ValueKind.Mutable, + }), + ], [ "map", addFunction(BUILTIN_SHAPES, [], { @@ -353,7 +397,7 @@ addObject(BUILTIN_SHAPES, BuiltInArrayId, [ "join", addFunction(BUILTIN_SHAPES, [], { positionalParams: [], - restParam: Effect.ConditionallyMutate, + restParam: Effect.Read, returnType: PRIMITIVE_TYPE, calleeEffect: Effect.Read, returnValueKind: ValueKind.Primitive, @@ -478,6 +522,90 @@ addObject(BUILTIN_SHAPES, BuiltInMixedReadonlyId, [ noAlias: true, }), ], + [ + "concat", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Capture, + returnType: { + kind: "Object", + shapeId: BuiltInArrayId, + }, + calleeEffect: Effect.Capture, + returnValueKind: ValueKind.Mutable, + }), + ], + [ + "slice", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: { + kind: "Object", + shapeId: BuiltInArrayId, + }, + calleeEffect: Effect.Capture, + returnValueKind: ValueKind.Mutable, + }), + ], + [ + "every", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Primitive, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "some", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Primitive, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "find", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Poly" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Mutable, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "findIndex", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.ConditionallyMutate, + returnType: { kind: "Primitive" }, + calleeEffect: Effect.ConditionallyMutate, + returnValueKind: ValueKind.Primitive, + noAlias: true, + mutableOnlyIfOperandsAreMutable: true, + }), + ], + [ + "join", + addFunction(BUILTIN_SHAPES, [], { + positionalParams: [], + restParam: Effect.Read, + returnType: PRIMITIVE_TYPE, + calleeEffect: Effect.Read, + returnValueKind: ValueKind.Primitive, + }), + ], ["*", { kind: "Object", shapeId: BuiltInMixedReadonlyId }], ]); diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts index 443a2522ee18b..df7d2698f4b29 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PrintHIR.ts @@ -194,7 +194,7 @@ export function printTerminal(terminal: Terminal): Array | string { break; } case "optional": { - value = `[${terminal.id}] Optional test:bb${terminal.test} fallthrough=bb${terminal.fallthrough}`; + value = `[${terminal.id}] Optional (optional=${terminal.optional}) test:bb${terminal.test} fallthrough=bb${terminal.fallthrough}`; break; } case "throw": { diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts index 10714d5d87971..aef414b2d2b24 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/PruneUnusedLabelsHIR.ts @@ -67,4 +67,14 @@ export function pruneUnusedLabelsHIR(fn: HIRFunction): void { fn.body.blocks.delete(fallthroughId); rewrites.set(fallthroughId, labelId); } + + for (const [_, block] of fn.body.blocks) { + for (const pred of block.preds) { + const rewritten = rewrites.get(pred); + if (rewritten != null) { + block.preds.delete(pred); + block.preds.add(rewritten); + } + } + } } diff --git a/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts b/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts index b17d3a09d08b4..bef4f1cb95d28 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/HIR/index.ts @@ -6,7 +6,10 @@ */ export { assertConsistentIdentifiers } from "./AssertConsistentIdentifiers"; -export { assertTerminalSuccessorsExist } from "./AssertTerminalSuccessorsExist"; +export { + assertTerminalSuccessorsExist, + assertTerminalPredsExist, +} from "./AssertTerminalBlocksExist"; export { assertValidBlockNesting } from "./AssertValidBlockNesting"; export { assertValidMutableRanges } from "./AssertValidMutableRanges"; export { lower } from "./BuildHIR"; diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts index 3e451c23c7807..3d78d99cb10ed 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/AlignReactiveScopesToBlockScopesHIR.ts @@ -22,8 +22,10 @@ import { mapTerminalSuccessors, terminalFallthrough, } from "../HIR/visitors"; +import { retainWhere_Set } from "../Utils/utils"; import { getPlaceScope } from "./BuildReactiveBlocks"; +type InstructionRange = MutableRange; /* * Note: this is the 2nd of 4 passes that determine how to break a function into discrete * reactive scopes (independently memoizeable units of code): @@ -66,18 +68,20 @@ import { getPlaceScope } from "./BuildReactiveBlocks"; * will be the updated end for that scope). */ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { - const blockNodes = new Map(); - const rootNode: BlockNode = { - kind: "node", - valueRange: null, - children: [], - id: makeInstructionId(0), - }; - blockNodes.set(fn.body.entry, rootNode); + const activeBlockFallthroughRanges: Array<{ + range: InstructionRange; + fallthrough: BlockId; + }> = []; + const activeScopes = new Set(); const seen = new Set(); + const valueBlockNodes = new Map(); const placeScopes = new Map(); - function recordPlace(id: InstructionId, place: Place, node: BlockNode): void { + function recordPlace( + id: InstructionId, + place: Place, + node: ValueBlockNode | null + ): void { if (place.identifier.scope !== null) { placeScopes.set(place, place.identifier.scope); } @@ -86,13 +90,14 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { if (scope == null) { return; } - node.children.push({ kind: "scope", scope, id }); + activeScopes.add(scope); + node?.children.push({ kind: "scope", scope, id }); if (seen.has(scope)) { return; } seen.add(scope); - if (node.valueRange !== null) { + if (node != null && node.valueRange !== null) { scope.range.start = makeInstructionId( Math.min(node.valueRange.start, scope.range.start) ); @@ -103,16 +108,25 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { } for (const [, block] of fn.body.blocks) { - const { instructions, terminal } = block; - const node = blockNodes.get(block.id); - if (node === undefined) { - CompilerError.invariant(false, { - reason: `Expected a node to be initialized for block`, - loc: instructions[0]?.loc ?? terminal.loc, - description: `No node for block bb${block.id} (${block.kind})`, - }); + const startingId = block.instructions[0]?.id ?? block.terminal.id; + retainWhere_Set(activeScopes, (scope) => scope.range.end > startingId); + const top = activeBlockFallthroughRanges.at(-1); + if (top?.fallthrough === block.id) { + activeBlockFallthroughRanges.pop(); + /* + * All active scopes must have either started before or within the last + * block-fallthrough range. In either case, they overlap this block- + * fallthrough range and can have their ranges extended. + */ + for (const scope of activeScopes) { + scope.range.start = makeInstructionId( + Math.min(scope.range.start, top.range.start) + ); + } } + const { instructions, terminal } = block; + const node = valueBlockNodes.get(block.id) ?? null; for (const instr of instructions) { for (const lvalue of eachInstructionLValue(instr)) { recordPlace(instr.id, lvalue, node); @@ -125,36 +139,42 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { recordPlace(terminal.id, operand, node); } - // Save the current node for the fallback block, where this block scope continues const fallthrough = terminalFallthrough(terminal); - if (fallthrough !== null && !blockNodes.has(fallthrough)) { + if (fallthrough !== null) { /* - * Any scopes that carried over across a terminal->fallback need their range extended - * to at least the first instruction of the fallback - * - * Note that it's possible for a terminal such as an if or switch to have a null fallback, - * indicating that all control-flow paths diverge instead of reaching the fallthrough. - * In this case there isn't an instruction id in the program that we can point to for the - * updated range. Since the output is correct in this case we leave it, but it would be - * more correct to find the maximum instuction id in the whole program and set the range.end - * to one greater. Alternatively, we could leave in an unreachable fallthrough (with a new - * "unreachable" terminal variant, perhaps) and use that instruction id. + * Any currently active scopes that overlaps the block-fallthrough range + * need their range extended to at least the first instruction of the + * fallthrough */ const fallthroughBlock = fn.body.blocks.get(fallthrough)!; const nextId = fallthroughBlock.instructions[0]?.id ?? fallthroughBlock.terminal.id; - for (const child of node.children) { - if (child.kind !== "scope") { - continue; - } - const scope = child.scope; + for (const scope of activeScopes) { if (scope.range.end > terminal.id) { scope.range.end = makeInstructionId( Math.max(scope.range.end, nextId) ); } } - blockNodes.set(fallthrough, node); + /** + * We also record the block-fallthrough range for future scopes that begin + * within the range (and overlap with the range end). + */ + activeBlockFallthroughRanges.push({ + fallthrough, + range: { + start: terminal.id, + end: nextId, + }, + }); + + CompilerError.invariant(!valueBlockNodes.has(fallthrough), { + reason: "Expect hir blocks to have unique fallthroughs", + loc: terminal.loc, + }); + if (node != null) { + valueBlockNodes.set(fallthrough, node); + } } /* @@ -166,48 +186,35 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { * just those that are direct successors for normal control-flow ordering. */ mapTerminalSuccessors(terminal, (successor) => { - if (blockNodes.has(successor)) { + if (valueBlockNodes.has(successor)) { return successor; } const successorBlock = fn.body.blocks.get(successor)!; - /* - * we need the block kind check here because the do..while terminal's successor - * is a block, and try's successor is a catch block - */ if (successorBlock.kind === "block" || successorBlock.kind === "catch") { - const childNode: BlockNode = { - kind: "node", - id: terminal.id, - children: [], - valueRange: null, - }; - node.children.push(childNode); - blockNodes.set(successor, childNode); + /* + * we need the block kind check here because the do..while terminal's + * successor is a block, and try's successor is a catch block + */ } else if ( - node.valueRange === null || + node == null || terminal.kind === "ternary" || terminal.kind === "logical" || terminal.kind === "optional" ) { /** - * Create a new scope node whenever we transition from block scope -> value scope. + * Create a new node whenever we transition from non-value -> value block. * * For compatibility with the previous ReactiveFunction-based scope merging logic, * we also create new scope nodes for ternary, logical, and optional terminals. - * However, inside value blocks we always store a range (valueRange) that is the + * Inside value blocks we always store a range (valueRange) that is the * start/end instruction ids at the nearest parent block scope level, so that * scopes inside the value blocks can be extended to align with block scope * instructions. */ - const childNode = { - kind: "node", - id: terminal.id, - children: [], - valueRange: null, - } as BlockNode; - if (node.valueRange === null) { - // Transition from block->value scope, derive the outer block scope range + let valueRange: MutableRange; + if (node == null) { + // Transition from block->value block, derive the outer block range CompilerError.invariant(fallthrough !== null, { reason: `Expected a fallthrough for value block`, loc: terminal.loc, @@ -216,32 +223,36 @@ export function alignReactiveScopesToBlockScopesHIR(fn: HIRFunction): void { const nextId = fallthroughBlock.instructions[0]?.id ?? fallthroughBlock.terminal.id; - childNode.valueRange = { + valueRange = { start: terminal.id, end: nextId, }; } else { // else value->value transition, reuse the range - childNode.valueRange = node.valueRange; + valueRange = node.valueRange; } - node.children.push(childNode); - blockNodes.set(successor, childNode); + const childNode: ValueBlockNode = { + kind: "node", + id: terminal.id, + children: [], + valueRange, + }; + node?.children.push(childNode); + valueBlockNodes.set(successor, childNode); } else { // this is a value -> value block transition, reuse the node - blockNodes.set(successor, node); + valueBlockNodes.set(successor, node); } return successor; }); } - - // console.log(_debug(rootNode)); } -type BlockNode = { +type ValueBlockNode = { kind: "node"; id: InstructionId; - valueRange: MutableRange | null; - children: Array; + valueRange: MutableRange; + children: Array; }; type ReactiveScopeNode = { kind: "scope"; @@ -249,13 +260,13 @@ type ReactiveScopeNode = { scope: ReactiveScope; }; -function _debug(node: BlockNode): string { +function _debug(node: ValueBlockNode): string { const buf: Array = []; _printNode(node, buf, 0); return buf.join("\n"); } function _printNode( - node: BlockNode | ReactiveScopeNode, + node: ValueBlockNode | ReactiveScopeNode, out: Array, depth: number = 0 ): void { @@ -265,10 +276,7 @@ function _printNode( `${prefix}[${node.id}] @${node.scope.id} [${node.scope.range.start}:${node.scope.range.end}]` ); } else { - let range = - node.valueRange !== null - ? ` [${node.valueRange.start}:${node.valueRange.end}]` - : ""; + let range = ` (range=[${node.valueRange.start}:${node.valueRange.end}])`; out.push(`${prefix}[${node.id}] node${range} [`); for (const child of node.children) { _printNode(child, out, depth + 1); diff --git a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts index ebc1d63c6d881..4a5176c7d6ed9 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/ReactiveScopes/BuildReactiveFunction.ts @@ -921,14 +921,26 @@ class Driver { }); } else if (defaultBlock.instructions.length === 1) { const instr = defaultBlock.instructions[0]!; - let place: Place = instr.lvalue!; + let place: Place = instr.lvalue; let value: ReactiveValue = instr.value; - if (instr.value.kind === "StoreLocal") { - place = instr.value.lvalue.place; + if ( + /* + * Value blocks generally end in a StoreLocal to assign the value of the + * expression for this branch. These StoreLocal instructions can be pruned, + * since we represent the value blocks as a compund value in ReactiveFunction + * (no phis). However, it's also possible to have a value block that ends in + * an AssignmentExpression, which we need to keep. So we only prune + * StoreLocal for temporaries — any named/promoted values must be used + * elsewhere and aren't safe to prune. + */ + value.kind === "StoreLocal" && + value.lvalue.place.identifier.name === null + ) { + place = value.lvalue.place; value = { kind: "LoadLocal", - place: instr.value.value, - loc: instr.value.value.loc, + place: value.value, + loc: value.value.loc, }; } return { @@ -939,14 +951,26 @@ class Driver { }; } else { const instr = defaultBlock.instructions.at(-1)!; - let place: Place = instr.lvalue!; + let place: Place = instr.lvalue; let value: ReactiveValue = instr.value; - if (instr.value.kind === "StoreLocal") { - place = instr.value.lvalue.place; + if ( + /* + * Value blocks generally end in a StoreLocal to assign the value of the + * expression for this branch. These StoreLocal instructions can be pruned, + * since we represent the value blocks as a compund value in ReactiveFunction + * (no phis). However, it's also possible to have a value block that ends in + * an AssignmentExpression, which we need to keep. So we only prune + * StoreLocal for temporaries — any named/promoted values must be used + * elsewhere and aren't safe to prune. + */ + value.kind === "StoreLocal" && + value.lvalue.place.identifier.name === null + ) { + place = value.lvalue.place; value = { kind: "LoadLocal", - place: instr.value.value, - loc: instr.value.value.loc, + place: value.value, + loc: value.value.loc, }; } const sequence: ReactiveSequenceValue = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts index c02e813255676..921a6bf2097d0 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Utils/utils.ts @@ -45,6 +45,17 @@ export function retainWhere( array.length = writeIndex; } +export function retainWhere_Set( + items: Set, + predicate: (item: T) => boolean +): void { + for (const item of items) { + if (!predicate(item)) { + items.delete(item); + } + } +} + export function getOrInsertWith( m: Map, key: U, diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts new file mode 100644 index 0000000000000..f36f7adfd901f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidateLocalsNotReassignedAfterRender.ts @@ -0,0 +1,165 @@ +/** + * 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 { CompilerError, Effect } from ".."; +import { HIRFunction, IdentifierId, Place } from "../HIR"; +import { + eachInstructionValueOperand, + eachTerminalOperand, +} from "../HIR/visitors"; + +/** + * Validates that local variables cannot be reassigned after render. + * This prevents a category of bugs in which a closure captures a + * binding from one render but does not update + */ +export function validateLocalsNotReassignedAfterRender(fn: HIRFunction): void { + const contextVariables = new Set(); + const reassignment = getContextReassignment( + fn, + contextVariables, + false, + false + ); + if (reassignment !== null) { + CompilerError.throwInvalidReact({ + reason: + "Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead", + description: + reassignment.identifier.name !== null && + reassignment.identifier.name.kind === "named" + ? `Variable \`${reassignment.identifier.name.value}\` cannot be reassigned after render` + : "", + loc: reassignment.loc, + }); + } +} + +function getContextReassignment( + fn: HIRFunction, + contextVariables: Set, + isFunctionExpression: boolean, + isAsync: boolean +): Place | null { + const reassigningFunctions = new Map(); + for (const [, block] of fn.body.blocks) { + for (const instr of block.instructions) { + const { lvalue, value } = instr; + switch (value.kind) { + case "FunctionExpression": + case "ObjectMethod": { + let reassignment = getContextReassignment( + value.loweredFunc.func, + contextVariables, + true, + isAsync || value.loweredFunc.func.async + ); + if (reassignment === null) { + // If the function itself doesn't reassign, does one of its dependencies? + for (const operand of eachInstructionValueOperand(value)) { + const reassignmentFromOperand = reassigningFunctions.get( + operand.identifier.id + ); + if (reassignmentFromOperand !== undefined) { + reassignment = reassignmentFromOperand; + break; + } + } + } + // if the function or its depends reassign, propagate that fact on the lvalue + if (reassignment !== null) { + if (isAsync || value.loweredFunc.func.async) { + CompilerError.throwInvalidReact({ + reason: + "Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead", + description: + reassignment.identifier.name !== null && + reassignment.identifier.name.kind === "named" + ? `Variable \`${reassignment.identifier.name.value}\` cannot be reassigned after render` + : "", + loc: reassignment.loc, + }); + } + reassigningFunctions.set(lvalue.identifier.id, reassignment); + } + break; + } + case "StoreLocal": { + const reassignment = reassigningFunctions.get( + value.value.identifier.id + ); + if (reassignment !== undefined) { + reassigningFunctions.set( + value.lvalue.place.identifier.id, + reassignment + ); + reassigningFunctions.set(lvalue.identifier.id, reassignment); + } + break; + } + case "LoadLocal": { + const reassignment = reassigningFunctions.get( + value.place.identifier.id + ); + if (reassignment !== undefined) { + reassigningFunctions.set(lvalue.identifier.id, reassignment); + } + break; + } + case "DeclareContext": { + if (!isFunctionExpression) { + contextVariables.add(value.lvalue.place.identifier.id); + } + break; + } + case "StoreContext": { + if (isFunctionExpression) { + if (contextVariables.has(value.lvalue.place.identifier.id)) { + return value.lvalue.place; + } + } else { + /* + * We only track reassignments of variables defined in the outer + * component or hook. + */ + contextVariables.add(value.lvalue.place.identifier.id); + } + break; + } + default: { + for (const operand of eachInstructionValueOperand(value)) { + CompilerError.invariant(operand.effect !== Effect.Unknown, { + reason: `Expected effects to be inferred prior to ValidateLocalsNotReassignedAfterRender`, + loc: operand.loc, + }); + const reassignment = reassigningFunctions.get( + operand.identifier.id + ); + if ( + reassignment !== undefined && + operand.effect === Effect.Freeze + ) { + /* + * Functions that reassign local variables are inherently mutable and are unsafe to pass + * to a place that expects a frozen value. Propagate the reassignment upward. + */ + return reassignment; + } + } + break; + } + } + } + for (const operand of eachTerminalOperand(block.terminal)) { + const reassignment = reassigningFunctions.get(operand.identifier.id); + if (reassignment !== undefined) { + return reassignment; + } + } + } + return null; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts index b15ef94d92e58..365e3aa88d4af 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/Validation/ValidatePreservedManualMemoization.ts @@ -280,7 +280,13 @@ class Visitor extends ReactiveFunctionVisitor { scopeMapping = new Map(); temporaries: Map = new Map(); - collectMaybeMemoDependencies( + /** + * Recursively visit values and instructions to collect declarations + * and property loads. + * @returns a @{ManualMemoDependency} representing the variable + + * property reads represented by @value + */ + recordDepsInValue( value: ReactiveValue, state: VisitorState ): ManualMemoDependency | null { @@ -289,16 +295,28 @@ class Visitor extends ReactiveFunctionVisitor { for (const instr of value.instructions) { this.visitInstruction(instr, state); } - const result = this.collectMaybeMemoDependencies(value.value, state); - + const result = this.recordDepsInValue(value.value, state); return result; } case "OptionalExpression": { - return this.collectMaybeMemoDependencies(value.value, state); + return this.recordDepsInValue(value.value, state); + } + case "ReactiveFunctionValue": { + CompilerError.throwTodo({ + reason: + "Handle ReactiveFunctionValue in ValidatePreserveManualMemoization", + loc: value.loc, + }); + } + case "ConditionalExpression": { + this.recordDepsInValue(value.test, state); + this.recordDepsInValue(value.consequent, state); + this.recordDepsInValue(value.alternate, state); + return null; } - case "ReactiveFunctionValue": - case "ConditionalExpression": case "LogicalExpression": { + this.recordDepsInValue(value.left, state); + this.recordDepsInValue(value.right, state); return null; } default: { @@ -336,7 +354,7 @@ class Visitor extends ReactiveFunctionVisitor { state.manualMemoState.decls.add(lvalId); } - const maybeDep = this.collectMaybeMemoDependencies(value, state); + const maybeDep = this.recordDepsInValue(value, state); if (lvalId != null) { if (maybeDep != null) { temporaries.set(lvalId, maybeDep); @@ -400,7 +418,10 @@ class Visitor extends ReactiveFunctionVisitor { instruction: ReactiveInstruction, state: VisitorState ): void { - this.traverseInstruction(instruction, state); + /** + * We don't invoke traverseInstructions because `recordDepsInValue` + * recursively visits ReactiveValues and instructions + */ this.recordTemporaries(instruction, state); if (instruction.value.kind === "StartMemoize") { let depsFromSource: Array | null = null; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.expect.md new file mode 100644 index 0000000000000..d386efbc310a6 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.expect.md @@ -0,0 +1,76 @@ + +## Input + +```javascript +import { mutate } from "shared-runtime"; + +/** + * Similar fixture to `align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + let s = null; + if (cond) { + s = {}; + } else { + return null; + } + mutate(s); + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { mutate } from "shared-runtime"; + +/** + * Similar fixture to `align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + const $ = _c(3); + let s; + let t0; + if ($[0] !== cond) { + t0 = Symbol.for("react.early_return_sentinel"); + bb0: { + if (cond) { + s = {}; + } else { + t0 = null; + break bb0; + } + + mutate(s); + } + $[0] = cond; + $[1] = t0; + $[2] = s; + } else { + t0 = $[1]; + s = $[2]; + } + if (t0 !== Symbol.for("react.early_return_sentinel")) { + return t0; + } + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; + +``` + +### Eval output +(kind: ok) {"wat0":"joe"} \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.ts new file mode 100644 index 0000000000000..03b73fa179192 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scope-starts-within-cond.ts @@ -0,0 +1,21 @@ +import { mutate } from "shared-runtime"; + +/** + * Similar fixture to `align-scopes-nested-block-structure`, but + * a simpler case. + */ +function useFoo(cond) { + let s = null; + if (cond) { + s = {}; + } else { + return null; + } + mutate(s); + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [true], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.expect.md new file mode 100644 index 0000000000000..449beb18fd81c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.expect.md @@ -0,0 +1,55 @@ + +## Input + +```javascript +import { getNull } from "shared-runtime"; + +function Component(props) { + const items = (() => { + return getNull() ?? []; + })(); + items.push(props.a); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: {} }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { getNull } from "shared-runtime"; + +function Component(props) { + const $ = _c(3); + let t0; + let items; + if ($[0] !== props.a) { + t0 = getNull() ?? []; + items = t0; + + items.push(props.a); + $[0] = props.a; + $[1] = items; + $[2] = t0; + } else { + items = $[1]; + t0 = $[2]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ a: {} }], +}; + +``` + +### Eval output +(kind: ok) [{}] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.js rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-iife-return-modified-later-logical.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.expect.md new file mode 100644 index 0000000000000..b06f495cd7272 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.expect.md @@ -0,0 +1,169 @@ + +## Input + +```javascript +import { mutate } from "shared-runtime"; +/** + * Fixture showing that it's not sufficient to only align direct scoped + * accesses of a block-fallthrough pair. + * Below is a simplified view of HIR blocks in this fixture. + * Note that here, s is mutated in both bb1 and bb4. However, neither + * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. + * + * This means that we need to recursively visit all scopes accessed between + * a block and its fallthrough and extend the range of those scopes which overlap + * with an active block/fallthrough pair, + * + * bb0 + * ┌──────────────┐ + * │let s = null │ + * │test cond1 │ + * │ │ + * └┬─────────────┘ + * │ bb1 + * ├─►┌───────┐ + * │ │s = {} ├────┐ + * │ └───────┘ │ + * │ bb2 │ + * └─►┌───────┐ │ + * │return;│ │ + * └───────┘ │ + * bb3 │ + * ┌──────────────┐◄┘ + * │test cond2 │ + * │ │ + * └┬─────────────┘ + * │ bb4 + * ├─►┌─────────┐ + * │ │mutate(s)├─┐ + * ▼ └─────────┘ │ + * bb5 │ + * ┌───────────┐ │ + * │return s; │◄──┘ + * └───────────┘ + */ +function useFoo({ cond1, cond2 }) { + let s = null; + if (cond1) { + s = {}; + } else { + return null; + } + + if (cond2) { + mutate(s); + } + + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond1: true, cond2: false }], + sequentialRenders: [ + { cond1: true, cond2: false }, + { cond1: true, cond2: false }, + { cond1: true, cond2: true }, + { cond1: true, cond2: true }, + { cond1: false, cond2: true }, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { mutate } from "shared-runtime"; +/** + * Fixture showing that it's not sufficient to only align direct scoped + * accesses of a block-fallthrough pair. + * Below is a simplified view of HIR blocks in this fixture. + * Note that here, s is mutated in both bb1 and bb4. However, neither + * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. + * + * This means that we need to recursively visit all scopes accessed between + * a block and its fallthrough and extend the range of those scopes which overlap + * with an active block/fallthrough pair, + * + * bb0 + * ┌──────────────┐ + * │let s = null │ + * │test cond1 │ + * │ │ + * └┬─────────────┘ + * │ bb1 + * ├─►┌───────┐ + * │ │s = {} ├────┐ + * │ └───────┘ │ + * │ bb2 │ + * └─►┌───────┐ │ + * │return;│ │ + * └───────┘ │ + * bb3 │ + * ┌──────────────┐◄┘ + * │test cond2 │ + * │ │ + * └┬─────────────┘ + * │ bb4 + * ├─►┌─────────┐ + * │ │mutate(s)├─┐ + * ▼ └─────────┘ │ + * bb5 │ + * ┌───────────┐ │ + * │return s; │◄──┘ + * └───────────┘ + */ +function useFoo(t0) { + const $ = _c(4); + const { cond1, cond2 } = t0; + let s; + let t1; + if ($[0] !== cond1 || $[1] !== cond2) { + t1 = Symbol.for("react.early_return_sentinel"); + bb0: { + if (cond1) { + s = {}; + } else { + t1 = null; + break bb0; + } + if (cond2) { + mutate(s); + } + } + $[0] = cond1; + $[1] = cond2; + $[2] = t1; + $[3] = s; + } else { + t1 = $[2]; + s = $[3]; + } + if (t1 !== Symbol.for("react.early_return_sentinel")) { + return t1; + } + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond1: true, cond2: false }], + sequentialRenders: [ + { cond1: true, cond2: false }, + { cond1: true, cond2: false }, + { cond1: true, cond2: true }, + { cond1: true, cond2: true }, + { cond1: false, cond2: true }, + ], +}; + +``` + +### Eval output +(kind: ok) {} +{} +{"wat0":"joe"} +{"wat0":"joe"} +null \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.ts new file mode 100644 index 0000000000000..3087f041a553e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-nested-block-structure.ts @@ -0,0 +1,66 @@ +import { mutate } from "shared-runtime"; +/** + * Fixture showing that it's not sufficient to only align direct scoped + * accesses of a block-fallthrough pair. + * Below is a simplified view of HIR blocks in this fixture. + * Note that here, s is mutated in both bb1 and bb4. However, neither + * bb1 nor bb4 have terminal fallthroughs or are fallthroughs themselves. + * + * This means that we need to recursively visit all scopes accessed between + * a block and its fallthrough and extend the range of those scopes which overlap + * with an active block/fallthrough pair, + * + * bb0 + * ┌──────────────┐ + * │let s = null │ + * │test cond1 │ + * │ │ + * └┬─────────────┘ + * │ bb1 + * ├─►┌───────┐ + * │ │s = {} ├────┐ + * │ └───────┘ │ + * │ bb2 │ + * └─►┌───────┐ │ + * │return;│ │ + * └───────┘ │ + * bb3 │ + * ┌──────────────┐◄┘ + * │test cond2 │ + * │ │ + * └┬─────────────┘ + * │ bb4 + * ├─►┌─────────┐ + * │ │mutate(s)├─┐ + * ▼ └─────────┘ │ + * bb5 │ + * ┌───────────┐ │ + * │return s; │◄──┘ + * └───────────┘ + */ +function useFoo({ cond1, cond2 }) { + let s = null; + if (cond1) { + s = {}; + } else { + return null; + } + + if (cond2) { + mutate(s); + } + + return s; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond1: true, cond2: false }], + sequentialRenders: [ + { cond1: true, cond2: false }, + { cond1: true, cond2: false }, + { cond1: true, cond2: true }, + { cond1: true, cond2: true }, + { cond1: false, cond2: true }, + ], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md new file mode 100644 index 0000000000000..906c15092e076 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.expect.md @@ -0,0 +1,84 @@ + +## Input + +```javascript +function useFoo({ cond }) { + let items: any = {}; + b0: { + if (cond) { + // Mutable range of `items` begins here, but its reactive scope block + // should be aligned to above the if-branch + items = []; + } else { + break b0; + } + items.push(2); + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true }], + sequentialRenders: [ + { cond: true }, + { cond: true }, + { cond: false }, + { cond: false }, + { cond: true }, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function useFoo(t0) { + const $ = _c(3); + const { cond } = t0; + let t1; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + t1 = {}; + $[0] = t1; + } else { + t1 = $[0]; + } + let items = t1; + bb0: if ($[1] !== cond) { + if (cond) { + items = []; + } else { + break bb0; + } + + items.push(2); + $[1] = cond; + $[2] = items; + } else { + items = $[2]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true }], + sequentialRenders: [ + { cond: true }, + { cond: true }, + { cond: false }, + { cond: false }, + { cond: true }, + ], +}; + +``` + +### Eval output +(kind: ok) [2] +[2] +{} +{} +[2] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-if.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.expect.md new file mode 100644 index 0000000000000..dd5a9a1926d8f --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.expect.md @@ -0,0 +1,79 @@ + +## Input + +```javascript +import { arrayPush } from "shared-runtime"; + +function useFoo({ cond, value }) { + let items; + label: { + items = []; + // Mutable range of `items` begins here, but its reactive scope block + // should be aligned to above the label-block + if (cond) break label; + arrayPush(items, value); + } + arrayPush(items, value); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true, value: 2 }], + sequentialRenders: [ + { cond: true, value: 2 }, + { cond: true, value: 2 }, + { cond: true, value: 3 }, + { cond: false, value: 3 }, + ], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { arrayPush } from "shared-runtime"; + +function useFoo(t0) { + const $ = _c(3); + const { cond, value } = t0; + let items; + if ($[0] !== cond || $[1] !== value) { + bb0: { + items = []; + if (cond) { + break bb0; + } + arrayPush(items, value); + } + + arrayPush(items, value); + $[0] = cond; + $[1] = value; + $[2] = items; + } else { + items = $[2]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ cond: true, value: 2 }], + sequentialRenders: [ + { cond: true, value: 2 }, + { cond: true, value: 2 }, + { cond: true, value: 3 }, + { cond: false, value: 3 }, + ], +}; + +``` + +### Eval output +(kind: ok) [2] +[2] +[3] +[3,3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.ts similarity index 100% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-label.ts diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md new file mode 100644 index 0000000000000..80a0349b3e8c0 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.expect.md @@ -0,0 +1,65 @@ + +## Input + +```javascript +import { arrayPush, mutate } from "shared-runtime"; + +function useFoo({ value }) { + let items = null; + try { + // Mutable range of `items` begins here, but its reactive scope block + // should be aligned to above the try-block + items = []; + arrayPush(items, value); + } catch { + // ignore + } + mutate(items); + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ value: 2 }], + sequentialRenders: [{ value: 2 }, { value: 2 }, { value: 3 }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { arrayPush, mutate } from "shared-runtime"; + +function useFoo(t0) { + const $ = _c(2); + const { value } = t0; + let items; + if ($[0] !== value) { + try { + items = []; + arrayPush(items, value); + } catch {} + + mutate(items); + $[0] = value; + $[1] = items; + } else { + items = $[1]; + } + return items; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useFoo, + params: [{ value: 2 }], + sequentialRenders: [{ value: 2 }, { value: 2 }, { value: 3 }], +}; + +``` + +### Eval output +(kind: ok) [2] +[2] +[3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.ts similarity index 89% rename from compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.ts rename to compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.ts index 0a4a7eab6c73d..378cdff83a3ee 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.ts +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-reactive-scope-overlaps-try.ts @@ -1,4 +1,4 @@ -import { arrayPush } from "shared-runtime"; +import { arrayPush, mutate } from "shared-runtime"; function useFoo({ value }) { let items = null; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.expect.md new file mode 100644 index 0000000000000..b8f525d6b98cf --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.expect.md @@ -0,0 +1,61 @@ + +## Input + +```javascript +import { CONST_TRUE, makeObject_Primitives } from "shared-runtime"; + +function Foo() { + try { + let thing = null; + if (cond) { + thing = makeObject_Primitives(); + } + if (CONST_TRUE) { + mutate(thing); + } + return thing; + } catch {} +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { CONST_TRUE, makeObject_Primitives } from "shared-runtime"; + +function Foo() { + const $ = _c(1); + try { + let thing; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + thing = null; + if (cond) { + thing = makeObject_Primitives(); + } + if (CONST_TRUE) { + mutate(thing); + } + $[0] = thing; + } else { + thing = $[0]; + } + return thing; + } catch {} +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; + +``` + +### Eval output +(kind: ok) \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.ts new file mode 100644 index 0000000000000..2cc042094463e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/align-scopes-trycatch-nested-overlapping-range.ts @@ -0,0 +1,19 @@ +import { CONST_TRUE, makeObject_Primitives } from "shared-runtime"; + +function Foo() { + try { + let thing = null; + if (cond) { + thing = makeObject_Primitives(); + } + if (CONST_TRUE) { + mutate(thing); + } + return thing; + } catch {} +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.expect.md new file mode 100644 index 0000000000000..6d34414bcd3ac --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.expect.md @@ -0,0 +1,68 @@ + +## Input + +```javascript +import { mutate } from "shared-runtime"; + +/** + * Fixture showing why `concat` needs to capture both the callee and rest args. + * Here, observe that arr1's values are captured into arr2. + * - Later mutations of arr2 may write to values within arr1. + * - Observe that it's technically valid to separately memoize the array arr1 + * itself. + */ +function Foo({ inputNum }) { + const arr1: Array = [{ a: 1 }, {}]; + const arr2 = arr1.concat([1, inputNum]); + mutate(arr2[0]); + return arr2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ inputNum: 2 }], + sequentialRenders: [{ inputNum: 2 }, { inputNum: 3 }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { mutate } from "shared-runtime"; + +/** + * Fixture showing why `concat` needs to capture both the callee and rest args. + * Here, observe that arr1's values are captured into arr2. + * - Later mutations of arr2 may write to values within arr1. + * - Observe that it's technically valid to separately memoize the array arr1 + * itself. + */ +function Foo(t0) { + const $ = _c(2); + const { inputNum } = t0; + let arr2; + if ($[0] !== inputNum) { + const arr1 = [{ a: 1 }, {}]; + arr2 = arr1.concat([1, inputNum]); + mutate(arr2[0]); + $[0] = inputNum; + $[1] = arr2; + } else { + arr2 = $[1]; + } + return arr2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ inputNum: 2 }], + sequentialRenders: [{ inputNum: 2 }, { inputNum: 3 }], +}; + +``` + +### Eval output +(kind: ok) [{"a":1,"wat0":"joe"},{},1,2] +[{"a":1,"wat0":"joe"},{},1,3] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.ts new file mode 100644 index 0000000000000..f9b5ed619c23e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/array-concat-should-capture.ts @@ -0,0 +1,21 @@ +import { mutate } from "shared-runtime"; + +/** + * Fixture showing why `concat` needs to capture both the callee and rest args. + * Here, observe that arr1's values are captured into arr2. + * - Later mutations of arr2 may write to values within arr1. + * - Observe that it's technically valid to separately memoize the array arr1 + * itself. + */ +function Foo({ inputNum }) { + const arr1: Array = [{ a: 1 }, {}]; + const arr2 = arr1.concat([1, inputNum]); + mutate(arr2[0]); + return arr2; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{ inputNum: 2 }], + sequentialRenders: [{ inputNum: 2 }, { inputNum: 3 }], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.expect.md new file mode 100644 index 0000000000000..535018bf76b33 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.expect.md @@ -0,0 +1,92 @@ + +## Input + +```javascript +import { makeArray, print } from "shared-runtime"; + +/** + * Exposes bug involving iife inlining + codegen. + * We currently inline iifes to labeled blocks (not value-blocks). + * + * Here, print(1) and the evaluation of makeArray(...) get the same scope + * as the compiler infers that the makeArray call may mutate its arguments. + * Since print(1) does not get its own scope (and is thus not a declaration + * or dependency), it does not get promoted. + * As a result, print(1) gets reordered across the labeled-block instructions + * to be inlined at the makeArray callsite. + * + * Current evaluator results: + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [null,2] + * logs: [1,2] + * Forget: + * (kind: ok) [null,2] + * logs: [2,1] + */ +function useTest() { + return makeArray( + print(1), + (function foo() { + print(2); + return 2; + })() + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { makeArray, print } from "shared-runtime"; + +/** + * Exposes bug involving iife inlining + codegen. + * We currently inline iifes to labeled blocks (not value-blocks). + * + * Here, print(1) and the evaluation of makeArray(...) get the same scope + * as the compiler infers that the makeArray call may mutate its arguments. + * Since print(1) does not get its own scope (and is thus not a declaration + * or dependency), it does not get promoted. + * As a result, print(1) gets reordered across the labeled-block instructions + * to be inlined at the makeArray callsite. + * + * Current evaluator results: + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [null,2] + * logs: [1,2] + * Forget: + * (kind: ok) [null,2] + * logs: [2,1] + */ +function useTest() { + const $ = _c(1); + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + let t1; + + print(2); + t1 = 2; + t0 = makeArray(print(1), t1); + $[0] = t0; + } else { + t0 = $[0]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [], +}; + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.ts new file mode 100644 index 0000000000000..185bd89cb44f2 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/bug-codegen-inline-iife.ts @@ -0,0 +1,36 @@ +import { makeArray, print } from "shared-runtime"; + +/** + * Exposes bug involving iife inlining + codegen. + * We currently inline iifes to labeled blocks (not value-blocks). + * + * Here, print(1) and the evaluation of makeArray(...) get the same scope + * as the compiler infers that the makeArray call may mutate its arguments. + * Since print(1) does not get its own scope (and is thus not a declaration + * or dependency), it does not get promoted. + * As a result, print(1) gets reordered across the labeled-block instructions + * to be inlined at the makeArray callsite. + * + * Current evaluator results: + * Found differences in evaluator results + * Non-forget (expected): + * (kind: ok) [null,2] + * logs: [1,2] + * Forget: + * (kind: ok) [null,2] + * logs: [2,1] + */ +function useTest() { + return makeArray( + print(1), + (function foo() { + print(2); + return 2; + })() + ); +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.expect.md deleted file mode 100644 index ca77829e2f7f4..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.expect.md +++ /dev/null @@ -1,26 +0,0 @@ - -## Input - -```javascript -function Foo() { - try { - let thing = null; - if (cond) { - thing = makeObject(); - } - if (otherCond) { - mutate(thing); - } - } catch {} -} - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 2:24(18:26) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.js deleted file mode 100644 index 37be9363a5acf..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.bug-repro-trycatch-nested-overlapping-range.js +++ /dev/null @@ -1,11 +0,0 @@ -function Foo() { - try { - let thing = null; - if (cond) { - thing = makeObject(); - } - if (otherCond) { - mutate(thing); - } - } catch {} -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.expect.md new file mode 100644 index 0000000000000..e090697b6ed35 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.expect.md @@ -0,0 +1,27 @@ + +## Input + +```javascript +function useFoo() { + let x = 0; + return (value) => { + x = value; + }; +} + +``` + + +## Error + +``` + 2 | let x = 0; + 3 | return (value) => { +> 4 | x = value; + | ^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `x` cannot be reassigned after render (4:4) + 5 | }; + 6 | } + 7 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.js new file mode 100644 index 0000000000000..47aa81548e957 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-in-hook-return-value.js @@ -0,0 +1,6 @@ +function useFoo() { + let x = 0; + return (value) => { + x = value; + }; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.expect.md new file mode 100644 index 0000000000000..15c631f420584 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.expect.md @@ -0,0 +1,37 @@ + +## Input + +```javascript +function Component() { + let value = null; + const reassign = async () => { + await foo().then((result) => { + // Reassigning a local variable in an async function is *always* mutating + // after render, so this should error regardless of where this ends up + // getting called + value = result; + }); + }; + + const onClick = async () => { + await reassign(); + }; + return
Click
; +} + +``` + + +## Error + +``` + 6 | // after render, so this should error regardless of where this ends up + 7 | // getting called +> 8 | value = result; + | ^^^^^ InvalidReact: Reassigning a variable in an async function can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `value` cannot be reassigned after render (8:8) + 9 | }); + 10 | }; + 11 | +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.js new file mode 100644 index 0000000000000..1ba759e3b8ae7 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-async-callback.js @@ -0,0 +1,16 @@ +function Component() { + let value = null; + const reassign = async () => { + await foo().then((result) => { + // Reassigning a local variable in an async function is *always* mutating + // after render, so this should error regardless of where this ends up + // getting called + value = result; + }); + }; + + const onClick = async () => { + await reassign(); + }; + return
Click
; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.expect.md new file mode 100644 index 0000000000000..96706d5f463ac --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.expect.md @@ -0,0 +1,59 @@ + +## Input + +```javascript +import { useEffect } from "react"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onMount = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useEffect(() => { + onMount(); + }, [onMount]); + + return "ok"; +} + +``` + + +## Error + +``` + 5 | + 6 | const reassignLocal = (newValue) => { +> 7 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (7:7) + 8 | }; + 9 | + 10 | const onMount = (newValue) => { +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.js new file mode 100644 index 0000000000000..9d9f3b7f3206e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-effect.js @@ -0,0 +1,38 @@ +import { useEffect } from "react"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onMount = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useEffect(() => { + onMount(); + }, [onMount]); + + return "ok"; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.expect.md new file mode 100644 index 0000000000000..5d56c2605bc9e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.expect.md @@ -0,0 +1,60 @@ + +## Input + +```javascript +import { useEffect } from "react"; +import { useIdentity } from "shared-runtime"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const callback = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useIdentity(() => { + callback(); + }); + + return "ok"; +} + +``` + + +## Error + +``` + 6 | + 7 | const reassignLocal = (newValue) => { +> 8 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (8:8) + 9 | }; + 10 | + 11 | const callback = (newValue) => { +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.js new file mode 100644 index 0000000000000..a46e58608bd3c --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-hook-argument.js @@ -0,0 +1,39 @@ +import { useEffect } from "react"; +import { useIdentity } from "shared-runtime"; + +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const callback = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + useIdentity(() => { + callback(); + }); + + return "ok"; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.expect.md new file mode 100644 index 0000000000000..8a00785cf98ff --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.expect.md @@ -0,0 +1,53 @@ + +## Input + +```javascript +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onClick = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + return ; +} + +``` + + +## Error + +``` + 3 | + 4 | const reassignLocal = (newValue) => { +> 5 | local = newValue; + | ^^^^^ InvalidReact: Reassigning a variable after render has completed can cause inconsistent behavior on subsequent renders. Consider using state instead. Variable `local` cannot be reassigned after render (5:5) + 6 | }; + 7 | + 8 | const onClick = (newValue) => { +``` + + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.js new file mode 100644 index 0000000000000..47cefa6708e95 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.invalid-reassign-local-variable-in-jsx-callback.js @@ -0,0 +1,32 @@ +function Component() { + let local; + + const reassignLocal = (newValue) => { + local = newValue; + }; + + const onClick = (newValue) => { + reassignLocal("hello"); + + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + + return ; +} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.expect.md deleted file mode 100644 index 7cd2acc9affab..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.expect.md +++ /dev/null @@ -1,27 +0,0 @@ - -## Input - -```javascript -function Foo(props, ref) { - const value = {}; - if (cond1) { - mutate(value); - return ; - } - mutate(value); - if (cond2) { - return ; - } - return value; -} - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 1:21(16:23) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.js deleted file mode 100644 index 8837c348eeba7..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.repro-bug-ref-mutable-range.js +++ /dev/null @@ -1,12 +0,0 @@ -function Foo(props, ref) { - const value = {}; - if (cond1) { - mutate(value); - return ; - } - mutate(value); - if (cond2) { - return ; - } - return value; -} diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.expect.md deleted file mode 100644 index c14ae737e544a..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-iife-return-modified-later-logical.expect.md +++ /dev/null @@ -1,29 +0,0 @@ - -## Input - -```javascript -import { getNull } from "shared-runtime"; - -function Component(props) { - const items = (() => { - return getNull() ?? []; - })(); - items.push(props.a); - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: Component, - params: [{ a: {} }], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 2:15(3:21) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.expect.md deleted file mode 100644 index df0192db2a006..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-if.expect.md +++ /dev/null @@ -1,41 +0,0 @@ - -## Input - -```javascript -function useFoo({ cond }) { - let items: any = {}; - b0: { - if (cond) { - // Mutable range of `items` begins here, but its reactive scope block - // should be aligned to above the if-branch - items = []; - } else { - break b0; - } - items.push(2); - } - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ cond: true }], - sequentialRenders: [ - { cond: true }, - { cond: true }, - { cond: false }, - { cond: false }, - { cond: true }, - ], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 6:11(7:15) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.expect.md deleted file mode 100644 index 62295b9a0ca0a..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-label.expect.md +++ /dev/null @@ -1,40 +0,0 @@ - -## Input - -```javascript -import { arrayPush } from "shared-runtime"; - -function useFoo({ cond, value }) { - let items; - label: { - items = []; - // Mutable range of `items` begins here, but its reactive scope block - // should be aligned to above the label-block - if (cond) break label; - arrayPush(items, value); - } - arrayPush(items, value); - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ cond: true, value: 2 }], - sequentialRenders: [ - { cond: true, value: 2 }, - { cond: true, value: 2 }, - { cond: true, value: 3 }, - { cond: false, value: 3 }, - ], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 3:14(4:18) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.expect.md deleted file mode 100644 index 95cdbe5aeea76..0000000000000 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/error.todo-reactive-scope-overlaps-try.expect.md +++ /dev/null @@ -1,36 +0,0 @@ - -## Input - -```javascript -import { arrayPush } from "shared-runtime"; - -function useFoo({ value }) { - let items = null; - try { - // Mutable range of `items` begins here, but its reactive scope block - // should be aligned to above the try-block - items = []; - arrayPush(items, value); - } catch { - // ignore - } - mutate(items); - return items; -} - -export const FIXTURE_ENTRYPOINT = { - fn: useFoo, - params: [{ value: 2 }], - sequentialRenders: [{ value: 2 }, { value: 2 }, { value: 3 }], -}; - -``` - - -## Error - -``` -Invariant: Invalid nesting in program blocks or scopes. Items overlap but are not nested: 4:19(5:22) -``` - - \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.expect.md new file mode 100644 index 0000000000000..6d44cb418701d --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.expect.md @@ -0,0 +1,49 @@ + +## Input + +```javascript +function Component(props) { + let x = props.init; + for (let i = 0; i < 100; i = i + 1) { + x += i; + } + return [x]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ init: 0 }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +function Component(props) { + const $ = _c(2); + let x = props.init; + for (let i = 0; i < 100; i = i + 1) { + x = x + i; + } + let t0; + if ($[0] !== x) { + t0 = [x]; + $[0] = x; + $[1] = t0; + } else { + t0 = $[1]; + } + return t0; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ init: 0 }], +}; + +``` + +### Eval output +(kind: ok) [4950] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.js new file mode 100644 index 0000000000000..99a7e75d110fe --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/for-with-assignment-as-update.js @@ -0,0 +1,12 @@ +function Component(props) { + let x = props.init; + for (let i = 0; i < 100; i = i + 1) { + x += i; + } + return [x]; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Component, + params: [{ init: 0 }], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md index f47b6089ebcf7..c96844716eaa4 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-deplist-controlflow.expect.md @@ -40,53 +40,46 @@ import { useCallback } from "react"; import { Stringify } from "shared-runtime"; function Foo(t0) { - const $ = _c(11); + const $ = _c(9); const { arr1, arr2, foo } = t0; let t1; - if ($[0] !== arr1) { - t1 = [arr1]; - $[0] = arr1; - $[1] = t1; - } else { - t1 = $[1]; - } - const x = t1; - let t2; let getVal1; - if ($[2] !== foo || $[3] !== x || $[4] !== arr2) { + if ($[0] !== arr1 || $[1] !== foo || $[2] !== arr2) { + const x = [arr1]; + let y; y = []; - let t3; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t3 = () => ({ x: 2 }); - $[7] = t3; + let t2; + if ($[5] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => ({ x: 2 }); + $[5] = t2; } else { - t3 = $[7]; + t2 = $[5]; } - getVal1 = t3; + getVal1 = t2; - t2 = () => [y]; + t1 = () => [y]; foo ? (y = x.concat(arr2)) : y; - $[2] = foo; - $[3] = x; - $[4] = arr2; - $[5] = t2; - $[6] = getVal1; + $[0] = arr1; + $[1] = foo; + $[2] = arr2; + $[3] = t1; + $[4] = getVal1; } else { - t2 = $[5]; - getVal1 = $[6]; + t1 = $[3]; + getVal1 = $[4]; } - const getVal2 = t2; - let t3; - if ($[8] !== getVal1 || $[9] !== getVal2) { - t3 = ; - $[8] = getVal1; - $[9] = getVal2; - $[10] = t3; + const getVal2 = t1; + let t2; + if ($[6] !== getVal1 || $[7] !== getVal2) { + t2 = ; + $[6] = getVal1; + $[7] = getVal2; + $[8] = t2; } else { - t3 = $[10]; + t2 = $[8]; } - return t3; + return t2; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md index a11b5f709e280..2401298c69ad5 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useCallback-reordering-depslist-assignment.expect.md @@ -36,38 +36,31 @@ import { Stringify } from "shared-runtime"; // We currently produce invalid output (incorrect scoping for `y` declaration) function useFoo(arr1, arr2) { - const $ = _c(7); + const $ = _c(5); let t0; - if ($[0] !== arr1) { - t0 = [arr1]; + if ($[0] !== arr1 || $[1] !== arr2) { + const x = [arr1]; + + let y; + t0 = () => ({ y }); + + (y = x.concat(arr2)), y; $[0] = arr1; - $[1] = t0; + $[1] = arr2; + $[2] = t0; } else { - t0 = $[1]; + t0 = $[2]; } - const x = t0; + const getVal = t0; let t1; - if ($[2] !== x || $[3] !== arr2) { - let y; - t1 = () => ({ y }); - - (y = x.concat(arr2)), y; - $[2] = x; - $[3] = arr2; + if ($[3] !== getVal) { + t1 = ; + $[3] = getVal; $[4] = t1; } else { t1 = $[4]; } - const getVal = t1; - let t2; - if ($[5] !== getVal) { - t2 = ; - $[5] = getVal; - $[6] = t2; - } else { - t2 = $[6]; - } - return t2; + return t1; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md index c7fd376912f5c..e485b6fc7ad65 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-assignment.expect.md @@ -30,38 +30,31 @@ import { c as _c } from "react/compiler-runtime"; import { useMemo } from "react"; function useFoo(arr1, arr2) { - const $ = _c(7); - let t0; - if ($[0] !== arr1) { - t0 = [arr1]; - $[0] = arr1; - $[1] = t0; - } else { - t0 = $[1]; - } - const x = t0; + const $ = _c(5); let y; - if ($[2] !== x || $[3] !== arr2) { + if ($[0] !== arr1 || $[1] !== arr2) { + const x = [arr1]; + y; (y = x.concat(arr2)), y; - $[2] = x; - $[3] = arr2; - $[4] = y; + $[0] = arr1; + $[1] = arr2; + $[2] = y; } else { - y = $[4]; + y = $[2]; } - let t1; - const t2 = y; - let t3; - if ($[5] !== t2) { - t3 = { y: t2 }; - $[5] = t2; - $[6] = t3; + let t0; + const t1 = y; + let t2; + if ($[3] !== t1) { + t2 = { y: t1 }; + $[3] = t1; + $[4] = t2; } else { - t3 = $[6]; + t2 = $[4]; } - t1 = t3; - return t1; + t0 = t2; + return t0; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md index 577db4ae91d57..787ff42def026 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/preserve-memo-validation/useMemo-reordering-depslist-controlflow.expect.md @@ -40,55 +40,48 @@ import { useMemo } from "react"; import { Stringify } from "shared-runtime"; function Foo(t0) { - const $ = _c(11); + const $ = _c(9); const { arr1, arr2, foo } = t0; let t1; - if ($[0] !== arr1) { - t1 = [arr1]; - $[0] = arr1; - $[1] = t1; - } else { - t1 = $[1]; - } - const x = t1; - let t2; let val1; - if ($[2] !== foo || $[3] !== x || $[4] !== arr2) { + if ($[0] !== arr1 || $[1] !== foo || $[2] !== arr2) { + const x = [arr1]; + let y; y = []; + let t2; let t3; - let t4; - if ($[7] === Symbol.for("react.memo_cache_sentinel")) { - t4 = { x: 2 }; - $[7] = t4; + if ($[5] === Symbol.for("react.memo_cache_sentinel")) { + t3 = { x: 2 }; + $[5] = t3; } else { - t4 = $[7]; + t3 = $[5]; } - t3 = t4; - val1 = t3; + t2 = t3; + val1 = t2; foo ? (y = x.concat(arr2)) : y; - t2 = (() => [y])(); - $[2] = foo; - $[3] = x; - $[4] = arr2; - $[5] = t2; - $[6] = val1; + t1 = (() => [y])(); + $[0] = arr1; + $[1] = foo; + $[2] = arr2; + $[3] = t1; + $[4] = val1; } else { - t2 = $[5]; - val1 = $[6]; + t1 = $[3]; + val1 = $[4]; } - const val2 = t2; - let t3; - if ($[8] !== val1 || $[9] !== val2) { - t3 = ; - $[8] = val1; - $[9] = val2; - $[10] = t3; + const val2 = t1; + let t2; + if ($[6] !== val1 || $[7] !== val2) { + t2 = ; + $[6] = val1; + $[7] = val2; + $[8] = t2; } else { - t3 = $[10]; + t2 = $[8]; } - return t3; + return t2; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md index 583148ceb75d1..945f68d4be107 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.expect.md @@ -18,7 +18,7 @@ function Component(props) { a.property = true; b.push(false); }; - return
; + return
; } export const FIXTURE_ENTRYPOINT = { @@ -35,10 +35,10 @@ import { c as _c } from "react/compiler-runtime"; // @enableAssumeHooksFollowRul let cond = true; function Component(props) { const $ = _c(1); - let a; - let b; let t0; if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + let a; + let b; const f = () => { if (cond) { a = {}; @@ -52,7 +52,7 @@ function Component(props) { b.push(false); }; - t0 =
; + t0 =
; $[0] = t0; } else { t0 = $[0]; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js index ac7299181ed5f..b975527138f3b 100644 --- a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-mutable-range-shared-inner-outer-function.js @@ -14,7 +14,7 @@ function Component(props) { a.property = true; b.push(false); }; - return
; + return
; } export const FIXTURE_ENTRYPOINT = { diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.expect.md new file mode 100644 index 0000000000000..69ae9aa3d6c2a --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.expect.md @@ -0,0 +1,83 @@ + +## Input + +```javascript +import { Stringify, identity, mutate, CONST_TRUE } from "shared-runtime"; + +function Foo(props, ref) { + const value = {}; + if (CONST_TRUE) { + mutate(value); + return ; + } + mutate(value); + if (CONST_TRUE) { + return ; + } + return value; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}, { current: "fake-ref-object" }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { Stringify, identity, mutate, CONST_TRUE } from "shared-runtime"; + +function Foo(props, ref) { + const $ = _c(5); + let value; + let t0; + if ($[0] !== ref) { + t0 = Symbol.for("react.early_return_sentinel"); + bb0: { + value = {}; + if (CONST_TRUE) { + mutate(value); + t0 = ; + break bb0; + } + + mutate(value); + if (CONST_TRUE) { + const t1 = identity(ref); + let t2; + if ($[3] !== t1) { + t2 = ; + $[3] = t1; + $[4] = t2; + } else { + t2 = $[4]; + } + t0 = t2; + break bb0; + } + } + $[0] = ref; + $[1] = value; + $[2] = t0; + } else { + value = $[1]; + t0 = $[2]; + } + if (t0 !== Symbol.for("react.early_return_sentinel")) { + return t0; + } + return value; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}, { current: "fake-ref-object" }], +}; + +``` + +### Eval output +(kind: ok)
{"ref":{"current":"fake-ref-object"}}
\ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.tsx b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.tsx new file mode 100644 index 0000000000000..106ebd772e611 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-ref-mutable-range.tsx @@ -0,0 +1,19 @@ +import { Stringify, identity, mutate, CONST_TRUE } from "shared-runtime"; + +function Foo(props, ref) { + const value = {}; + if (CONST_TRUE) { + mutate(value); + return ; + } + mutate(value); + if (CONST_TRUE) { + return ; + } + return value; +} + +export const FIXTURE_ENTRYPOINT = { + fn: Foo, + params: [{}, { current: "fake-ref-object" }], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.expect.md new file mode 100644 index 0000000000000..4cfd4cd247819 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.expect.md @@ -0,0 +1,68 @@ + +## Input + +```javascript +// @validatePreserveExistingMemoizationGuarantees + +import { Builder } from "shared-runtime"; +function useTest({ isNull, data }: { isNull: boolean; data: string }) { + const result = Builder.makeBuilder(isNull, "hello world") + ?.push("1", 2) + ?.push(3, { + a: 4, + b: 5, + c: data, + }) + ?.push(6, data) + ?.push(7, "8") + ?.push("8", Builder.makeBuilder(!isNull)?.push(9).vals)?.vals; + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [{ isNull: false, data: "param" }], +}; + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; // @validatePreserveExistingMemoizationGuarantees + +import { Builder } from "shared-runtime"; +function useTest(t0) { + const $ = _c(3); + const { isNull, data } = t0; + let t1; + if ($[0] !== isNull || $[1] !== data) { + t1 = Builder.makeBuilder(isNull, "hello world") + ?.push("1", 2) + ?.push(3, { a: 4, b: 5, c: data }) + ?.push( + 6, + + data, + ) + ?.push(7, "8") + ?.push("8", Builder.makeBuilder(!isNull)?.push(9).vals)?.vals; + $[0] = isNull; + $[1] = data; + $[2] = t1; + } else { + t1 = $[2]; + } + const result = t1; + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [{ isNull: false, data: "param" }], +}; + +``` + +### Eval output +(kind: ok) ["hello world","1",2,3,{"a":4,"b":5,"c":"param"},6,"param",7,"8","8",null] \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.ts b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.ts new file mode 100644 index 0000000000000..0fd75e4fc361e --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/repro-slow-validate-preserve-memo.ts @@ -0,0 +1,21 @@ +// @validatePreserveExistingMemoizationGuarantees + +import { Builder } from "shared-runtime"; +function useTest({ isNull, data }: { isNull: boolean; data: string }) { + const result = Builder.makeBuilder(isNull, "hello world") + ?.push("1", 2) + ?.push(3, { + a: 4, + b: 5, + c: data, + }) + ?.push(6, data) + ?.push(7, "8") + ?.push("8", Builder.makeBuilder(!isNull)?.push(9).vals)?.vals; + return result; +} + +export const FIXTURE_ENTRYPOINT = { + fn: useTest, + params: [{ isNull: false, data: "param" }], +}; diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.expect.md b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.expect.md new file mode 100644 index 0000000000000..b863dfdcdbccb --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.expect.md @@ -0,0 +1,101 @@ + +## Input + +```javascript +import { useEffect } from "react"; +function Component() { + let local; + const mk_reassignlocal = () => { + // Create the reassignment function inside another function, then return it + const reassignLocal = (newValue) => { + local = newValue; + }; + return reassignLocal; + }; + const reassignLocal = mk_reassignlocal(); + const onMount = (newValue) => { + reassignLocal("hello"); + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + useEffect(() => { + onMount(); + }, [onMount]); + return "ok"; +} + +``` + +## Code + +```javascript +import { c as _c } from "react/compiler-runtime"; +import { useEffect } from "react"; +function Component() { + const $ = _c(4); + let local; + let t0; + if ($[0] === Symbol.for("react.memo_cache_sentinel")) { + const mk_reassignlocal = () => { + const reassignLocal = (newValue) => { + local = newValue; + }; + return reassignLocal; + }; + + t0 = mk_reassignlocal(); + $[0] = t0; + } else { + t0 = $[0]; + } + const reassignLocal_0 = t0; + let t1; + if ($[1] === Symbol.for("react.memo_cache_sentinel")) { + t1 = (newValue_0) => { + reassignLocal_0("hello"); + if (local === newValue_0) { + console.log("`local` was updated!"); + } else { + throw new Error("`local` not updated!"); + } + }; + $[1] = t1; + } else { + t1 = $[1]; + } + const onMount = t1; + let t2; + let t3; + if ($[2] === Symbol.for("react.memo_cache_sentinel")) { + t2 = () => { + onMount(); + }; + t3 = [onMount]; + $[2] = t2; + $[3] = t3; + } else { + t2 = $[2]; + t3 = $[3]; + } + useEffect(t2, t3); + return "ok"; +} + +``` + \ No newline at end of file diff --git a/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.js b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.js new file mode 100644 index 0000000000000..e754870acce67 --- /dev/null +++ b/compiler/packages/babel-plugin-react-compiler/src/__tests__/fixtures/compiler/todo.invalid-nested-function-reassign-local-variable-in-effect.js @@ -0,0 +1,37 @@ +import { useEffect } from "react"; +function Component() { + let local; + const mk_reassignlocal = () => { + // Create the reassignment function inside another function, then return it + const reassignLocal = (newValue) => { + local = newValue; + }; + return reassignLocal; + }; + const reassignLocal = mk_reassignlocal(); + const onMount = (newValue) => { + reassignLocal("hello"); + if (local === newValue) { + // Without React Compiler, `reassignLocal` is freshly created + // on each render, capturing a binding to the latest `local`, + // such that invoking reassignLocal will reassign the same + // binding that we are observing in the if condition, and + // we reach this branch + console.log("`local` was updated!"); + } else { + // With React Compiler enabled, `reassignLocal` is only created + // once, capturing a binding to `local` in that render pass. + // Therefore, calling `reassignLocal` will reassign the wrong + // version of `local`, and not update the binding we are checking + // in the if condition. + // + // To protect against this, we disallow reassigning locals from + // functions that escape + throw new Error("`local` not updated!"); + } + }; + useEffect(() => { + onMount(); + }, [onMount]); + return "ok"; +} diff --git a/compiler/packages/eslint-plugin-react-compiler/package.json b/compiler/packages/eslint-plugin-react-compiler/package.json index 2a3151133491b..dcf78c77513a9 100644 --- a/compiler/packages/eslint-plugin-react-compiler/package.json +++ b/compiler/packages/eslint-plugin-react-compiler/package.json @@ -1,6 +1,6 @@ { "name": "eslint-plugin-react-compiler", - "version": "0.0.0-experimental-8ae29d2-20240614", + "version": "0.0.0-experimental-0998c1e-20240625", "description": "ESLint plugin to display errors found by the React compiler.", "main": "dist/index.js", "scripts": { diff --git a/compiler/packages/react-compiler-healthcheck/package.json b/compiler/packages/react-compiler-healthcheck/package.json index 790d17a641a0b..eff9cb2ef1117 100644 --- a/compiler/packages/react-compiler-healthcheck/package.json +++ b/compiler/packages/react-compiler-healthcheck/package.json @@ -1,6 +1,6 @@ { "name": "react-compiler-healthcheck", - "version": "0.0.0-experimental-c20572a-20240614", + "version": "0.0.0-experimental-b130d5f-20240625", "description": "Health check script to test violations of the rules of react.", "bin": { "react-compiler-healthcheck": "dist/index.js" diff --git a/compiler/packages/snap/src/SproutTodoFilter.ts b/compiler/packages/snap/src/SproutTodoFilter.ts index b36e8064a7876..85f7413534bbe 100644 --- a/compiler/packages/snap/src/SproutTodoFilter.ts +++ b/compiler/packages/snap/src/SproutTodoFilter.ts @@ -444,7 +444,6 @@ const skipFilter = new Set([ "loop-unused-let", "reanimated-no-memo-arg", - // Tested e2e in forget-feedback repo "userspace-use-memo-cache", "transitive-freeze-function-expressions", @@ -488,6 +487,7 @@ const skipFilter = new Set([ "bug-invalid-hoisting-functionexpr", "original-reactive-scopes-fork/bug-nonmutating-capture-in-unsplittable-memo-block", "original-reactive-scopes-fork/bug-hoisted-declaration-with-scope", + "bug-codegen-inline-iife", // 'react-compiler-runtime' not yet supported "flag-enable-emit-hook-guards", @@ -500,6 +500,8 @@ const skipFilter = new Set([ // needs to be executed as a module "meta-property", + + "todo.invalid-nested-function-reassign-local-variable-in-effect", ]); export default skipFilter; diff --git a/compiler/packages/snap/src/runner-watch.ts b/compiler/packages/snap/src/runner-watch.ts index 414d99084c52e..d6c7ae0601c53 100644 --- a/compiler/packages/snap/src/runner-watch.ts +++ b/compiler/packages/snap/src/runner-watch.ts @@ -27,7 +27,7 @@ export function watchSrc( const host = ts.createWatchCompilerHost( configPath, ts.convertCompilerOptionsFromJson( - { module: "commonjs", outDir: "dist" }, + { module: "commonjs", outDir: "dist", sourceMap: true }, "." ).options, ts.sys, diff --git a/compiler/packages/snap/src/sprout/shared-runtime.ts b/compiler/packages/snap/src/sprout/shared-runtime.ts index 94fba22c04974..a67ab298ada0c 100644 --- a/compiler/packages/snap/src/sprout/shared-runtime.ts +++ b/compiler/packages/snap/src/sprout/shared-runtime.ts @@ -36,7 +36,7 @@ export const CONST_NUMBER2 = 2; export const CONST_TRUE = true; export const CONST_FALSE = false; -export function initFbt() { +export function initFbt(): void { const viewerContext: IntlViewerContext = { GENDER: IntlVariations.GENDER_UNKNOWN, locale: "en_US", @@ -52,7 +52,7 @@ export function initFbt() { export function mutate(arg: any): void { // don't mutate primitive - if (typeof arg === null || typeof arg !== "object") { + if (arg == null || typeof arg !== "object") { return; } @@ -80,7 +80,7 @@ export function mutateAndReturnNewValue(arg: T): string { export function setProperty(arg: any, property: any): void { // don't mutate primitive - if (typeof arg === null || typeof arg !== "object") { + if (arg == null || typeof arg !== "object") { return arg; } @@ -123,7 +123,7 @@ export function calculateExpensiveNumber(x: number): number { /** * Functions that do not mutate their parameters */ -export function shallowCopy(obj: Object): object { +export function shallowCopy(obj: object): object { return Object.assign({}, obj); } @@ -139,9 +139,11 @@ export function addOne(value: number): number { return value + 1; } -// Alias console.log, as it is defined as a global and may have -// different compiler handling than unknown functions -export function print(...args: Array) { +/* + * Alias console.log, as it is defined as a global and may have + * different compiler handling than unknown functions + */ +export function print(...args: Array): void { console.log(...args); } @@ -153,7 +155,7 @@ export function throwErrorWithMessage(message: string): never { throw new Error(message); } -export function throwInput(x: Object): never { +export function throwInput(x: object): never { throw x; } @@ -167,12 +169,12 @@ export function logValue(value: T): void { console.log(value); } -export function useHook(): Object { +export function useHook(): object { return makeObject_Primitives(); } const noAliasObject = Object.freeze({}); -export function useNoAlias(...args: Array): object { +export function useNoAlias(..._args: Array): object { return noAliasObject; } @@ -183,7 +185,7 @@ export function useIdentity(arg: T): T { export function invoke, ReturnType>( fn: (...input: T) => ReturnType, ...params: T -) { +): ReturnType { return fn(...params); } @@ -191,7 +193,7 @@ export function conditionalInvoke, ReturnType>( shouldInvoke: boolean, fn: (...input: T) => ReturnType, ...params: T -) { +): ReturnType | null { if (shouldInvoke) { return fn(...params); } else { @@ -205,21 +207,25 @@ export function conditionalInvoke, ReturnType>( export function Text(props: { value: string; children?: Array; -}) { +}): React.ReactElement { return React.createElement("div", null, props.value, props.children); } -export function StaticText1(props: { children?: Array }) { +export function StaticText1(props: { + children?: Array; +}): React.ReactElement { return React.createElement("div", null, "StaticText1", props.children); } -export function StaticText2(props: { children?: Array }) { +export function StaticText2(props: { + children?: Array; +}): React.ReactElement { return React.createElement("div", null, "StaticText2", props.children); } export function RenderPropAsChild(props: { items: Array<() => React.ReactNode>; -}) { +}): React.ReactElement { return React.createElement( "div", null, @@ -242,7 +248,7 @@ export function ValidateMemoization({ }: { inputs: Array; output: any; -}) { +}): React.ReactElement { "use no forget"; const [previousInputs, setPreviousInputs] = React.useState(inputs); const [previousOutput, setPreviousOutput] = React.useState(output); @@ -273,7 +279,7 @@ export function createHookWrapper( } // helper functions -export function toJSON(value: any, invokeFns: boolean = false) { +export function toJSON(value: any, invokeFns: boolean = false): string { const seen = new Map(); return JSON.stringify(value, (_key: string, val: any) => { @@ -306,6 +312,22 @@ export function toJSON(value: any, invokeFns: boolean = false) { return val; }); } +export class Builder { + vals: Array = []; + static makeBuilder(isNull: boolean, ...args: Array): Builder | null { + if (isNull) { + return null; + } else { + const builder = new Builder(); + builder.push(...args); + return builder; + } + } + push(...args: Array): Builder { + this.vals.push(...args); + return this; + } +} export const ObjectWithHooks = { useFoo(): number { @@ -319,7 +341,7 @@ export const ObjectWithHooks = { }, }; -export function useFragment(...args: Array): Object { +export function useFragment(..._args: Array): object { return { a: [1, 2, 3], b: { c: { d: 4 } }, diff --git a/compiler/scripts/build-packages-forget-feedback.sh b/compiler/scripts/build-packages-forget-feedback.sh deleted file mode 100755 index 8458d7d47ae77..0000000000000 --- a/compiler/scripts/build-packages-forget-feedback.sh +++ /dev/null @@ -1,39 +0,0 @@ -#!/bin/sh -# 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. - -# Script to build packages for Forget Feedback (eg when you need to add a new package to the -# testapp) - -set -eo pipefail - -cwd=`basename $(pwd)` - -if [ $cwd != "react-forget" ]; then - echo "Error: This script must be run from the top level react-forget directory. Exiting" - exit 1 -fi - -# ----------------------- Build packages -yarn install --silent -rm -rf forget-feedback/dist -mkdir forget-feedback/dist - -packages=("babel-plugin-react-compiler" "eslint-plugin-react-compiler" "react-forget-runtime") -for package in ${packages[@]}; do - echo "Building" $package - yarn workspace $package run build -done - -echo "Copying artifacts to local forget-feedback directory..." -for package in ${packages[@]}; do - for dir in packages/$package/; do - if [ -d $dir/dist ]; then - package_name=$(basename $dir) - cp -R $dir/dist/. ./forget-feedback/dist/$package_name - cp $dir/package.json ./forget-feedback/dist/$package_name - fi - done -done diff --git a/compiler/scripts/copyright.js b/compiler/scripts/copyright.js index 101d25c453432..c8f12cbdb57c4 100644 --- a/compiler/scripts/copyright.js +++ b/compiler/scripts/copyright.js @@ -22,9 +22,6 @@ const files = glob.sync("**/*.{js,ts,tsx,jsx,rs}", { ignore: [ "**/dist/**", "**/node_modules/**", - "react/**", - "forget-feedback/**", - "packages/js-fuzzer/**", "**/tests/fixtures/**", "**/__tests__/fixtures/**", ], diff --git a/compiler/yarn.lock b/compiler/yarn.lock index 4e4c82e1932fd..6df7539281795 100644 --- a/compiler/yarn.lock +++ b/compiler/yarn.lock @@ -9127,7 +9127,16 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -9220,7 +9229,14 @@ string_decoder@^1.1.1: dependencies: safe-buffer "~5.2.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + +strip-ansi@^6.0.0, strip-ansi@^6.0.1: version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -9871,7 +9887,7 @@ wordwrap@>=0.0.2: resolved "https://registry.yarnpkg.com/wordwrap/-/wordwrap-1.0.0.tgz#27584810891456a4171c8d0226441ade90cbcaeb" integrity sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q== -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -9889,6 +9905,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -9911,15 +9936,10 @@ write-file-atomic@^4.0.1, write-file-atomic@^4.0.2: imurmurhash "^0.1.4" signal-exit "^3.0.7" -ws@^8.13.0: - version "8.13.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.13.0.tgz#9a9fb92f93cf41512a0735c8f4dd09b8a1211cd0" - integrity sha512-x9vcZYTrFPC7aSIbj7sRCYo7L/Xb8Iy+pW0ng0wt2vCJv7M9HOMy0UoN3rr+IFC7hb7vXoqS+P9ktyLLLhO+LA== - -ws@^8.8.0: - version "8.9.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.9.0.tgz#2a994bb67144be1b53fe2d23c53c028adeb7f45e" - integrity sha512-Ja7nszREasGaYUYCI2k4lCKIRTt+y7XuqVoHR44YpI49TtryyqbqvDMn5eqfW7e6HzTukDRIsXqzVHScqRcafg== +ws@^8.13.0, ws@^8.8.0: + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xml-name-validator@^4.0.0: version "4.0.0" diff --git a/fixtures/dom/public/renderer.html b/fixtures/dom/public/renderer.html index 3852dd952f2b9..83abba28c0311 100644 --- a/fixtures/dom/public/renderer.html +++ b/fixtures/dom/public/renderer.html @@ -80,7 +80,7 @@
- + diff --git a/packages/internal-test-utils/consoleMock.js b/packages/internal-test-utils/consoleMock.js index 45a59d2b4cb29..328cf3d90d0c4 100644 --- a/packages/internal-test-utils/consoleMock.js +++ b/packages/internal-test-utils/consoleMock.js @@ -418,13 +418,18 @@ export function createLogAssertion( let argIndex = 0; // console.* could have been called with a non-string e.g. `console.error(new Error())` // eslint-disable-next-line react-internal/safe-string-coercion - String(format).replace(/%s/g, () => argIndex++); + String(format).replace(/%s|%c/g, () => argIndex++); if (argIndex !== args.length) { - logsMismatchingFormat.push({ - format, - args, - expectedArgCount: argIndex, - }); + if (format.includes('%c%s')) { + // We intentionally use mismatching formatting when printing badging because we don't know + // the best default to use for different types because the default varies by platform. + } else { + logsMismatchingFormat.push({ + format, + args, + expectedArgCount: argIndex, + }); + } } // Check for extra component stacks diff --git a/packages/internal-test-utils/shouldIgnoreConsoleError.js b/packages/internal-test-utils/shouldIgnoreConsoleError.js index 383650d25a0b7..0b5798d241a1d 100644 --- a/packages/internal-test-utils/shouldIgnoreConsoleError.js +++ b/packages/internal-test-utils/shouldIgnoreConsoleError.js @@ -3,6 +3,10 @@ module.exports = function shouldIgnoreConsoleError(format, args) { if (__DEV__) { if (typeof format === 'string') { + if (format.startsWith('%c%s')) { + // Looks like a badged error message + args.splice(0, 3); + } if ( args[0] != null && ((typeof args[0] === 'object' && diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js b/packages/react-client/src/ReactClientConsoleConfigBrowser.js similarity index 82% rename from packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js rename to packages/react-client/src/ReactClientConsoleConfigBrowser.js index cc934685c8f7b..da87324b6df27 100644 --- a/packages/react-client/src/ReactFlightClientConsoleConfigBrowser.js +++ b/packages/react-client/src/ReactClientConsoleConfigBrowser.js @@ -7,6 +7,8 @@ * @flow */ +import {warn, error} from 'shared/consoleWithStackDev'; + const badgeFormat = '%c%s%c '; // Same badge styling as DevTools. const badgeStyle = @@ -63,7 +65,12 @@ export function printToConsole( ); } - // eslint-disable-next-line react-internal/no-production-logging - console[methodName].apply(console, newArgs); - return; + if (methodName === 'error') { + error.apply(console, newArgs); + } else if (methodName === 'warn') { + warn.apply(console, newArgs); + } else { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + } } diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigPlain.js b/packages/react-client/src/ReactClientConsoleConfigPlain.js similarity index 76% rename from packages/react-client/src/ReactFlightClientConsoleConfigPlain.js rename to packages/react-client/src/ReactClientConsoleConfigPlain.js index 1dbdec54cd078..a4e7c3c6d7e3c 100644 --- a/packages/react-client/src/ReactFlightClientConsoleConfigPlain.js +++ b/packages/react-client/src/ReactClientConsoleConfigPlain.js @@ -7,6 +7,8 @@ * @flow */ +import {warn, error} from 'shared/consoleWithStackDev'; + const badgeFormat = '[%s] '; const pad = ' '; @@ -44,7 +46,12 @@ export function printToConsole( newArgs.splice(offset, 0, badgeFormat, pad + badgeName + pad); } - // eslint-disable-next-line react-internal/no-production-logging - console[methodName].apply(console, newArgs); - return; + if (methodName === 'error') { + error.apply(console, newArgs); + } else if (methodName === 'warn') { + warn.apply(console, newArgs); + } else { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + } } diff --git a/packages/react-client/src/ReactFlightClientConsoleConfigServer.js b/packages/react-client/src/ReactClientConsoleConfigServer.js similarity index 83% rename from packages/react-client/src/ReactFlightClientConsoleConfigServer.js rename to packages/react-client/src/ReactClientConsoleConfigServer.js index 7567483245fa6..f6ecad92f3eef 100644 --- a/packages/react-client/src/ReactFlightClientConsoleConfigServer.js +++ b/packages/react-client/src/ReactClientConsoleConfigServer.js @@ -7,6 +7,8 @@ * @flow */ +import {warn, error} from 'shared/consoleWithStackDev'; + // This flips color using ANSI, then sets a color styling, then resets. const badgeFormat = '\x1b[0m\x1b[7m%c%s\x1b[0m%c '; // Same badge styling as DevTools. @@ -64,7 +66,12 @@ export function printToConsole( ); } - // eslint-disable-next-line react-internal/no-production-logging - console[methodName].apply(console, newArgs); - return; + if (methodName === 'error') { + error.apply(console, newArgs); + } else if (methodName === 'warn') { + warn.apply(console, newArgs); + } else { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, newArgs); + } } diff --git a/packages/react-client/src/ReactFlightClient.js b/packages/react-client/src/ReactFlightClient.js index 866805b5578c1..7d421f0422b56 100644 --- a/packages/react-client/src/ReactFlightClient.js +++ b/packages/react-client/src/ReactFlightClient.js @@ -1730,6 +1730,7 @@ function resolveErrorDev( digest: string, message: string, stack: string, + env: string, ): void { if (!__DEV__) { // These errors should never make it into a build so we don't need to encode them in codes.json @@ -1769,6 +1770,7 @@ function resolveErrorDev( } (error: any).digest = digest; + (error: any).environmentName = env; const errorWithDigest: ErrorWithDigest = (error: any); const chunks = response._chunks; const chunk = chunks.get(id); @@ -2056,6 +2058,8 @@ function resolveConsoleEntry( task.run(callStack); return; } + // TODO: Set the current owner so that consoleWithStackDev adds the component + // stack during the replay - if needed. } const rootTask = response._debugRootTask; if (rootTask != null) { @@ -2198,6 +2202,7 @@ function processFullRow( errorInfo.digest, errorInfo.message, errorInfo.stack, + errorInfo.env, ); } else { resolveErrorProd(response, id, errorInfo.digest); diff --git a/packages/react-client/src/__tests__/ReactFlight-test.js b/packages/react-client/src/__tests__/ReactFlight-test.js index bd2d98736addd..2e3857a90deca 100644 --- a/packages/react-client/src/__tests__/ReactFlight-test.js +++ b/packages/react-client/src/__tests__/ReactFlight-test.js @@ -127,6 +127,7 @@ describe('ReactFlight', () => { this.props.expectedMessage, ); expect(this.state.error.digest).toBe('a dev digest'); + expect(this.state.error.environmentName).toBe('Server'); } else { expect(this.state.error.message).toBe( 'An error occurred in the Server Components render. The specific message is omitted in production' + @@ -143,6 +144,7 @@ describe('ReactFlight', () => { expectedDigest = '[]'; } expect(this.state.error.digest).toContain(expectedDigest); + expect(this.state.error.environmentName).toBe(undefined); expect(this.state.error.stack).toBe( 'Error: ' + this.state.error.message, ); @@ -690,14 +692,22 @@ describe('ReactFlight', () => { const transport = ReactNoopFlightServer.render(); - await act(async () => { - const rootModel = await ReactNoopFlightClient.read(transport); - ReactNoop.render(rootModel); - }); - expect(ReactNoop).toMatchRenderedOutput('Loading...'); - spyOnDevAndProd(console, 'error').mockImplementation(() => {}); await load(); - expect(console.error).toHaveBeenCalledTimes(1); + + await expect(async () => { + await act(async () => { + const rootModel = await ReactNoopFlightClient.read(transport); + ReactNoop.render(rootModel); + }); + }).rejects.toThrow( + __DEV__ + ? 'Element type is invalid: expected a string (for built-in components) or a class/function ' + + '(for composite components) but got:
. ' + + 'Did you accidentally export a JSX literal instead of a component?' + : 'Element type is invalid: expected a string (for built-in components) or a class/function ' + + '(for composite components) but got: object.', + ); + expect(ReactNoop).toMatchRenderedOutput(null); }); it('can render a lazy element', async () => { diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js index 87d87ea523e59..7ae8d5f5cdc7d 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-esm.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMBrowser'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js index 97b4afd13a835..28e2489cf22bb 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser-turbopack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackBrowser'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js index 51c832bff43a3..3a5ec6800fd57 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-browser.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackBrowser'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackBrowser'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js index 50713ae8e8e68..0a8027e3e12aa 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-bun.js @@ -8,10 +8,9 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigPlain'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; -export type Response = any; export opaque type ModuleLoading = mixed; export opaque type SSRModuleMap = mixed; export opaque type ServerManifest = mixed; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js index 269f8ec0c2313..c08af0a6531de 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-turbopack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js index cafa02b686214..db8da42686215 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-edge-webpack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js index 017dc33081d5f..b992b01803260 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-legacy.js @@ -8,8 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigWeb'; -export * from 'react-client/src/ReactFlightClientConsoleConfigBrowser'; -export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; export type Response = any; export opaque type ModuleLoading = mixed; @@ -22,5 +21,6 @@ export const resolveClientReference: any = null; export const resolveServerReference: any = null; export const preloadModule: any = null; export const requireModule: any = null; +export const dispatchHint: any = null; export const prepareDestinationForModule: any = null; export const usedWithSSR = true; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js index 6c68ae163bb51..bf2071d6fc864 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-esm.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigBundlerESM'; export * from 'react-server-dom-esm/src/ReactFlightClientConfigTargetESMServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js index f1e7d66ee8117..16f649249dd37 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack-bundled.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js index c6da80ef6060f..68047af97b9f8 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-turbopack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerNode'; export * from 'react-server-dom-turbopack/src/ReactFlightClientConfigTargetTurbopackServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js index 95fd1590ab5c6..37a5322140a8a 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node-webpack.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpack'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerWebpackServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js index 41c7e8e1d4706..867612c0ac2d7 100644 --- a/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js +++ b/packages/react-client/src/forks/ReactFlightClientConfig.dom-node.js @@ -8,7 +8,7 @@ */ export * from 'react-client/src/ReactFlightClientStreamConfigNode'; -export * from 'react-client/src/ReactFlightClientConsoleConfigServer'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigBundlerNode'; export * from 'react-server-dom-webpack/src/ReactFlightClientConfigTargetWebpackServer'; export * from 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM'; diff --git a/packages/react-client/src/forks/ReactFlightClientConfig.markup.js b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js new file mode 100644 index 0000000000000..8e91e3a8062fd --- /dev/null +++ b/packages/react-client/src/forks/ReactFlightClientConfig.markup.js @@ -0,0 +1,88 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Thenable} from 'shared/ReactTypes'; + +export * from 'react-html/src/ReactHTMLLegacyClientStreamConfig.js'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export type ModuleLoading = null; +export type SSRModuleMap = null; +export opaque type ServerManifest = null; +export opaque type ServerReferenceId = string; +export opaque type ClientReferenceMetadata = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars + +export function prepareDestinationForModule( + moduleLoading: ModuleLoading, + nonce: ?string, + metadata: ClientReferenceMetadata, +) { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveClientReference( + bundlerConfig: SSRModuleMap, + metadata: ClientReferenceMetadata, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export function resolveServerReference( + config: ServerManifest, + id: ServerReferenceId, +): ClientReference { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + +export function preloadModule( + metadata: ClientReference, +): null | Thenable { + return null; +} + +export function requireModule(metadata: ClientReference): T { + throw new Error( + 'renderToMarkup should not have emitted Client References. This is a bug in React.', + ); +} + +export const usedWithSSR = true; + +type HintCode = string; +type HintModel = null; // eslint-disable-line no-unused-vars + +export function dispatchHint( + code: Code, + model: HintModel, +): void { + // Should never happen. +} + +export function preinitModuleForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} + +export function preinitScriptForSSR( + href: string, + nonce: ?string, + crossOrigin: ?string, +) { + // Should never happen. +} diff --git a/packages/react-devtools-shared/src/backend/console.js b/packages/react-devtools-shared/src/backend/console.js index 524bad82cca2b..e1b98b9190129 100644 --- a/packages/react-devtools-shared/src/backend/console.js +++ b/packages/react-devtools-shared/src/backend/console.js @@ -51,7 +51,7 @@ const STYLE_DIRECTIVE_REGEX = /^%c/; // method has been overridden by the patchForStrictMode function. // If it has we'll need to do some special formatting of the arguments // so the console color stays consistent -function isStrictModeOverride(args: Array): boolean { +function isStrictModeOverride(args: Array): boolean { if (__IS_FIREFOX__) { return ( args.length >= 2 && @@ -63,6 +63,21 @@ function isStrictModeOverride(args: Array): boolean { } } +function restorePotentiallyModifiedArgs(args: Array): Array { + // If the arguments don't have any styles applied, then just copy + if (!isStrictModeOverride(args)) { + return args.slice(); + } + + if (__IS_FIREFOX__) { + // Filter out %c from the start of the first argument and color as a second argument + return [args[0].slice(2)].concat(args.slice(2)); + } else { + // Filter out the `\x1b...%s\x1b` template + return args.slice(1); + } +} + type OnErrorOrWarning = ( fiber: Fiber, type: 'error' | 'warn', @@ -220,8 +235,8 @@ export function patch({ onErrorOrWarning( current, ((method: any): 'error' | 'warn'), - // Copy args before we mutate them (e.g. adding the component stack) - args.slice(), + // Restore and copy args before we mutate them (e.g. adding the component stack) + restorePotentiallyModifiedArgs(args), ); } } diff --git a/packages/react-devtools-shared/src/hydration.js b/packages/react-devtools-shared/src/hydration.js index 6522076e7ddae..c5b78135e74a2 100644 --- a/packages/react-devtools-shared/src/hydration.js +++ b/packages/react-devtools-shared/src/hydration.js @@ -84,7 +84,9 @@ function createDehydrated( preview_long: formatDataForPreview(data, true), preview_short: formatDataForPreview(data, false), name: - !data.constructor || data.constructor.name === 'Object' + typeof data.constructor !== 'function' || + typeof data.constructor.name !== 'string' || + data.constructor.name === 'Object' ? '' : data.constructor.name, }; @@ -240,7 +242,9 @@ export function dehydrate( preview_short: formatDataForPreview(data, false), preview_long: formatDataForPreview(data, true), name: - !data.constructor || data.constructor.name === 'Object' + typeof data.constructor !== 'function' || + typeof data.constructor.name !== 'string' || + data.constructor.name === 'Object' ? '' : data.constructor.name, }; @@ -332,7 +336,11 @@ export function dehydrate( readonly: true, preview_short: formatDataForPreview(data, false), preview_long: formatDataForPreview(data, true), - name: data.constructor.name, + name: + typeof data.constructor !== 'function' || + typeof data.constructor.name !== 'string' + ? '' + : data.constructor.name, }; getAllEnumerableKeys(data).forEach(key => { diff --git a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js index 4b940731b99b0..b9b5d741d796a 100644 --- a/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js +++ b/packages/react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig.js @@ -66,9 +66,9 @@ export function typedArrayToBinaryChunk( throw new Error('Not implemented.'); } -export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number { - throw new Error('Not implemented.'); -} +export const byteLengthOfChunk: + | null + | ((chunk: Chunk | PrecomputedChunk) => number) = null; export function byteLengthOfBinaryChunk(chunk: BinaryChunk): number { throw new Error('Not implemented.'); diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js index 731b6f2483d07..594051dc35c2c 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOM.js @@ -108,6 +108,8 @@ export type HeadersDescriptor = { // E.g. this can be used to distinguish legacy renderers from this modern one. export const isPrimaryRenderer = true; +export const supportsClientAPIs = true; + export type StreamingFormat = 0 | 1; const ScriptStreamingFormat: StreamingFormat = 0; const DataStreamingFormat: StreamingFormat = 1; diff --git a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js index 92cd890976d22..725ee666f52af 100644 --- a/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js +++ b/packages/react-dom-bindings/src/server/ReactFizzConfigDOMLegacy.js @@ -166,6 +166,7 @@ export { resetResumableState, completeResumableState, emitEarlyPreloads, + supportsClientAPIs, } from './ReactFizzConfigDOM'; import escapeTextForBrowser from './escapeTextForBrowser'; diff --git a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js index 32905aa45b1fb..9cded881352af 100644 --- a/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js +++ b/packages/react-dom-bindings/src/server/ReactFlightServerConfigDOM.js @@ -20,10 +20,6 @@ import type { // but it does not have any exports import './ReactDOMFlightServerHostDispatcher'; -// Used to distinguish these contexts from ones used in other renderers. -// E.g. this can be used to distinguish legacy renderers from this modern one. -export const isPrimaryRenderer = true; - // We use zero to represent the absence of an explicit precedence because it is // small, smaller than how we encode undefined, and is unambiguous. We could use // a different tuple structure to encode this instead but this makes the runtime diff --git a/packages/react-dom/src/__tests__/ReactComponent-test.js b/packages/react-dom/src/__tests__/ReactComponent-test.js index f0d58280cd8c1..9b2f443a7c34d 100644 --- a/packages/react-dom/src/__tests__/ReactComponent-test.js +++ b/packages/react-dom/src/__tests__/ReactComponent-test.js @@ -14,6 +14,7 @@ let ReactDOM; let ReactDOMClient; let ReactDOMServer; let act; +let assertConsoleErrorDev; describe('ReactComponent', () => { beforeEach(() => { @@ -24,6 +25,8 @@ describe('ReactComponent', () => { ReactDOMClient = require('react-dom/client'); ReactDOMServer = require('react-dom/server'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; }); // @gate !disableLegacyMode @@ -131,8 +134,6 @@ describe('ReactComponent', () => { // @gate !disableStringRefs it('string refs do not detach and reattach on every render', async () => { - spyOnDev(console, 'error').mockImplementation(() => {}); - let refVal; class Child extends React.Component { componentDidUpdate() { @@ -171,6 +172,8 @@ describe('ReactComponent', () => { root.render(); }); + assertConsoleErrorDev(['contains the string ref']); + expect(refVal).toBe(undefined); await act(() => { root.render(); @@ -511,19 +514,25 @@ describe('ReactComponent', () => { }); it('throws usefully when rendering badly-typed elements', async () => { + const container = document.createElement('div'); + const root = ReactDOMClient.createRoot(container); + const X = undefined; - let container = document.createElement('div'); - let root = ReactDOMClient.createRoot(container); - await expect( - expect(async () => { - await act(() => { - root.render(); - }); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string (for built-in components) ' + - 'or a class/function (for composite components) but got: undefined.', - ), - ).rejects.toThrowError( + const XElement = ; + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: undefined.', + ], + {withoutStack: true}, + ); + } + await expect(async () => { + await act(() => { + root.render(XElement); + }); + }).rejects.toThrowError( 'Element type is invalid: expected a string (for built-in components) ' + 'or a class/function (for composite components) but got: undefined.' + (__DEV__ @@ -533,21 +542,44 @@ describe('ReactComponent', () => { ); const Y = null; - container = document.createElement('div'); - root = ReactDOMClient.createRoot(container); - await expect( - expect(async () => { - await act(() => { - root.render(); - }); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string (for built-in components) ' + - 'or a class/function (for composite components) but got: null.', - ), - ).rejects.toThrowError( + const YElement = ; + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: null.', + ], + {withoutStack: true}, + ); + } + await expect(async () => { + await act(() => { + root.render(YElement); + }); + }).rejects.toThrowError( 'Element type is invalid: expected a string (for built-in components) ' + 'or a class/function (for composite components) but got: null.', ); + + const Z = true; + const ZElement = ; + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: boolean.', + ], + {withoutStack: true}, + ); + } + await expect(async () => { + await act(() => { + root.render(ZElement); + }); + }).rejects.toThrowError( + 'Element type is invalid: expected a string (for built-in components) ' + + 'or a class/function (for composite components) but got: boolean.', + ); }); it('includes owner name in the error about badly-typed elements', async () => { diff --git a/packages/react-dom/src/__tests__/ReactDOMOption-test.js b/packages/react-dom/src/__tests__/ReactDOMOption-test.js index ee54bac0c3915..dab7f69b27e22 100644 --- a/packages/react-dom/src/__tests__/ReactDOMOption-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMOption-test.js @@ -134,7 +134,7 @@ describe('ReactDOMOption', () => { }).rejects.toThrow('Objects are not valid as a React child'); }); - // @gate www + // @gate www && !renameElementSymbol it('should support element-ish child', async () => { // This is similar to . // We don't toString it because you must instead provide a value prop. diff --git a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js index 2912a4f401dec..0fcc314d39a05 100644 --- a/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js +++ b/packages/react-dom/src/__tests__/ReactDOMServerIntegrationElements-test.js @@ -987,11 +987,13 @@ describe('ReactDOMServerIntegration', () => { expect(() => { EmptyComponent = ; }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: object. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', + gate(flags => flags.enableOwnerStacks) + ? [] + : 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: object. You likely forgot to export your ' + + "component from the file it's defined in, or you might have mixed up " + + 'default and named imports.', {withoutStack: true}, ); await render(EmptyComponent); @@ -1011,9 +1013,11 @@ describe('ReactDOMServerIntegration', () => { expect(() => { NullComponent = ; }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: null.', + gate(flags => flags.enableOwnerStacks) + ? [] + : 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: null.', {withoutStack: true}, ); await render(NullComponent); @@ -1029,11 +1033,13 @@ describe('ReactDOMServerIntegration', () => { expect(() => { UndefinedComponent = ; }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: undefined. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', + gate(flags => flags.enableOwnerStacks) + ? [] + : 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: undefined. You likely forgot to export your ' + + "component from the file it's defined in, or you might have mixed up " + + 'default and named imports.', {withoutStack: true}, ); diff --git a/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js b/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js index 2809460c5b06b..f45fae7bbd7e1 100644 --- a/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js +++ b/packages/react-dom/src/__tests__/ReactLegacyErrorBoundaries-test.internal.js @@ -13,6 +13,7 @@ let PropTypes; let React; let ReactDOM; let act; +let assertConsoleErrorDev; // TODO: Refactor this test once componentDidCatch setState is deprecated. describe('ReactLegacyErrorBoundaries', () => { @@ -42,6 +43,8 @@ describe('ReactLegacyErrorBoundaries', () => { ReactDOM = require('react-dom'); React = require('react'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; log = []; @@ -2099,32 +2102,38 @@ describe('ReactLegacyErrorBoundaries', () => { const Y = undefined; await expect(async () => { - await expect(async () => { - const container = document.createElement('div'); - await act(() => { - ReactDOM.render(, container); - }); - }).rejects.toThrow('got: null'); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function ' + - '(for composite components) but got: null.', - {withoutStack: 1}, - ); + const container = document.createElement('div'); + await act(() => { + ReactDOM.render(, container); + }); + }).rejects.toThrow('got: null'); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function ' + + '(for composite components) but got: null.', + ], + {withoutStack: true}, + ); + } await expect(async () => { - await expect(async () => { - const container = document.createElement('div'); - await act(() => { - ReactDOM.render(, container); - }); - }).rejects.toThrow('got: undefined'); - }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function ' + - '(for composite components) but got: undefined.', - {withoutStack: 1}, - ); + const container = document.createElement('div'); + await act(() => { + ReactDOM.render(, container); + }); + }).rejects.toThrow('got: undefined'); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + [ + 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function ' + + '(for composite components) but got: undefined.', + ], + {withoutStack: true}, + ); + } }); // @gate !disableLegacyMode diff --git a/packages/react-html/README.md b/packages/react-html/README.md new file mode 100644 index 0000000000000..c0794aac104c5 --- /dev/null +++ b/packages/react-html/README.md @@ -0,0 +1,32 @@ +# `react-html` + +This package provides the ability to render standalone HTML from Server Components for use in embedded contexts such as e-mails and RSS/Atom feeds. It cannot use Client Components and does not hydrate. It is intended to be paired with the generic React package, which is shipped as `react` to npm. + +## Installation + +```sh +npm install react react-html +``` + +## Usage + +```js +import { renderToMarkup } from 'react-html'; +import EmailTemplate from './my-email-template-component.js' + +async function action(email, name) { + "use server"; + // ... in your server, e.g. a Server Action... + const htmlString = await renderToMarkup(); + // ... send e-mail using some e-mail provider + await sendEmail({ to: email, contentType: 'text/html', body: htmlString }); +} +``` + +Note that this is an async function that needs to be awaited - unlike the legacy `renderToString` in `react-dom`. + +## API + +### `react-html` + +See https://react.dev/reference/react-html diff --git a/packages/react-html/index.js b/packages/react-html/index.js new file mode 100644 index 0000000000000..01e5f5e51b08c --- /dev/null +++ b/packages/react-html/index.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './src/ReactHTMLClient'; diff --git a/packages/react-html/npm/index.js b/packages/react-html/npm/index.js new file mode 100644 index 0000000000000..753fdef93e42d --- /dev/null +++ b/packages/react-html/npm/index.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-html.production.js'); +} else { + module.exports = require('./cjs/react-html.development.js'); +} diff --git a/packages/react-html/npm/react-html.react-server.js b/packages/react-html/npm/react-html.react-server.js new file mode 100644 index 0000000000000..be2af1e0c5838 --- /dev/null +++ b/packages/react-html/npm/react-html.react-server.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-html.react-server.production.js'); +} else { + module.exports = require('./cjs/react-html.react-server.development.js'); +} diff --git a/packages/react-html/package.json b/packages/react-html/package.json new file mode 100644 index 0000000000000..92dfc38512338 --- /dev/null +++ b/packages/react-html/package.json @@ -0,0 +1,38 @@ +{ + "name": "react-html", + "version": "19.0.0", + "private": true, + "description": "React package generating embedded HTML markup such as e-mails using Server Components.", + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react.git", + "directory": "packages/react-html" + }, + "keywords": [ + "react" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/facebook/react/issues" + }, + "homepage": "https://react.dev/", + "peerDependencies": { + "react": "^19.0.0" + }, + "files": [ + "LICENSE", + "README.md", + "index.js", + "react-html.react-server.js", + "cjs/" + ], + "exports": { + ".": { + "react-server": "./react-html.react-server.js", + "default": "./index.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + } +} diff --git a/packages/react-html/react-html.react-server.js b/packages/react-html/react-html.react-server.js new file mode 100644 index 0000000000000..fb30cd4200836 --- /dev/null +++ b/packages/react-html/react-html.react-server.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from './src/ReactHTMLServer'; diff --git a/packages/react-html/src/ReactFizzConfigHTML.js b/packages/react-html/src/ReactFizzConfigHTML.js new file mode 100644 index 0000000000000..0be7abe8bb0ad --- /dev/null +++ b/packages/react-html/src/ReactFizzConfigHTML.js @@ -0,0 +1,182 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import type { + RenderState, + ResumableState, + HoistableState, + FormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import {pushStartInstance as pushStartInstanceImpl} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import type { + Destination, + Chunk, + PrecomputedChunk, +} from 'react-server/src/ReactServerStreamConfig'; + +import type {FormStatus} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; + +import {NotPending} from 'react-dom-bindings/src/shared/ReactDOMFormActions'; + +import hasOwnProperty from 'shared/hasOwnProperty'; + +// Allow embedding inside another Fizz render. +export const isPrimaryRenderer = false; + +// Disable Client Hooks +export const supportsClientAPIs = false; + +import {stringToChunk} from 'react-server/src/ReactServerStreamConfig'; + +export type { + RenderState, + ResumableState, + HoistableState, + FormatContext, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +export { + getChildFormatContext, + makeId, + pushEndInstance, + pushStartCompletedSuspenseBoundary, + pushEndCompletedSuspenseBoundary, + pushFormStateMarkerIsMatching, + pushFormStateMarkerIsNotMatching, + writeStartSegment, + writeEndSegment, + writeCompletedSegmentInstruction, + writeCompletedBoundaryInstruction, + writeClientRenderBoundaryInstruction, + writeStartPendingSuspenseBoundary, + writeEndPendingSuspenseBoundary, + writeHoistablesForBoundary, + writePlaceholder, + writeCompletedRoot, + createRootFormatContext, + createRenderState, + createResumableState, + createHoistableState, + writePreamble, + writeHoistables, + writePostamble, + hoistHoistables, + resetResumableState, + completeResumableState, + emitEarlyPreloads, + doctypeChunk, +} from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; + +import escapeTextForBrowser from 'react-dom-bindings/src/server/escapeTextForBrowser'; + +export function pushStartInstance( + target: Array, + type: string, + props: Object, + resumableState: ResumableState, + renderState: RenderState, + hoistableState: null | HoistableState, + formatContext: FormatContext, + textEmbedded: boolean, + isFallback: boolean, +): ReactNodeList { + for (const propKey in props) { + if (hasOwnProperty.call(props, propKey)) { + const propValue = props[propKey]; + if (propKey === 'ref' && propValue != null) { + throw new Error( + 'Cannot pass ref in renderToMarkup because they will never be hydrated.', + ); + } + if (typeof propValue === 'function') { + throw new Error( + 'Cannot pass event handlers (' + + propKey + + ') in renderToMarkup because ' + + 'the HTML will never be hydrated so they can never get called.', + ); + } + } + } + + return pushStartInstanceImpl( + target, + type, + props, + resumableState, + renderState, + hoistableState, + formatContext, + textEmbedded, + isFallback, + ); +} + +export function pushTextInstance( + target: Array, + text: string, + renderState: RenderState, + textEmbedded: boolean, +): boolean { + // Markup doesn't need any termination. + target.push(stringToChunk(escapeTextForBrowser(text))); + return false; +} + +export function pushSegmentFinale( + target: Array, + renderState: RenderState, + lastPushedText: boolean, + textEmbedded: boolean, +): void { + // Markup doesn't need any termination. + return; +} + +export function writeStartCompletedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} +export function writeStartClientRenderedSuspenseBoundary( + destination: Destination, + renderState: RenderState, + // flushing these error arguments are not currently supported in this legacy streaming format. + errorDigest: ?string, + errorMessage: ?string, + errorStack: ?string, + errorComponentStack: ?string, +): boolean { + // Markup doesn't have any instructions. + return true; +} + +export function writeEndCompletedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} +export function writeEndClientRenderedSuspenseBoundary( + destination: Destination, + renderState: RenderState, +): boolean { + // Markup doesn't have any instructions. + return true; +} + +export type TransitionStatus = FormStatus; +export const NotPendingTransition: TransitionStatus = NotPending; diff --git a/packages/react-html/src/ReactHTMLClient.js b/packages/react-html/src/ReactHTMLClient.js new file mode 100644 index 0000000000000..533ae7a3c3e7b --- /dev/null +++ b/packages/react-html/src/ReactHTMLClient.js @@ -0,0 +1,100 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest as createFizzRequest, + startWork as startFizzWork, + startFlowing as startFizzFlowing, + abort as abortFizz, +} from 'react-server/src/ReactFizzServer'; + +import { + createResumableState, + createRenderState, + createRootFormatContext, +} from './ReactFizzConfigHTML'; + +type MarkupOptions = { + identifierPrefix?: string, + signal?: AbortSignal, +}; + +export function renderToMarkup( + children: ReactNodeList, + options?: MarkupOptions, +): Promise { + return new Promise((resolve, reject) => { + let buffer = ''; + const fizzDestination = { + push(chunk: string | null): boolean { + if (chunk !== null) { + buffer += chunk; + } else { + // null indicates that we finished + resolve(buffer); + } + return true; + }, + destroy(error: mixed) { + reject(error); + }, + }; + function onError(error: mixed) { + // Any error rejects the promise, regardless of where it happened. + // Unlike other React SSR we don't want to put Suspense boundaries into + // client rendering mode because there's no client rendering here. + reject(error); + } + const resumableState = createResumableState( + options ? options.identifierPrefix : undefined, + undefined, + ); + const fizzRequest = createFizzRequest( + children, + resumableState, + createRenderState( + resumableState, + undefined, + undefined, + undefined, + undefined, + undefined, + ), + createRootFormatContext(), + Infinity, + onError, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abortFizz(fizzRequest, (signal: any).reason); + } else { + const listener = () => { + abortFizz(fizzRequest, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startFizzWork(fizzRequest); + startFizzFlowing(fizzRequest, fizzDestination); + }); +} + +export {ReactVersion as version}; diff --git a/packages/react-html/src/ReactHTMLClient.stable.js b/packages/react-html/src/ReactHTMLClient.stable.js new file mode 100644 index 0000000000000..0874c2b7af877 --- /dev/null +++ b/packages/react-html/src/ReactHTMLClient.stable.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// eslint-disable-next-line react-internal/prod-error-codes +throw new Error('react-html should not get built in stable'); diff --git a/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js new file mode 100644 index 0000000000000..74b0503590462 --- /dev/null +++ b/packages/react-html/src/ReactHTMLLegacyClientStreamConfig.js @@ -0,0 +1,32 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// TODO: The legacy one should not use binary. + +export type StringDecoder = TextDecoder; + +export function createStringDecoder(): StringDecoder { + return new TextDecoder(); +} + +const decoderOptions = {stream: true}; + +export function readPartialStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer, decoderOptions); +} + +export function readFinalStringChunk( + decoder: StringDecoder, + buffer: Uint8Array, +): string { + return decoder.decode(buffer); +} diff --git a/packages/react-html/src/ReactHTMLServer.js b/packages/react-html/src/ReactHTMLServer.js new file mode 100644 index 0000000000000..923881e4da755 --- /dev/null +++ b/packages/react-html/src/ReactHTMLServer.js @@ -0,0 +1,185 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {ReactNodeList} from 'shared/ReactTypes'; +import type {LazyComponent} from 'react/src/ReactLazy'; + +import ReactVersion from 'shared/ReactVersion'; + +import { + createRequest as createFlightRequest, + startWork as startFlightWork, + startFlowing as startFlightFlowing, + abort as abortFlight, +} from 'react-server/src/ReactFlightServer'; + +import { + createResponse as createFlightResponse, + getRoot as getFlightRoot, + processBinaryChunk as processFlightBinaryChunk, + close as closeFlight, +} from 'react-client/src/ReactFlightClient'; + +import { + createRequest as createFizzRequest, + startWork as startFizzWork, + startFlowing as startFizzFlowing, + abort as abortFizz, +} from 'react-server/src/ReactFizzServer'; + +import { + createResumableState, + createRenderState, + createRootFormatContext, +} from './ReactFizzConfigHTML'; + +type ReactMarkupNodeList = + // This is the intersection of ReactNodeList and ReactClientValue minus + // Client/ServerReferences. + | React$Element> + | LazyComponent + | React$Element + | string + | boolean + | number + | symbol + | null + | void + | bigint + | $AsyncIterable + | $AsyncIterator + | Iterable + | Iterator + | Array + | Promise; // Thenable + +type MarkupOptions = { + identifierPrefix?: string, + signal?: AbortSignal, +}; + +function noServerCallOrFormAction() { + throw new Error( + 'renderToMarkup should not have emitted Server References. This is a bug in React.', + ); +} + +export function renderToMarkup( + children: ReactMarkupNodeList, + options?: MarkupOptions, +): Promise { + return new Promise((resolve, reject) => { + const textEncoder = new TextEncoder(); + const flightDestination = { + push(chunk: string | null): boolean { + if (chunk !== null) { + // TODO: Legacy should not use binary streams. + processFlightBinaryChunk(flightResponse, textEncoder.encode(chunk)); + } else { + closeFlight(flightResponse); + } + return true; + }, + destroy(error: mixed): void { + abortFizz(fizzRequest, error); + reject(error); + }, + }; + let buffer = ''; + const fizzDestination = { + // $FlowFixMe[missing-local-annot] + push(chunk) { + if (chunk !== null) { + buffer += chunk; + } else { + // null indicates that we finished + resolve(buffer); + } + return true; + }, + // $FlowFixMe[missing-local-annot] + destroy(error) { + abortFlight(flightRequest, error); + reject(error); + }, + }; + function onError(error: mixed) { + // Any error rejects the promise, regardless of where it happened. + // Unlike other React SSR we don't want to put Suspense boundaries into + // client rendering mode because there's no client rendering here. + reject(error); + } + const flightRequest = createFlightRequest( + // $FlowFixMe: This should be a subtype but not everything is typed covariant. + children, + null, + onError, + options ? options.identifierPrefix : undefined, + undefined, + 'Markup', + undefined, + ); + const flightResponse = createFlightResponse( + null, + null, + noServerCallOrFormAction, + noServerCallOrFormAction, + undefined, + undefined, + undefined, + ); + const resumableState = createResumableState( + options ? options.identifierPrefix : undefined, + undefined, + ); + const root = getFlightRoot(flightResponse); + const fizzRequest = createFizzRequest( + // $FlowFixMe: Thenables as children are supported. + root, + resumableState, + createRenderState( + resumableState, + undefined, + undefined, + undefined, + undefined, + undefined, + ), + createRootFormatContext(), + Infinity, + onError, + undefined, + undefined, + undefined, + undefined, + undefined, + undefined, + ); + if (options && options.signal) { + const signal = options.signal; + if (signal.aborted) { + abortFlight(flightRequest, (signal: any).reason); + abortFizz(fizzRequest, (signal: any).reason); + } else { + const listener = () => { + abortFlight(flightRequest, (signal: any).reason); + abortFizz(fizzRequest, (signal: any).reason); + signal.removeEventListener('abort', listener); + }; + signal.addEventListener('abort', listener); + } + } + startFlightWork(flightRequest); + startFlightFlowing(flightRequest, flightDestination); + startFizzWork(fizzRequest); + startFizzFlowing(fizzRequest, fizzDestination); + }); +} + +export {ReactVersion as version}; diff --git a/packages/react-html/src/ReactHTMLServer.stable.js b/packages/react-html/src/ReactHTMLServer.stable.js new file mode 100644 index 0000000000000..0874c2b7af877 --- /dev/null +++ b/packages/react-html/src/ReactHTMLServer.stable.js @@ -0,0 +1,11 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// eslint-disable-next-line react-internal/prod-error-codes +throw new Error('react-html should not get built in stable'); diff --git a/packages/react-html/src/__tests__/ReactHTMLClient-test.js b/packages/react-html/src/__tests__/ReactHTMLClient-test.js new file mode 100644 index 0000000000000..02cef97c2d82f --- /dev/null +++ b/packages/react-html/src/__tests__/ReactHTMLClient-test.js @@ -0,0 +1,174 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +let React; +let ReactHTML; + +if (!__EXPERIMENTAL__) { + it('should not be built in stable', () => { + try { + require('react-html'); + } catch (x) { + return; + } + throw new Error('Expected react-html not to exist in stable.'); + }); +} else { + describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + React = require('react'); + ReactHTML = require('react-html'); + }); + + it('should be able to render a simple component', async () => { + function Component() { + return
hello world
; + } + + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
hello world
'); + }); + + it('should be able to render a large string', async () => { + function Component() { + return
{'hello '.repeat(200)}world
; + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
' + ('hello '.repeat(200) + 'world') + '
'); + }); + + it('should prefix html tags with a doctype', async () => { + const html = await ReactHTML.renderToMarkup( + + hello + , + ); + expect(html).toBe( + 'hello', + ); + }); + + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + return
{state}
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + return
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + return
; + } + + await expect(async () => { + await ReactHTML.renderToMarkup(); + }).rejects.toThrow(); + }); + + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } + + const html = await ReactHTML.renderToMarkup(); + const container = document.createElement('div'); + container.innerHTML = html; + + expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), + ); + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + }); + + // @gate disableClientCache + it('does NOT support cache yet because it is a client component', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return ( +
+ {a} + {b} +
+ ); + } + + const html = await ReactHTML.renderToMarkup(); + expect(html).toBe('
01
'); + }); + }); +} diff --git a/packages/react-html/src/__tests__/ReactHTMLServer-test.js b/packages/react-html/src/__tests__/ReactHTMLServer-test.js new file mode 100644 index 0000000000000..236c2b0f4021c --- /dev/null +++ b/packages/react-html/src/__tests__/ReactHTMLServer-test.js @@ -0,0 +1,204 @@ +/** + * 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. + * + * @emails react-core + */ + +'use strict'; + +global.TextDecoder = require('util').TextDecoder; +global.TextEncoder = require('util').TextEncoder; + +let React; +let ReactHTML; + +if (!__EXPERIMENTAL__) { + it('should not be built in stable', () => { + try { + require('react-html'); + } catch (x) { + return; + } + throw new Error('Expected react-html not to exist in stable.'); + }); +} else { + describe('ReactHTML', () => { + beforeEach(() => { + jest.resetModules(); + // We run in the react-server condition. + jest.mock('react', () => require('react/react.react-server')); + if (__EXPERIMENTAL__) { + jest.mock('react-html', () => + require('react-html/react-html.react-server'), + ); + } + + React = require('react'); + if (__EXPERIMENTAL__) { + ReactHTML = require('react-html'); + } else { + try { + require('react-html/react-html.react-server'); + } catch (x) { + return; + } + throw new Error('Expected react-html not to exist in stable.'); + } + }); + + it('should be able to render a simple component', async () => { + function Component() { + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, 'hello world'); + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
hello world
'); + }); + + it('should be able to render a large string', async () => { + function Component() { + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, 'hello '.repeat(200) + 'world'); + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
' + ('hello '.repeat(200) + 'world') + '
'); + }); + + it('should prefix html tags with a doctype', async () => { + const html = await ReactHTML.renderToMarkup( + // We can't use JSX because that's client-JSX in our tests. + React.createElement( + 'html', + null, + React.createElement('body', null, 'hello'), + ), + ); + expect(html).toBe( + 'hello', + ); + }); + + it('should error on useState', async () => { + function Component() { + const [state] = React.useState('hello'); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', null, state); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + + it('should error on refs passed to host components', async () => { + function Component() { + const ref = React.createRef(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {ref}); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + + it('should error on callbacks passed to event handlers', async () => { + function Component() { + function onClick() { + // This won't be able to be called. + } + // We can't use JSX because that's client-JSX in our tests. + return React.createElement('div', {onClick}); + } + + await expect(async () => { + await ReactHTML.renderToMarkup(React.createElement(Component)); + }).rejects.toThrow(); + }); + + it('supports the useId Hook', async () => { + function Component() { + const firstNameId = React.useId(); + const lastNameId = React.useId(); + // We can't use JSX because that's client-JSX in our tests. + return React.createElement( + 'div', + null, + React.createElement( + 'h2', + { + id: firstNameId, + }, + 'First', + ), + React.createElement( + 'p', + { + 'aria-labelledby': firstNameId, + }, + 'Sebastian', + ), + React.createElement( + 'h2', + { + id: lastNameId, + }, + 'Last', + ), + React.createElement( + 'p', + { + 'aria-labelledby': lastNameId, + }, + 'Smith', + ), + ); + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + const container = document.createElement('div'); + container.innerHTML = html; + + expect(container.getElementsByTagName('h2')[0].id).toBe( + container.getElementsByTagName('p')[0].getAttribute('aria-labelledby'), + ); + expect(container.getElementsByTagName('h2')[1].id).toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + + // It's not the same id between them. + expect(container.getElementsByTagName('h2')[0].id).not.toBe( + container.getElementsByTagName('p')[1].getAttribute('aria-labelledby'), + ); + }); + + // @gate enableCache + it('supports cache', async () => { + let counter = 0; + const getCount = React.cache(() => { + return counter++; + }); + function Component() { + const a = getCount(); + const b = getCount(); + return React.createElement('div', null, a, b); + } + + const html = await ReactHTML.renderToMarkup( + React.createElement(Component), + ); + expect(html).toBe('
00
'); + }); + }); +} diff --git a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js index 47ee3cb8f7434..99d21ea933ecd 100644 --- a/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js +++ b/packages/react-native-renderer/src/__tests__/ReactFabric-test.internal.js @@ -25,10 +25,6 @@ const SEND_ACCESSIBILITY_EVENT_REQUIRES_HOST_COMPONENT = "sendAccessibilityEvent was called with a ref that isn't a " + 'native component. Use React.forwardRef to get access to the underlying native component'; -jest.mock('shared/ReactFeatureFlags', () => - require('shared/forks/ReactFeatureFlags.native-oss'), -); - describe('ReactFabric', () => { beforeEach(() => { jest.resetModules(); @@ -52,7 +48,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(, 1); + ReactFabric.render(, 1, null, true); }); expect(nativeFabricUIManager.createNode).toBeCalled(); expect(nativeFabricUIManager.appendChild).not.toBeCalled(); @@ -70,13 +66,13 @@ describe('ReactFabric', () => { nativeFabricUIManager.createNode.mockReturnValue(firstNode); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.createNode).toHaveBeenCalledTimes(1); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.createNode).toHaveBeenCalledTimes(1); @@ -100,7 +96,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(1, 11); + ReactFabric.render(1, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewChildren).not.toBeCalled(); @@ -111,7 +107,7 @@ describe('ReactFabric', () => { // If no properties have changed, we shouldn't call cloneNode. await act(() => { - ReactFabric.render(1, 11); + ReactFabric.render(1, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewChildren).not.toBeCalled(); @@ -122,7 +118,7 @@ describe('ReactFabric', () => { // Only call cloneNode for the changed property (and not for text). await act(() => { - ReactFabric.render(1, 11); + ReactFabric.render(1, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewChildren).not.toBeCalled(); @@ -135,7 +131,7 @@ describe('ReactFabric', () => { // Only call cloneNode for the changed text (and no other properties). await act(() => { - ReactFabric.render(2, 11); + ReactFabric.render(2, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect( @@ -150,7 +146,7 @@ describe('ReactFabric', () => { // Call cloneNode for both changed text and properties. await act(() => { - ReactFabric.render(3, 11); + ReactFabric.render(3, 11, null, true); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect( @@ -176,6 +172,8 @@ describe('ReactFabric', () => { 1 , 11, + null, + true, ); }); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); @@ -191,6 +189,8 @@ describe('ReactFabric', () => { 1 , 11, + null, + true, ); }); expect( @@ -198,9 +198,9 @@ describe('ReactFabric', () => { ).toEqual({ bar: 'b', }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTText {"foo":"a","bar":"b"} + RCTRawText {"text":"1"}`); await act(() => { ReactFabric.render( @@ -208,6 +208,8 @@ describe('ReactFabric', () => { 2 , 11, + null, + true, ); }); const argIndex = gate(flags => flags.passChildrenWhenCloningPersistedNodes) @@ -220,9 +222,9 @@ describe('ReactFabric', () => { ).toEqual({ foo: 'b', }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTText {"foo":"b","bar":"b"} + RCTRawText {"text":"2"}`); }); it('should not clone nodes without children when updating props', async () => { @@ -237,11 +239,15 @@ describe('ReactFabric', () => { ); - await act(() => ReactFabric.render(, 11)); + await act(() => + ReactFabric.render(, 11, null, true), + ); expect(nativeFabricUIManager.completeRoot).toBeCalled(); jest.clearAllMocks(); - await act(() => ReactFabric.render(, 11)); + await act(() => + ReactFabric.render(, 11, null, true), + ); expect(nativeFabricUIManager.cloneNode).not.toBeCalled(); expect(nativeFabricUIManager.cloneNodeWithNewProps).toHaveBeenCalledTimes( 1, @@ -290,6 +296,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -321,6 +329,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -351,6 +361,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -383,6 +395,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); @@ -396,7 +410,33 @@ describe('ReactFabric', () => { expect(nativeFabricUIManager.sendAccessibilityEvent).not.toBeCalled(); }); - it('returns the correct instance and calls it in the callback', () => { + it('calls the callback with the correct instance and returns null', async () => { + const View = createReactNativeComponentClass('RCTView', () => ({ + validAttributes: {foo: true}, + uiViewClassName: 'RCTView', + })); + + let a; + let b; + let c; + await act(() => { + c = ReactFabric.render( + (a = v)} />, + 11, + function () { + b = this; + }, + true, + ); + }); + + expect(a).toBeTruthy(); + expect(a).toBe(b); + expect(c).toBe(null); + }); + + // @gate !disableLegacyMode + it('returns the instance in legacy mode and calls the callback with it', () => { const View = createReactNativeComponentClass('RCTView', () => ({ validAttributes: {foo: true}, uiViewClassName: 'RCTView', @@ -405,7 +445,12 @@ describe('ReactFabric', () => { let a; let b; const c = ReactFabric.render( - (a = v)} />, + { + a = v; + }} + />, 11, function () { b = this; @@ -441,18 +486,56 @@ describe('ReactFabric', () => { const after = 'mxhpgwfralkeoivcstzy'; await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTView null + RCTView {"title":"a"} + RCTView {"title":"b"} + RCTView {"title":"c"} + RCTView {"title":"d"} + RCTView {"title":"e"} + RCTView {"title":"f"} + RCTView {"title":"g"} + RCTView {"title":"h"} + RCTView {"title":"i"} + RCTView {"title":"j"} + RCTView {"title":"k"} + RCTView {"title":"l"} + RCTView {"title":"m"} + RCTView {"title":"n"} + RCTView {"title":"o"} + RCTView {"title":"p"} + RCTView {"title":"q"} + RCTView {"title":"r"} + RCTView {"title":"s"} + RCTView {"title":"t"}`); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTView null + RCTView {"title":"m"} + RCTView {"title":"x"} + RCTView {"title":"h"} + RCTView {"title":"p"} + RCTView {"title":"g"} + RCTView {"title":"w"} + RCTView {"title":"f"} + RCTView {"title":"r"} + RCTView {"title":"a"} + RCTView {"title":"l"} + RCTView {"title":"k"} + RCTView {"title":"e"} + RCTView {"title":"o"} + RCTView {"title":"i"} + RCTView {"title":"v"} + RCTView {"title":"c"} + RCTView {"title":"s"} + RCTView {"title":"t"} + RCTView {"title":"z"} + RCTView {"title":"y"}`); }); it('recreates host parents even if only children changed', async () => { @@ -488,20 +571,66 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe( + `11 + RCTView null + RCTView null + RCTView {"title":"a"} + RCTView {"title":"b"} + RCTView {"title":"c"} + RCTView {"title":"d"} + RCTView {"title":"e"} + RCTView {"title":"f"} + RCTView {"title":"g"} + RCTView {"title":"h"} + RCTView {"title":"i"} + RCTView {"title":"j"} + RCTView {"title":"k"} + RCTView {"title":"l"} + RCTView {"title":"m"} + RCTView {"title":"n"} + RCTView {"title":"o"} + RCTView {"title":"p"} + RCTView {"title":"q"} + RCTView {"title":"r"} + RCTView {"title":"s"} + RCTView {"title":"t"}`, + ); // Call setState() so that we skip over the top-level host node. // It should still get recreated despite a bailout. - ref.current.setState({ - chars: after, + await act(() => { + ref.current.setState({ + chars: after, + }); }); - expect( - nativeFabricUIManager.__dumpHierarchyForJestTestsOnly(), - ).toMatchSnapshot(); + expect(nativeFabricUIManager.__dumpHierarchyForJestTestsOnly()).toBe(`11 + RCTView null + RCTView null + RCTView {"title":"m"} + RCTView {"title":"x"} + RCTView {"title":"h"} + RCTView {"title":"p"} + RCTView {"title":"g"} + RCTView {"title":"w"} + RCTView {"title":"f"} + RCTView {"title":"r"} + RCTView {"title":"a"} + RCTView {"title":"l"} + RCTView {"title":"k"} + RCTView {"title":"e"} + RCTView {"title":"o"} + RCTView {"title":"i"} + RCTView {"title":"v"} + RCTView {"title":"c"} + RCTView {"title":"s"} + RCTView {"title":"t"} + RCTView {"title":"z"} + RCTView {"title":"y"}`); }); it('calls setState with no arguments', async () => { @@ -516,7 +645,7 @@ describe('ReactFabric', () => { } await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(mockArgs.length).toEqual(0); }); @@ -542,9 +671,14 @@ describe('ReactFabric', () => { , 22, + null, + true, ); }); - expect(snapshots).toMatchSnapshot(); + expect(snapshots).toEqual([ + `RCTView {"foo":"a"} + RCTView {"foo":"b"}`, + ]); }); it('should not throw when is used inside of a ancestor', async () => { @@ -567,6 +701,8 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); @@ -576,6 +712,8 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); }); @@ -596,7 +734,7 @@ describe('ReactFabric', () => { await expect(async () => { await act(() => { - ReactFabric.render(this should warn, 11); + ReactFabric.render(this should warn, 11, null, true); }); }).toErrorDev(['Text strings must be rendered within a component.']); @@ -607,6 +745,8 @@ describe('ReactFabric', () => { hi hello hi , 11, + null, + true, ); }); }).toErrorDev(['Text strings must be rendered within a component.']); @@ -626,6 +766,8 @@ describe('ReactFabric', () => { , 11, + null, + true, ); }); }); @@ -645,7 +787,7 @@ describe('ReactFabric', () => { const touchStart2 = jest.fn(); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); expect(nativeFabricUIManager.createNode.mock.calls.length).toBe(1); @@ -671,7 +813,7 @@ describe('ReactFabric', () => { expect(touchStart2).not.toBeCalled(); await act(() => { - ReactFabric.render(, 11); + ReactFabric.render(, 11, null, true); }); // Intentionally dispatch to the same instanceHandle again. @@ -737,6 +879,8 @@ describe('ReactFabric', () => { /> , 11, + null, + true, ); }); @@ -832,6 +976,8 @@ describe('ReactFabric', () => { /> , 1, + null, + true, ); }); @@ -891,6 +1037,8 @@ describe('ReactFabric', () => { ReactFabric.render( (parent = n)} />, 11, + null, + true, ); }); @@ -930,6 +1078,8 @@ describe('ReactFabric', () => { (parent = n)} /> , 11, + null, + true, ); }); @@ -971,6 +1121,8 @@ describe('ReactFabric', () => { ReactFabric.render( (parent = n)} />, 11, + null, + true, ); }); @@ -1010,6 +1162,8 @@ describe('ReactFabric', () => { (parent = n)} /> , 11, + null, + true, ); }); @@ -1045,6 +1199,8 @@ describe('ReactFabric', () => { }} />, 11, + null, + true, ); }); const dangerouslyRetainedViewRef = viewRef; @@ -1067,7 +1223,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(, 1); + ReactFabric.render(, 1, null, true); }); const internalInstanceHandle = @@ -1100,6 +1256,8 @@ describe('ReactFabric', () => { }} />, 1, + null, + true, ); }); @@ -1114,7 +1272,7 @@ describe('ReactFabric', () => { expect(publicInstance).toBe(viewRef); await act(() => { - ReactFabric.render(null, 1); + ReactFabric.render(null, 1, null, true); }); const publicInstanceAfterUnmount = @@ -1133,7 +1291,7 @@ describe('ReactFabric', () => { })); await act(() => { - ReactFabric.render(Text content, 1); + ReactFabric.render(Text content, 1, null, true); }); // Access the internal instance handle used to create the text node. @@ -1165,7 +1323,7 @@ describe('ReactFabric', () => { expect(publicInstance).toBe(expectedPublicInstance); await act(() => { - ReactFabric.render(null, 1); + ReactFabric.render(null, 1, null, true); }); const publicInstanceAfterUnmount = diff --git a/packages/react-native-renderer/src/__tests__/__snapshots__/ReactFabric-test.internal.js.snap b/packages/react-native-renderer/src/__tests__/__snapshots__/ReactFabric-test.internal.js.snap deleted file mode 100644 index 90953ff2e6b31..0000000000000 --- a/packages/react-native-renderer/src/__tests__/__snapshots__/ReactFabric-test.internal.js.snap +++ /dev/null @@ -1,122 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ReactFabric recreates host parents even if only children changed 1`] = ` -"11 - RCTView null - RCTView null - RCTView {"title":"a"} - RCTView {"title":"b"} - RCTView {"title":"c"} - RCTView {"title":"d"} - RCTView {"title":"e"} - RCTView {"title":"f"} - RCTView {"title":"g"} - RCTView {"title":"h"} - RCTView {"title":"i"} - RCTView {"title":"j"} - RCTView {"title":"k"} - RCTView {"title":"l"} - RCTView {"title":"m"} - RCTView {"title":"n"} - RCTView {"title":"o"} - RCTView {"title":"p"} - RCTView {"title":"q"} - RCTView {"title":"r"} - RCTView {"title":"s"} - RCTView {"title":"t"}" -`; - -exports[`ReactFabric recreates host parents even if only children changed 2`] = ` -"11 - RCTView null - RCTView null - RCTView {"title":"m"} - RCTView {"title":"x"} - RCTView {"title":"h"} - RCTView {"title":"p"} - RCTView {"title":"g"} - RCTView {"title":"w"} - RCTView {"title":"f"} - RCTView {"title":"r"} - RCTView {"title":"a"} - RCTView {"title":"l"} - RCTView {"title":"k"} - RCTView {"title":"e"} - RCTView {"title":"o"} - RCTView {"title":"i"} - RCTView {"title":"v"} - RCTView {"title":"c"} - RCTView {"title":"s"} - RCTView {"title":"t"} - RCTView {"title":"z"} - RCTView {"title":"y"}" -`; - -exports[`ReactFabric renders and reorders children 1`] = ` -"11 - RCTView null - RCTView {"title":"a"} - RCTView {"title":"b"} - RCTView {"title":"c"} - RCTView {"title":"d"} - RCTView {"title":"e"} - RCTView {"title":"f"} - RCTView {"title":"g"} - RCTView {"title":"h"} - RCTView {"title":"i"} - RCTView {"title":"j"} - RCTView {"title":"k"} - RCTView {"title":"l"} - RCTView {"title":"m"} - RCTView {"title":"n"} - RCTView {"title":"o"} - RCTView {"title":"p"} - RCTView {"title":"q"} - RCTView {"title":"r"} - RCTView {"title":"s"} - RCTView {"title":"t"}" -`; - -exports[`ReactFabric renders and reorders children 2`] = ` -"11 - RCTView null - RCTView {"title":"m"} - RCTView {"title":"x"} - RCTView {"title":"h"} - RCTView {"title":"p"} - RCTView {"title":"g"} - RCTView {"title":"w"} - RCTView {"title":"f"} - RCTView {"title":"r"} - RCTView {"title":"a"} - RCTView {"title":"l"} - RCTView {"title":"k"} - RCTView {"title":"e"} - RCTView {"title":"o"} - RCTView {"title":"i"} - RCTView {"title":"v"} - RCTView {"title":"c"} - RCTView {"title":"s"} - RCTView {"title":"t"} - RCTView {"title":"z"} - RCTView {"title":"y"}" -`; - -exports[`ReactFabric should call complete after inserting children 1`] = ` -[ - "RCTView {"foo":"a"} - RCTView {"foo":"b"}", -] -`; - -exports[`ReactFabric should only pass props diffs to FabricUIManager.cloneNode 1`] = ` -"11 - RCTText {"foo":"a","bar":"b"} - RCTRawText {"text":"1"}" -`; - -exports[`ReactFabric should only pass props diffs to FabricUIManager.cloneNode 2`] = ` -"11 - RCTText {"foo":"b","bar":"b"} - RCTRawText {"text":"2"}" -`; diff --git a/packages/react-noop-renderer/src/createReactNoop.js b/packages/react-noop-renderer/src/createReactNoop.js index e512fba13fc5d..a7cfc94e00a15 100644 --- a/packages/react-noop-renderer/src/createReactNoop.js +++ b/packages/react-noop-renderer/src/createReactNoop.js @@ -635,6 +635,11 @@ function createReactNoop(reconciler: Function, useMutation: boolean) { NotPendingTransition: (null: TransitionStatus), resetFormInstance(form: Instance) {}, + + printToConsole(methodName, args, badgeName) { + // eslint-disable-next-line react-internal/no-production-logging + console[methodName].apply(console, args); + }, }; const hostConfig = useMutation diff --git a/packages/react-reconciler/src/ReactChildFiber.js b/packages/react-reconciler/src/ReactChildFiber.js index db8f0e6233644..10edf8537a53f 100644 --- a/packages/react-reconciler/src/ReactChildFiber.js +++ b/packages/react-reconciler/src/ReactChildFiber.js @@ -220,6 +220,9 @@ function validateFragmentProps( // For unkeyed root fragments there's no Fiber. We create a fake one just for // error stack handling. fiber = createFiberFromElement(element, returnFiber.mode, 0); + if (__DEV__) { + fiber._debugInfo = currentDebugInfo; + } fiber.return = returnFiber; } runWithFiberInDEV( @@ -242,6 +245,9 @@ function validateFragmentProps( // For unkeyed root fragments there's no Fiber. We create a fake one just for // error stack handling. fiber = createFiberFromElement(element, returnFiber.mode, 0); + if (__DEV__) { + fiber._debugInfo = currentDebugInfo; + } fiber.return = returnFiber; } runWithFiberInDEV(fiber, () => { diff --git a/packages/react-reconciler/src/ReactFiber.js b/packages/react-reconciler/src/ReactFiber.js index da67714d0906f..2911b1bf5c549 100644 --- a/packages/react-reconciler/src/ReactFiber.js +++ b/packages/react-reconciler/src/ReactFiber.js @@ -485,6 +485,7 @@ export function createHostRootFiber( return createFiber(HostRoot, null, null, mode); } +// TODO: Get rid of this helper. Only createFiberFromElement should exist. export function createFiberFromTypeAndProps( type: any, // React$ElementType key: null | string, @@ -650,11 +651,18 @@ export function createFiberFromTypeAndProps( typeString = type === null ? 'null' : typeof type; } - throw new Error( + // The type is invalid but it's conceptually a child that errored and not the + // current component itself so we create a virtual child that throws in its + // begin phase. This is the same thing we do in ReactChildFiber if we throw + // but we do it here so that we can assign the debug owner and stack from the + // element itself. That way the error stack will point to the JSX callsite. + fiberTag = Throw; + pendingProps = new Error( 'Element type is invalid: expected a string (for built-in ' + 'components) or a class/function (for composite components) ' + `but got: ${typeString}.${info}`, ); + resolvedType = null; } } } diff --git a/packages/react-reconciler/src/ReactFiberErrorLogger.js b/packages/react-reconciler/src/ReactFiberErrorLogger.js index a948e5c79b694..addb0aea43dcd 100644 --- a/packages/react-reconciler/src/ReactFiberErrorLogger.js +++ b/packages/react-reconciler/src/ReactFiberErrorLogger.js @@ -20,6 +20,8 @@ import ReactSharedInternals from 'shared/ReactSharedInternals'; import {enableOwnerStacks} from 'shared/ReactFeatureFlags'; +import {printToConsole} from './ReactFiberConfig'; + // Side-channel since I'm not sure we want to make this part of the public API let componentName: null | string = null; let errorBoundaryName: null | string = null; @@ -94,13 +96,33 @@ export function defaultOnCaughtError( }.`; if (enableOwnerStacks) { - console.error( - '%o\n\n%s\n\n%s\n', - error, - componentNameMessage, - recreateMessage, - // We let our consoleWithStackDev wrapper add the component stack to the end. - ); + if ( + typeof error === 'object' && + error !== null && + typeof error.environmentName === 'string' + ) { + // This was a Server error. We print the environment name in a badge just like we do with + // replays of console logs to indicate that the source of this throw as actually the Server. + printToConsole( + 'error', + [ + '%o\n\n%s\n\n%s\n', + error, + componentNameMessage, + recreateMessage, + // We let our consoleWithStackDev wrapper add the component stack to the end. + ], + error.environmentName, + ); + } else { + console.error( + '%o\n\n%s\n\n%s\n', + error, + componentNameMessage, + recreateMessage, + // We let our consoleWithStackDev wrapper add the component stack to the end. + ); + } } else { // The current Fiber is disconnected at this point which means that console printing // cannot add a component stack since it terminates at the deletion node. This is not diff --git a/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js b/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js index b577bd1bc074e..43f58ffc4bfd5 100644 --- a/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ErrorBoundaryReconciliation-test.internal.js @@ -6,6 +6,7 @@ describe('ErrorBoundaryReconciliation', () => { let ReactTestRenderer; let span; let act; + let assertConsoleErrorDev; beforeEach(() => { jest.resetModules(); @@ -13,6 +14,8 @@ describe('ErrorBoundaryReconciliation', () => { ReactTestRenderer = require('react-test-renderer'); React = require('react'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; DidCatchErrorBoundary = class extends React.Component { state = {error: null}; componentDidCatch(error) { @@ -58,15 +61,17 @@ describe('ErrorBoundaryReconciliation', () => { ); }); expect(renderer).toMatchRenderedOutput(); - await expect(async () => { - await act(() => { - renderer.update( - - - , - ); - }); - }).toErrorDev(['invalid', 'invalid']); + await act(() => { + renderer.update( + + + , + ); + }); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev(['invalid', 'invalid']); + } + const Fallback = fallbackTagName; expect(renderer).toMatchRenderedOutput(); } diff --git a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js index e800dd74e10a3..0cee8150cb15f 100644 --- a/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js +++ b/packages/react-reconciler/src/__tests__/ReactIncrementalErrorHandling-test.internal.js @@ -19,6 +19,7 @@ let assertLog; let waitForAll; let waitFor; let waitForThrow; +let assertConsoleErrorDev; describe('ReactIncrementalErrorHandling', () => { beforeEach(() => { @@ -28,6 +29,8 @@ describe('ReactIncrementalErrorHandling', () => { ReactNoop = require('react-noop-renderer'); Scheduler = require('scheduler'); act = require('internal-test-utils').act; + assertConsoleErrorDev = + require('internal-test-utils').assertConsoleErrorDev; const InternalTestUtils = require('internal-test-utils'); assertLog = InternalTestUtils.assertLog; @@ -1237,11 +1240,15 @@ describe('ReactIncrementalErrorHandling', () => { , ); - await expect(async () => await waitForAll([])).toErrorDev([ - 'React.jsx: type is invalid -- expected a string', - // React retries once on error - 'React.jsx: type is invalid -- expected a string', - ]); + await waitForAll([]); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev([ + 'React.jsx: type is invalid -- expected a string', + // React retries once on error + 'React.jsx: type is invalid -- expected a string', + ]); + } + expect(ReactNoop).toMatchRenderedOutput( { , ); - await expect(async () => await waitForAll([])).toErrorDev([ - 'React.jsx: type is invalid -- expected a string', - // React retries once on error - 'React.jsx: type is invalid -- expected a string', - ]); + await waitForAll([]); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev([ + 'React.jsx: type is invalid -- expected a string', + // React retries once on error + 'React.jsx: type is invalid -- expected a string', + ]); + } expect(ReactNoop).toMatchRenderedOutput( { it('recovers from uncaught reconciler errors', async () => { const InvalidType = undefined; - expect(() => ReactNoop.render()).toErrorDev( - 'React.jsx: type is invalid -- expected a string', - {withoutStack: true}, - ); + ReactNoop.render(); + if (gate(flags => !flags.enableOwnerStacks)) { + assertConsoleErrorDev( + ['React.jsx: type is invalid -- expected a string'], + {withoutStack: true}, + ); + } + await waitForThrow( 'Element type is invalid: expected a string (for built-in components) or ' + 'a class/function (for composite components) but got: undefined.' + diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.art.js b/packages/react-reconciler/src/forks/ReactFiberConfig.art.js index 867ca996ec5af..1fb43b5ade8d3 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.art.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.art.js @@ -8,3 +8,4 @@ */ export * from 'react-art/src/ReactFiberConfigART'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js index 0bfe93a008cf1..24c80469c72a5 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.custom.js @@ -80,6 +80,7 @@ export const suspendInstance = $$$config.suspendInstance; export const waitForCommitToBeReady = $$$config.waitForCommitToBeReady; export const NotPendingTransition = $$$config.NotPendingTransition; export const resetFormInstance = $$$config.resetFormInstance; +export const printToConsole = $$$config.printToConsole; // ------------------- // Microtasks diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js b/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js index 4932b1a787bb9..fa7dbe3123284 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.dom.js @@ -8,3 +8,4 @@ */ export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js b/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js index f1787a68e845b..2fb8768972da4 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.fabric.js @@ -8,3 +8,4 @@ */ export * from 'react-native-renderer/src/ReactFiberConfigFabric'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js b/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js new file mode 100644 index 0000000000000..9f70eeb70e5bc --- /dev/null +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.markup.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +// Re-exported just because we always type check react-reconciler even in +// dimensions where it's not used. +export * from 'react-dom-bindings/src/client/ReactFiberConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; + +// eslint-disable-next-line react-internal/prod-error-codes +throw new Error('Fiber is not used in react-html'); diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.native.js b/packages/react-reconciler/src/forks/ReactFiberConfig.native.js index 3f8a28688b716..3e06abc660ef7 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.native.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.native.js @@ -8,3 +8,4 @@ */ export * from 'react-native-renderer/src/ReactFiberConfigNative'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; diff --git a/packages/react-reconciler/src/forks/ReactFiberConfig.test.js b/packages/react-reconciler/src/forks/ReactFiberConfig.test.js index 85020417c2c7d..238434a50df8c 100644 --- a/packages/react-reconciler/src/forks/ReactFiberConfig.test.js +++ b/packages/react-reconciler/src/forks/ReactFiberConfig.test.js @@ -8,3 +8,4 @@ */ export * from 'react-test-renderer/src/ReactFiberConfigTestHost'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; diff --git a/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js b/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js index 26ee500ce5026..ecf6a35dfa6ef 100644 --- a/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js +++ b/packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js @@ -133,6 +133,11 @@ const deepProxyHandlers = { `Instead, you can export a Client Component wrapper ` + `that itself renders a Client Context Provider.`, ); + case 'then': + throw new Error( + `Cannot await or return from a thenable. ` + + `You cannot await a client module from a server component.`, + ); } // eslint-disable-next-line react-internal/safe-string-coercion const expression = String(target.name) + '.' + String(name); diff --git a/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js b/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js index 78e8fa359426f..6d14f412063c1 100644 --- a/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js +++ b/packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js @@ -141,6 +141,11 @@ const deepProxyHandlers = { `Instead, you can export a Client Component wrapper ` + `that itself renders a Client Context Provider.`, ); + case 'then': + throw new Error( + `Cannot await or return from a thenable. ` + + `You cannot await a client module from a server component.`, + ); } // eslint-disable-next-line react-internal/safe-string-coercion const expression = String(target.name) + '.' + String(name); diff --git a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js index 3bf8e02e0f687..96c63f63c6a41 100644 --- a/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js +++ b/packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js @@ -623,6 +623,20 @@ describe('ReactFlightDOM', () => { ); }); + it('throws when await a client module prop of client exports', async () => { + const ClientModule = clientExports({ + Component: {deep: 'thing'}, + }); + async function awaitExport() { + const mod = await ClientModule; + return await Promise.resolve(mod.Component); + } + await expect(awaitExport()).rejects.toThrowError( + `Cannot await or return from a thenable. ` + + `You cannot await a client module from a server component.`, + ); + }); + it('throws when accessing a symbol prop from client exports', () => { const symbol = Symbol('test'); const ClientModule = clientExports({ diff --git a/packages/react-server/src/ReactFizzHooks.js b/packages/react-server/src/ReactFizzHooks.js index 05cc1a2e1bead..f5965c4f69096 100644 --- a/packages/react-server/src/ReactFizzHooks.js +++ b/packages/react-server/src/ReactFizzHooks.js @@ -31,7 +31,11 @@ import { readPreviousThenable, } from './ReactFizzThenable'; -import {makeId, NotPendingTransition} from './ReactFizzConfig'; +import { + makeId, + NotPendingTransition, + supportsClientAPIs, +} from './ReactFizzConfig'; import {createFastHash} from './ReactServerStreamConfig'; import { @@ -803,29 +807,56 @@ function useMemoCache(size: number): Array { function noop(): void {} -export const HooksDispatcher: Dispatcher = { - readContext, - use, - useContext, - useMemo, - useReducer, - useRef, - useState, - useInsertionEffect: noop, - useLayoutEffect: noop, - useCallback, - // useImperativeHandle is not run in the server environment - useImperativeHandle: noop, - // Effects are not run in the server environment. - useEffect: noop, - // Debugging effect - useDebugValue: noop, - useDeferredValue, - useTransition, - useId, - // Subscriptions are not setup in a server environment. - useSyncExternalStore, -}; +function clientHookNotSupported() { + throw new Error( + 'Cannot use state or effect Hooks in renderToMarkup because ' + + 'this component will never be hydrated.', + ); +} + +export const HooksDispatcher: Dispatcher = supportsClientAPIs + ? { + readContext, + use, + useContext, + useMemo, + useReducer, + useRef, + useState, + useInsertionEffect: noop, + useLayoutEffect: noop, + useCallback, + // useImperativeHandle is not run in the server environment + useImperativeHandle: noop, + // Effects are not run in the server environment. + useEffect: noop, + // Debugging effect + useDebugValue: noop, + useDeferredValue, + useTransition, + useId, + // Subscriptions are not setup in a server environment. + useSyncExternalStore, + } + : { + readContext, + use, + useContext, + useMemo, + useReducer: clientHookNotSupported, + useRef: clientHookNotSupported, + useState: clientHookNotSupported, + useInsertionEffect: clientHookNotSupported, + useLayoutEffect: clientHookNotSupported, + useCallback, + useImperativeHandle: clientHookNotSupported, + useEffect: clientHookNotSupported, + useDebugValue: noop, + useDeferredValue: clientHookNotSupported, + useTransition: clientHookNotSupported, + useId, + useSyncExternalStore: clientHookNotSupported, + }; if (enableCache) { HooksDispatcher.useCacheRefresh = useCacheRefresh; diff --git a/packages/react-server/src/ReactFizzServer.js b/packages/react-server/src/ReactFizzServer.js index eedae9a46f1b0..75b195e1e25fb 100644 --- a/packages/react-server/src/ReactFizzServer.js +++ b/packages/react-server/src/ReactFizzServer.js @@ -78,6 +78,7 @@ import { resetResumableState, completeResumableState, emitEarlyPreloads, + printToConsole, } from './ReactFizzConfig'; import { constructClassInstance, @@ -363,7 +364,17 @@ export opaque type Request = { const DEFAULT_PROGRESSIVE_CHUNK_SIZE = 12800; function defaultErrorHandler(error: mixed) { - console['error'](error); // Don't transform to our wrapper + if ( + typeof error === 'object' && + error !== null && + typeof error.environmentName === 'string' + ) { + // This was a Server error. We print the environment name in a badge just like we do with + // replays of console logs to indicate that the source of this throw as actually the Server. + printToConsole('error', [error], error.environmentName); + } else { + console['error'](error); // Don't transform to our wrapper + } return null; } diff --git a/packages/react-server/src/ReactFlightServer.js b/packages/react-server/src/ReactFlightServer.js index cb138fd30a710..7ff1e47897299 100644 --- a/packages/react-server/src/ReactFlightServer.js +++ b/packages/react-server/src/ReactFlightServer.js @@ -110,7 +110,6 @@ import { } from 'shared/ReactSymbols'; import { - describeValueForErrorMessage, describeObjectForErrorMessage, isSimpleObject, jsxPropsParents, @@ -1501,19 +1500,11 @@ function renderElement( jsxChildrenParents.set(props.children, type); } } - if (typeof type === 'function') { - if (isClientReference(type) || isOpaqueTemporaryReference(type)) { - // This is a reference to a Client Component. - return renderClientElement( - task, - type, - key, - props, - owner, - stack, - validated, - ); - } + if ( + typeof type === 'function' && + !isClientReference(type) && + !isOpaqueTemporaryReference(type) + ) { // This is a Server Component. return renderFunctionComponent( request, @@ -1525,43 +1516,27 @@ function renderElement( stack, validated, ); - } else if (typeof type === 'string') { - // This is a host element. E.g. HTML. - return renderClientElement(task, type, key, props, owner, stack, validated); - } else if (typeof type === 'symbol') { - if (type === REACT_FRAGMENT_TYPE && key === null) { - // For key-less fragments, we add a small optimization to avoid serializing - // it as a wrapper. - const prevImplicitSlot = task.implicitSlot; - if (task.keyPath === null) { - task.implicitSlot = true; - } - const json = renderModelDestructive( - request, - task, - emptyRoot, - '', - props.children, - ); - task.implicitSlot = prevImplicitSlot; - return json; - } - // This might be a built-in React component. We'll let the client decide. - // Any built-in works as long as its props are serializable. - return renderClientElement(task, type, key, props, owner, stack, validated); - } else if (type != null && typeof type === 'object') { - if (isClientReference(type)) { - // This is a reference to a Client Component. - return renderClientElement( - task, - type, - key, - props, - owner, - stack, - validated, - ); - } + } else if (type === REACT_FRAGMENT_TYPE && key === null) { + // For key-less fragments, we add a small optimization to avoid serializing + // it as a wrapper. + const prevImplicitSlot = task.implicitSlot; + if (task.keyPath === null) { + task.implicitSlot = true; + } + const json = renderModelDestructive( + request, + task, + emptyRoot, + '', + props.children, + ); + task.implicitSlot = prevImplicitSlot; + return json; + } else if ( + type != null && + typeof type === 'object' && + !isClientReference(type) + ) { switch (type.$$typeof) { case REACT_LAZY_TYPE: { let wrappedType; @@ -1615,11 +1590,21 @@ function renderElement( validated, ); } + case REACT_ELEMENT_TYPE: { + // This is invalid but we'll let the client determine that it is. + if (__DEV__) { + // Disable the key warning that would happen otherwise because this + // element gets serialized inside an array. We'll error later anyway. + type._store.validated = 1; + } + } } } - throw new Error( - `Unsupported Server Component type: ${describeValueForErrorMessage(type)}`, - ); + // For anything else, try it on the client instead. + // We don't know if the client will support it or not. This might error on the + // client or error during serialization but the stack will point back to the + // server. + return renderClientElement(task, type, key, props, owner, stack, validated); } function pingTask(request: Request, task: Task): void { @@ -2556,7 +2541,7 @@ function renderModelDestructive( return serializeDateFromDateJSON(value); } } - if (value.length >= 1024) { + if (value.length >= 1024 && byteLengthOfChunk !== null) { // For large strings, we encode them outside the JSON payload so that we // don't have to double encode and double parse the strings. This can also // be more compact in case the string has a lot of escaped characters. @@ -2774,11 +2759,18 @@ function emitErrorChunk( if (__DEV__) { let message; let stack = ''; + let env = request.environmentName(); try { if (error instanceof Error) { // eslint-disable-next-line react-internal/safe-string-coercion message = String(error.message); stack = getStack(error); + const errorEnv = (error: any).environmentName; + if (typeof errorEnv === 'string') { + // This probably came from another FlightClient as a pass through. + // Keep the environment name. + env = errorEnv; + } } else if (typeof error === 'object' && error !== null) { message = describeObjectForErrorMessage(error); } else { @@ -2788,7 +2780,7 @@ function emitErrorChunk( } catch (x) { message = 'An error occurred but serializing the error message failed.'; } - errorInfo = {digest, message, stack}; + errorInfo = {digest, message, stack, env}; } else { errorInfo = {digest}; } @@ -2900,6 +2892,12 @@ function emitTypedArrayChunk( } function emitTextChunk(request: Request, id: number, text: string): void { + if (byteLengthOfChunk === null) { + // eslint-disable-next-line react-internal/prod-error-codes + throw new Error( + 'Existence of byteLengthOfChunk should have already been checked. This is a bug in React.', + ); + } request.pendingChunks++; // Extra chunk for the header. const textChunk = stringToChunk(text); const binaryLength = byteLengthOfChunk(textChunk); @@ -3297,7 +3295,7 @@ function emitChunk( const id = task.id; // For certain types we have special types, we typically outlined them but // we can emit them directly for this row instead of through an indirection. - if (typeof value === 'string') { + if (typeof value === 'string' && byteLengthOfChunk !== null) { if (enableTaint) { const tainted = TaintRegistryValues.get(value); if (tainted !== undefined) { diff --git a/packages/react-server/src/forks/ReactFizzConfig.custom.js b/packages/react-server/src/forks/ReactFizzConfig.custom.js index 07ebb3295f32f..249292fd19519 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.custom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.custom.js @@ -37,9 +37,13 @@ export type {TransitionStatus}; export const isPrimaryRenderer = false; +export const supportsClientAPIs = true; + export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); +export const printToConsole = $$$config.printToConsole; + export const resetResumableState = $$$config.resetResumableState; export const completeResumableState = $$$config.completeResumableState; export const getChildFormatContext = $$$config.getChildFormatContext; diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js b/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js index 7c5ba9bce7e27..244202002edd7 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-edge.js @@ -10,6 +10,8 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; + // For now, we get this from the global scope, but this will likely move to a module. export const supportsRequestStorage = typeof AsyncLocalStorage === 'function'; export const requestStorage: AsyncLocalStorage = diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js index 84d49396efcdf..5695669839f81 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-legacy.js @@ -10,5 +10,7 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOMLegacy'; +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom-node.js b/packages/react-server/src/forks/ReactFizzConfig.dom-node.js index 8c9718e8234c3..5ee4566ad09ba 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom-node.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom-node.js @@ -13,6 +13,8 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigServer'; + export const supportsRequestStorage = true; export const requestStorage: AsyncLocalStorage = new AsyncLocalStorage(); diff --git a/packages/react-server/src/forks/ReactFizzConfig.dom.js b/packages/react-server/src/forks/ReactFizzConfig.dom.js index 2bf9be13273d6..17ddc166a7922 100644 --- a/packages/react-server/src/forks/ReactFizzConfig.dom.js +++ b/packages/react-server/src/forks/ReactFizzConfig.dom.js @@ -10,5 +10,7 @@ import type {Request} from 'react-server/src/ReactFizzServer'; export * from 'react-dom-bindings/src/server/ReactFizzConfigDOM'; +export * from 'react-client/src/ReactClientConsoleConfigBrowser'; + export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFizzConfig.markup.js b/packages/react-server/src/forks/ReactFizzConfig.markup.js new file mode 100644 index 0000000000000..15e35a2ef0b97 --- /dev/null +++ b/packages/react-server/src/forks/ReactFizzConfig.markup.js @@ -0,0 +1,16 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ +import type {Request} from 'react-server/src/ReactFizzServer'; + +export * from 'react-html/src/ReactFizzConfigHTML.js'; + +export * from 'react-client/src/ReactClientConsoleConfigPlain'; + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js index 5afdf8c29c888..15874c64e0858 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.custom.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.custom.js @@ -19,8 +19,6 @@ export type HintCode = any; // eslint-disable-next-line no-unused-vars export type HintModel = any; -export const isPrimaryRenderer = false; - export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js index bfe794c9a2c55..15874c64e0858 100644 --- a/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js +++ b/packages/react-server/src/forks/ReactFlightServerConfig.dom-legacy.js @@ -11,7 +11,13 @@ import type {Request} from 'react-server/src/ReactFlightServer'; import type {ReactComponentInfo} from 'shared/ReactTypes'; export * from '../ReactFlightServerConfigBundlerCustom'; -export * from 'react-dom-bindings/src/server/ReactFlightServerConfigDOM'; + +export * from '../ReactFlightServerConfigDebugNoop'; + +export type Hints = any; +export type HintCode = any; +// eslint-disable-next-line no-unused-vars +export type HintModel = any; export const supportsRequestStorage = false; export const requestStorage: AsyncLocalStorage = (null: any); @@ -20,4 +26,6 @@ export const supportsComponentStorage = false; export const componentStorage: AsyncLocalStorage = (null: any); -export * from '../ReactFlightServerConfigDebugNoop'; +export function createHints(): any { + return null; +} diff --git a/packages/react-server/src/forks/ReactFlightServerConfig.markup.js b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js new file mode 100644 index 0000000000000..99591bb954ea9 --- /dev/null +++ b/packages/react-server/src/forks/ReactFlightServerConfig.markup.js @@ -0,0 +1,90 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type {Request} from 'react-server/src/ReactFlightServer'; +import type {ReactComponentInfo} from 'shared/ReactTypes'; +import type {ReactClientValue} from 'react-server/src/ReactFlightServer'; + +export type HintCode = string; +export type HintModel = null; // eslint-disable-line no-unused-vars +export type Hints = null; + +export function createHints(): Hints { + return null; +} + +export const supportsRequestStorage = false; +export const requestStorage: AsyncLocalStorage = (null: any); + +export const supportsComponentStorage = false; +export const componentStorage: AsyncLocalStorage = + (null: any); + +export * from '../ReactFlightServerConfigDebugNoop'; + +export type ClientManifest = null; +export opaque type ClientReference = null; // eslint-disable-line no-unused-vars +export opaque type ServerReference = null; // eslint-disable-line no-unused-vars +export opaque type ClientReferenceMetadata: any = null; +export opaque type ServerReferenceId: string = string; +export opaque type ClientReferenceKey: any = string; + +const CLIENT_REFERENCE_TAG = Symbol.for('react.client.reference'); +const SERVER_REFERENCE_TAG = Symbol.for('react.server.reference'); + +export function isClientReference(reference: Object): boolean { + return reference.$$typeof === CLIENT_REFERENCE_TAG; +} + +export function isServerReference(reference: Object): boolean { + return reference.$$typeof === SERVER_REFERENCE_TAG; +} + +export function getClientReferenceKey( + reference: ClientReference, +): ClientReferenceKey { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function resolveClientReferenceMetadata( + config: ClientManifest, + clientReference: ClientReference, +): ClientReferenceMetadata { + throw new Error( + 'Attempted to render a Client Component from renderToMarkup. ' + + 'This is not supported since it will never hydrate. ' + + 'Only render Server Components with renderToMarkup.', + ); +} + +export function getServerReferenceId( + config: ClientManifest, + serverReference: ServerReference, +): ServerReferenceId { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} + +export function getServerReferenceBoundArguments( + config: ClientManifest, + serverReference: ServerReference, +): null | Array { + throw new Error( + 'Attempted to render a Server Action from renderToMarkup. ' + + 'This is not supported since it varies by version of the app. ' + + 'Use a fixed URL for any forms instead.', + ); +} diff --git a/packages/react-server/src/forks/ReactServerStreamConfig.markup.js b/packages/react-server/src/forks/ReactServerStreamConfig.markup.js new file mode 100644 index 0000000000000..80d5dcab2a5f2 --- /dev/null +++ b/packages/react-server/src/forks/ReactServerStreamConfig.markup.js @@ -0,0 +1,10 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +export * from 'react-dom-bindings/src/server/ReactDOMLegacyServerStreamConfig'; diff --git a/packages/react/src/__tests__/ReactElementValidator-test.internal.js b/packages/react/src/__tests__/ReactElementValidator-test.internal.js index 00c92eabe703f..4383a6472d350 100644 --- a/packages/react/src/__tests__/ReactElementValidator-test.internal.js +++ b/packages/react/src/__tests__/ReactElementValidator-test.internal.js @@ -515,11 +515,15 @@ describe('ReactElementValidator', () => { expect(() => { void ({[
]}); }).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: undefined. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', + gate(flags => flags.enableOwnerStacks) + ? [] + : [ + 'React.jsx: type is invalid -- expected a string ' + + '(for built-in components) or a class/function (for composite ' + + 'components) but got: undefined. You likely forgot to export your ' + + "component from the file it's defined in, or you might have mixed up " + + 'default and named imports.', + ], {withoutStack: true}, ); }); diff --git a/packages/react/src/__tests__/ReactJSXElementValidator-test.js b/packages/react/src/__tests__/ReactJSXElementValidator-test.js index 4fc666aa4e7fd..f827a52bc59ca 100644 --- a/packages/react/src/__tests__/ReactJSXElementValidator-test.js +++ b/packages/react/src/__tests__/ReactJSXElementValidator-test.js @@ -215,35 +215,6 @@ describe('ReactJSXElementValidator', () => { ); }); - it('gives a helpful error when passing null, undefined, or boolean', () => { - const Undefined = undefined; - const Null = null; - const True = true; - const Div = 'div'; - expect(() => void ()).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: undefined. You likely forgot to export your ' + - "component from the file it's defined in, or you might have mixed up " + - 'default and named imports.', - {withoutStack: true}, - ); - expect(() => void ()).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: null.', - {withoutStack: true}, - ); - expect(() => void ()).toErrorDev( - 'React.jsx: type is invalid -- expected a string ' + - '(for built-in components) or a class/function (for composite ' + - 'components) but got: boolean.', - {withoutStack: true}, - ); - // No error expected - void (
); - }); - it('warns for fragments with illegal attributes', async () => { class Foo extends React.Component { render() { diff --git a/packages/react/src/jsx/ReactJSXElement.js b/packages/react/src/jsx/ReactJSXElement.js index 0f8b9f397df5b..1652b1e2edecc 100644 --- a/packages/react/src/jsx/ReactJSXElement.js +++ b/packages/react/src/jsx/ReactJSXElement.js @@ -559,9 +559,14 @@ function jsxDEVImpl( debugTask, ) { if (__DEV__) { - if (!isValidElementType(type)) { + if (!enableOwnerStacks && !isValidElementType(type)) { // This is an invalid element type. // + // We warn here so that we can get better stack traces but with enableOwnerStacks + // enabled we don't need this because we get good stacks if we error in the + // renderer anyway. The renderer is the only one that knows what types are valid + // for this particular renderer so we let it error there instead. + // // We warn in this case but don't throw. We expect the element creation to // succeed and there will likely be errors in render. let info = ''; @@ -604,6 +609,9 @@ function jsxDEVImpl( // errors. We don't want exception behavior to differ between dev and // prod. (Rendering will throw with a helpful message and as soon as the // type is fixed, the key warnings will appear.) + // When enableOwnerStacks is on, we no longer need the type here so this + // comment is no longer true. Which is why we can run this even for invalid + // types. const children = config.children; if (children !== undefined) { if (isStaticChildren) { @@ -1103,6 +1111,17 @@ export function cloneElement(element, config, children) { */ function validateChildKeys(node, parentType) { if (__DEV__) { + if (enableOwnerStacks) { + // When owner stacks is enabled no warnings happens. All we do is + // mark elements as being in a valid static child position so they + // don't need keys. + if (isValidElement(node)) { + if (node._store) { + node._store.validated = 1; + } + } + return; + } if (typeof node !== 'object' || !node) { return; } diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js index ecdb3755691d2..a0513f81e7bf7 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb-dynamic.js @@ -21,8 +21,6 @@ export const alwaysThrottleRetries = __VARIANT__; export const consoleManagedByDevToolsDuringStrictMode = __VARIANT__; export const disableDefaultPropsExceptForClasses = __VARIANT__; export const enableAddPropertiesFastPath = __VARIANT__; -export const enableDeferRootSchedulingToMicrotask = __VARIANT__; export const enableFastJSX = __VARIANT__; -export const enableInfiniteRenderLoopDetection = __VARIANT__; export const enableShallowPropDiffing = __VARIANT__; export const passChildrenWhenCloningPersistedNodes = __VARIANT__; diff --git a/packages/shared/forks/ReactFeatureFlags.native-fb.js b/packages/shared/forks/ReactFeatureFlags.native-fb.js index de6086c33f257..353937a7eaed8 100644 --- a/packages/shared/forks/ReactFeatureFlags.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.native-fb.js @@ -23,9 +23,7 @@ export const { consoleManagedByDevToolsDuringStrictMode, disableDefaultPropsExceptForClasses, enableAddPropertiesFastPath, - enableDeferRootSchedulingToMicrotask, enableFastJSX, - enableInfiniteRenderLoopDetection, enableShallowPropDiffing, passChildrenWhenCloningPersistedNodes, } = dynamicFlags; @@ -51,11 +49,13 @@ export const enableComponentStackLocations = true; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; export const enableDebugTracing = false; +export const enableDeferRootSchedulingToMicrotask = true; export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableFilterEmptyStringAttributesDOM = true; export const enableFizzExternalRuntime = true; export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = true; +export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; diff --git a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js index 63fa1a451459a..5d256ca9e06c1 100644 --- a/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js +++ b/packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js @@ -34,14 +34,14 @@ export const enableComponentStackLocations = true; export const enableCPUSuspense = true; export const enableCreateEventHandleAPI = false; export const enableDebugTracing = false; -export const enableDeferRootSchedulingToMicrotask = false; +export const enableDeferRootSchedulingToMicrotask = true; export const enableDO_NOT_USE_disableStrictPassiveEffect = false; export const enableFastJSX = true; export const enableFilterEmptyStringAttributesDOM = true; export const enableFizzExternalRuntime = true; export const enableFlightReadableStream = true; export const enableGetInspectorDataForInstanceInProduction = false; -export const enableInfiniteRenderLoopDetection = false; +export const enableInfiniteRenderLoopDetection = true; export const enableLazyContextPropagation = false; export const enableLegacyCache = false; export const enableLegacyFBSupport = false; diff --git a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js index 08f8041f2560d..07c50c7a3ecec 100644 --- a/packages/shared/forks/ReactFeatureFlags.www-dynamic.js +++ b/packages/shared/forks/ReactFeatureFlags.www-dynamic.js @@ -32,6 +32,7 @@ export const syncLaneExpirationMs = 250; export const transitionLaneExpirationMs = 5000; export const enableAddPropertiesFastPath = __VARIANT__; export const disableLegacyMode = __VARIANT__; +export const renameElementSymbol = __VARIANT__; // Enable this flag to help with concurrent mode debugging. // It logs information to the console about React scheduling, rendering, and commit phases. diff --git a/packages/shared/forks/ReactFeatureFlags.www.js b/packages/shared/forks/ReactFeatureFlags.www.js index 04ab3405e3cd3..66a3723071986 100644 --- a/packages/shared/forks/ReactFeatureFlags.www.js +++ b/packages/shared/forks/ReactFeatureFlags.www.js @@ -35,6 +35,7 @@ export const { enableNoCloningMemoCache, enableAddPropertiesFastPath, enableFastJSX, + renameElementSymbol, } = dynamicFeatureFlags; // On WWW, __EXPERIMENTAL__ is used for a new modern build. @@ -65,8 +66,6 @@ export const enableSchedulingProfiler: boolean = export const disableLegacyContext = __EXPERIMENTAL__; export const enableGetInspectorDataForInstanceInProduction = false; -export const renameElementSymbol = false; - export const enableCache = true; export const enableLegacyCache = true; diff --git a/scripts/error-codes/codes.json b/scripts/error-codes/codes.json index ef4ae75a6d634..46600256b0279 100644 --- a/scripts/error-codes/codes.json +++ b/scripts/error-codes/codes.json @@ -515,5 +515,13 @@ "527": "Incompatible React versions: The \"react\" and \"react-dom\" packages must have the exact same version. Instead got:\n - react: %s\n - react-dom: %s\nLearn more: https://react.dev/warnings/version-mismatch", "528": "Expected not to update to be updated to a stylesheet with precedence. Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", "529": "Expected stylesheet with precedence to not be updated to a different kind of . Check the `rel`, `href`, and `precedence` props of this component. Alternatively, check whether two different components render in the same slot or share the same key.%s", - "530": "The render was aborted by the server with a promise." + "530": "The render was aborted by the server with a promise.", + "531": "react-html is not supported outside a React Server Components environment.", + "532": "Attempted to render a Client Component from renderToMarkup. This is not supported since it will never hydrate. Only render Server Components with renderToMarkup.", + "533": "Attempted to render a Server Action from renderToMarkup. This is not supported since it varies by version of the app. Use a fixed URL for any forms instead.", + "534": "renderToMarkup should not have emitted Client References. This is a bug in React.", + "535": "renderToMarkup should not have emitted Server References. This is a bug in React.", + "536": "Cannot pass ref in renderToMarkup because they will never be hydrated.", + "537": "Cannot pass event handlers (%s) in renderToMarkup because the HTML will never be hydrated so they can never get called.", + "538": "Cannot use state or effect Hooks in renderToMarkup because this component will never be hydrated." } diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 66e59124ca2d5..d17db8976390e 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -363,6 +363,31 @@ const bundles = [ externals: [], }, + /******* React HTML RSC *******/ + { + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], + moduleType: RENDERER, + entry: 'react-html/src/ReactHTMLServer.js', + name: 'react-html.react-server', + condition: 'react-server', + global: 'ReactHTML', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react'], + }, + + /******* React HTML Client *******/ + { + bundleTypes: __EXPERIMENTAL__ ? [NODE_DEV, NODE_PROD] : [], + moduleType: RENDERER, + entry: 'react-html/src/ReactHTMLClient.js', + name: 'react-html', + global: 'ReactHTML', + minifyWithProdErrorCodes: false, + wrapWithModuleBoundaries: false, + externals: ['react'], + }, + /******* React Server DOM Webpack Server *******/ { bundleTypes: [NODE_DEV, NODE_PROD], diff --git a/scripts/rollup/forks.js b/scripts/rollup/forks.js index a83b8b1b1a969..cffc66d2324f3 100644 --- a/scripts/rollup/forks.js +++ b/scripts/rollup/forks.js @@ -65,6 +65,12 @@ const forks = Object.freeze({ if (entry === 'react/src/ReactServer.js') { return './packages/react/src/ReactSharedInternalsServer.js'; } + if (entry === 'react-html/src/ReactHTMLServer.js') { + // Inside the ReactHTMLServer render we don't refer to any shared internals + // but instead use our own internal copy of the state because you cannot use + // any of this state from a component anyway. E.g. you can't use a client hook. + return './packages/react/src/ReactSharedInternalsClient.js'; + } if (bundle.condition === 'react-server') { return './packages/react-server/src/ReactSharedInternalsServer.js'; } @@ -93,7 +99,9 @@ const forks = Object.freeze({ entry === 'react-dom' || entry === 'react-dom/src/ReactDOMFB.js' || entry === 'react-dom/src/ReactDOMTestingFB.js' || - entry === 'react-dom/src/ReactDOMServer.js' + entry === 'react-dom/src/ReactDOMServer.js' || + entry === 'react-html/src/ReactHTMLClient.js' || + entry === 'react-html/src/ReactHTMLServer.js' ) { if ( bundleType === FB_WWW_DEV || diff --git a/scripts/shared/inlinedHostConfigs.js b/scripts/shared/inlinedHostConfigs.js index a63da44c36f29..d59a9bde49170 100644 --- a/scripts/shared/inlinedHostConfigs.js +++ b/scripts/shared/inlinedHostConfigs.js @@ -36,6 +36,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.node.unbundled', 'react-server-dom-webpack/server', @@ -71,6 +74,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.node', 'react-server-dom-webpack/server', @@ -108,6 +114,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.node.unbundled', 'react-server-dom-turbopack/server', @@ -145,6 +154,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.node', 'react-server-dom-turbopack/server', @@ -182,6 +194,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.bun', 'react-dom/src/server/ReactDOMFizzServerBun.js', 'react-dom-bindings', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'shared/ReactDOMSharedInternals', ], isFlowTyped: true, @@ -212,6 +227,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.browser', 'react-dom/src/server/ReactDOMFizzServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMFizzStaticBrowser.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client', 'react-server-dom-webpack/client.browser', @@ -240,6 +258,9 @@ module.exports = [ 'react-dom/server', 'react-dom/server.node', 'react-dom-bindings', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-esm', 'react-server-dom-esm/client', 'react-server-dom-esm/client.browser', @@ -274,6 +295,9 @@ module.exports = [ 'react-server-dom-turbopack/src/ReactFlightDOMServerBrowser.js', // react-server-dom-turbopack/server.browser 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopack.js', 'react-server-dom-turbopack/src/ReactFlightClientConfigBundlerTurbopackBrowser.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-devtools', 'react-devtools-core', 'react-devtools-shell', @@ -303,6 +327,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.edge', 'react-dom/src/server/ReactDOMFizzServerEdge.js', // react-dom/server.edge 'react-dom/src/server/ReactDOMFizzStaticEdge.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-webpack', 'react-server-dom-webpack/client.edge', 'react-server-dom-webpack/server.edge', @@ -338,6 +365,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.edge', 'react-dom/src/server/ReactDOMFizzServerEdge.js', // react-dom/server.edge 'react-dom/src/server/ReactDOMFizzStaticEdge.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-turbopack', 'react-server-dom-turbopack/client.edge', 'react-server-dom-turbopack/server.edge', @@ -374,6 +404,9 @@ module.exports = [ 'react-dom/src/server/react-dom-server.node', 'react-dom/src/server/ReactDOMFizzServerNode.js', // react-dom/server.node 'react-dom/src/server/ReactDOMFizzStaticNode.js', + 'react-dom-bindings/src/server/ReactDOMFlightServerHostDispatcher.js', + 'react-dom-bindings/src/server/ReactFlightServerConfigDOM.js', + 'react-dom-bindings/src/shared/ReactFlightClientConfigDOM.js', 'react-server-dom-esm', 'react-server-dom-esm/client.node', 'react-server-dom-esm/server', @@ -400,7 +433,6 @@ module.exports = [ 'react-dom', 'react-dom/src/ReactDOMReactServer.js', 'react-dom-bindings', - 'react-server-dom-webpack', 'react-dom/src/server/ReactDOMLegacyServerImpl.js', // not an entrypoint, but only usable in *Browser and *Node files 'react-dom/src/server/ReactDOMLegacyServerBrowser.js', // react-dom/server.browser 'react-dom/src/server/ReactDOMLegacyServerNode.js', // react-dom/server.node @@ -409,6 +441,22 @@ module.exports = [ isFlowTyped: true, isServerSupported: true, }, + { + shortName: 'markup', + entryPoints: [ + 'react-html/src/ReactHTMLClient.js', // react-html + 'react-html/src/ReactHTMLServer.js', // react-html/react-html.react-server + ], + paths: [ + 'react-dom', + 'react-dom/src/ReactDOMReactServer.js', + 'react-dom-bindings', + 'react-html', + 'shared/ReactDOMSharedInternals', + ], + isFlowTyped: true, + isServerSupported: true, + }, { shortName: 'dom-fb', entryPoints: [ diff --git a/scripts/shared/listChangedFiles.js b/scripts/shared/listChangedFiles.js index 2fd80d4b488c4..b6449e5cc6e4b 100644 --- a/scripts/shared/listChangedFiles.js +++ b/scripts/shared/listChangedFiles.js @@ -19,14 +19,46 @@ const exec = (command, args) => { return execFileSync(command, args, options); }; +const isGit = () => { + try { + const wt = execGitCmd(['rev-parse', '--is-inside-work-tree']); + return wt.length > 0 && wt[0] === 'true'; + } catch (_e) { + return false; + } +}; + +const isSl = () => { + try { + execSlCmd(['whereami']); + return true; + } catch (_e) { + return false; + } +}; + const execGitCmd = args => exec('git', args).trim().toString().split('\n'); +const execSlCmd = args => exec('sl', args).trim().toString().split('\n'); const listChangedFiles = () => { - const mergeBase = execGitCmd(['merge-base', 'HEAD', 'main']); - return new Set([ - ...execGitCmd(['diff', '--name-only', '--diff-filter=ACMRTUB', mergeBase]), - ...execGitCmd(['ls-files', '--others', '--exclude-standard']), - ]); + if (isGit()) { + const mergeBase = execGitCmd(['merge-base', 'HEAD', 'main']); + return new Set([ + ...execGitCmd([ + 'diff', + '--name-only', + '--diff-filter=ACMRTUB', + mergeBase, + ]), + ...execGitCmd(['ls-files', '--others', '--exclude-standard']), + ]); + } else if (isSl()) { + const mergeBase = execSlCmd(['log', '-r', 'last(public() & ::.)'])[0] + .trim() + .split(/\s+/)[1]; + return new Set(execSlCmd(['status', '--no-status', '--rev', mergeBase])); + } + throw new Error('Not a git or sl repo'); }; module.exports = listChangedFiles; diff --git a/yarn.lock b/yarn.lock index 1f3a2dd01d434..b4a5640f3ca87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7202,7 +7202,6 @@ eslint-plugin-no-unsanitized@3.1.2: "eslint-plugin-react-internal@link:./scripts/eslint-rules": version "0.0.0" - uid "" eslint-plugin-react@^6.7.1: version "6.10.3" @@ -7274,7 +7273,7 @@ eslint-utils@^2.0.0, eslint-utils@^2.1.0: dependencies: eslint-visitor-keys "^1.1.0" -"eslint-v7@npm:eslint@^7.7.0", eslint@^7.7.0: +"eslint-v7@npm:eslint@^7.7.0": version "7.32.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== @@ -7437,6 +7436,52 @@ eslint@5.16.0: table "^5.2.3" text-table "^0.2.0" +eslint@^7.7.0: + version "7.32.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-7.32.0.tgz#c6d328a14be3fb08c8d1d21e12c02fdb7a2a812d" + integrity sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA== + dependencies: + "@babel/code-frame" "7.12.11" + "@eslint/eslintrc" "^0.4.3" + "@humanwhocodes/config-array" "^0.5.0" + ajv "^6.10.0" + chalk "^4.0.0" + cross-spawn "^7.0.2" + debug "^4.0.1" + doctrine "^3.0.0" + enquirer "^2.3.5" + escape-string-regexp "^4.0.0" + eslint-scope "^5.1.1" + eslint-utils "^2.1.0" + eslint-visitor-keys "^2.0.0" + espree "^7.3.1" + esquery "^1.4.0" + esutils "^2.0.2" + fast-deep-equal "^3.1.3" + file-entry-cache "^6.0.1" + functional-red-black-tree "^1.0.1" + glob-parent "^5.1.2" + globals "^13.6.0" + ignore "^4.0.6" + import-fresh "^3.0.0" + imurmurhash "^0.1.4" + is-glob "^4.0.0" + js-yaml "^3.13.1" + json-stable-stringify-without-jsonify "^1.0.1" + levn "^0.4.1" + lodash.merge "^4.6.2" + minimatch "^3.0.4" + natural-compare "^1.4.0" + optionator "^0.9.1" + progress "^2.0.0" + regexpp "^3.1.0" + semver "^7.2.1" + strip-ansi "^6.0.0" + strip-json-comments "^3.1.0" + table "^6.0.9" + text-table "^0.2.0" + v8-compile-cache "^2.0.3" + espree@6.2.1: version "6.2.1" resolved "https://registry.yarnpkg.com/espree/-/espree-6.2.1.tgz#77fc72e1fd744a2052c20f38a5b575832e82734a" @@ -12116,9 +12161,9 @@ nullthrows@^1.0.0, nullthrows@^1.1.1: integrity sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw== nwsapi@^2.2.4: - version "2.2.9" - resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.9.tgz#7f3303218372db2e9f27c27766bcfc59ae7e61c6" - integrity sha512-2f3F0SEEer8bBu0dsNCFF50N0cTThV1nWFYcEYFZttdW0lDAoybv9cQoK7X7/68Z89S7FoRrVjP1LPX4XRf9vg== + version "2.2.10" + resolved "https://registry.yarnpkg.com/nwsapi/-/nwsapi-2.2.10.tgz#0b77a68e21a0b483db70b11fad055906e867cda8" + integrity sha512-QK0sRs7MKv0tKe1+5uZIQk/C8XGza4DAnztJG8iD+TpJIORARrCxczA738awHrZoHeTjSSoHqao2teO0dC/gFQ== oauth-sign@~0.9.0: version "0.9.0" @@ -13555,9 +13600,9 @@ rc@^1.0.1, rc@^1.1.6, rc@^1.2.8: scheduler "^0.20.2" react-is@^16.8.1, react-is@^17.0.1, react-is@^18.0.0, react-is@^18.2.0, "react-is@npm:react-is": - version "17.0.2" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" - integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + version "18.3.1" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== react-lifecycles-compat@^3.0.4: version "3.0.4" @@ -15045,7 +15090,7 @@ string-natural-compare@^3.0.1: resolved "https://registry.yarnpkg.com/string-natural-compare/-/string-natural-compare-3.0.1.tgz#7a42d58474454963759e8e8b7ae63d71c1e7fdf4" integrity sha512-n3sPwynL1nwKi3WJ6AIsClwBMa0zTi54fn2oLU6ndfTSIO05xaznjSf15PcBZU6FNWbmN5Q6cxT4V5hGvB4taw== -"string-width-cjs@npm:string-width@^4.2.0", string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: +"string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== @@ -15080,6 +15125,15 @@ string-width@^4.0.0: is-fullwidth-code-point "^3.0.0" strip-ansi "^6.0.0" +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + string-width@^5.0.1, string-width@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/string-width/-/string-width-5.1.2.tgz#14f8daec6d81e7221d2a357e668cab73bdbca794" @@ -15140,7 +15194,7 @@ string_decoder@~1.1.1: dependencies: safe-buffer "~5.1.0" -"strip-ansi-cjs@npm:strip-ansi@^6.0.1", strip-ansi@^6.0.0, strip-ansi@^6.0.1: +"strip-ansi-cjs@npm:strip-ansi@^6.0.1": version "6.0.1" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== @@ -15168,6 +15222,13 @@ strip-ansi@^5.1.0: dependencies: ansi-regex "^4.1.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-6.0.1.tgz#9e26c63d30f53443e9489495b2105d37b67a85d9" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-ansi@^7.0.1: version "7.1.0" resolved "https://registry.yarnpkg.com/strip-ansi/-/strip-ansi-7.1.0.tgz#d5b6568ca689d8561370b0707685d22434faff45" @@ -16620,7 +16681,7 @@ workerize-loader@^2.0.2: dependencies: loader-utils "^2.0.0" -"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0", wrap-ansi@^7.0.0: +"wrap-ansi-cjs@npm:wrap-ansi@^7.0.0": version "7.0.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== @@ -16638,6 +16699,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrap-ansi@^8.1.0: version "8.1.0" resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-8.1.0.tgz#56dc22368ee570face1b49819975d9b9a5ead214" @@ -16691,20 +16761,15 @@ ws@7.2.3: resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.3.tgz#a5411e1fb04d5ed0efee76d26d5c46d830c39b46" integrity sha512-HTDl9G9hbkNDk98naoR/cHDws7+EyYMOdL1BmjsZXRUjf7d+MficC4B7HLUPlSiho0vg+CWKrGIt/VJBd1xunQ== -ws@^7: - version "7.2.1" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.2.1.tgz#03ed52423cd744084b2cf42ed197c8b65a936b8e" - integrity sha512-sucePNSafamSKoOqoNfBd8V0StlkzJKL2ZAhGQinCfNQ+oacw+Pk7lcdAElecBF2VkLNZRiIb5Oi1Q5lVUVt2A== - -ws@^7.4.6: - version "7.5.5" - resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.5.tgz#8b4bc4af518cfabd0473ae4f99144287b33eb881" - integrity sha512-BAkMFcAzl8as1G/hArkxOxq3G7pjUqQ3gzYbLL0/5zNkph70e+lCoxBGnm6AW1+/aiNeV4fnKqZ8m4GZewmH2w== +ws@^7, ws@^7.4.6: + version "7.5.10" + resolved "https://registry.yarnpkg.com/ws/-/ws-7.5.10.tgz#58b5c20dc281633f6c19113f39b349bd8bd558d9" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== ws@^8.13.0: - version "8.17.0" - resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.0.tgz#d145d18eca2ed25aaf791a183903f7be5e295fea" - integrity sha512-uJq6108EgZMAl20KagGkzCKfMEjxmKvZHG7Tlq0Z6nOky7YF7aq4mOx6xK8TJ/i1LeK4Qus7INktacctDgY8Ow== + version "8.17.1" + resolved "https://registry.yarnpkg.com/ws/-/ws-8.17.1.tgz#9293da530bb548febc95371d90f9c878727d919b" + integrity sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ== xcase@^2.0.1: version "2.0.1"