Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55bf051afd | ||
|
|
2a18d35301 | ||
|
|
7f5ea1bf67 | ||
|
|
0e32da71c7 | ||
|
|
2381ecc290 | ||
|
|
5418d8bdc1 | ||
|
|
ed1351c4fb | ||
|
|
93f8593289 | ||
|
|
dc1becd893 | ||
|
|
d8aa94b0f4 | ||
|
|
03ba0c76e1 | ||
|
|
4e00747378 | ||
|
|
7bd8716acd | ||
|
|
7385d1f61a | ||
|
|
85f415e33b | ||
|
|
903366b8b1 | ||
|
|
0fbb9b3683 | ||
|
|
e096403c59 | ||
|
|
1873ad7960 | ||
|
|
77b2f909f6 | ||
|
|
6773248311 | ||
|
|
5747cadf44 | ||
|
|
751edd6e2c | ||
|
|
6cfc9c1ff3 |
@@ -9,7 +9,7 @@ Read the [React 19.2 release post](https://react.dev/blog/2025/10/01/react-19-2)
|
||||
- [`<Activity>`](https://react.dev/reference/react/Activity): A new API to hide and restore the UI and internal state of its children.
|
||||
- [`useEffectEvent`](https://react.dev/reference/react/useEffectEvent) is a React Hook that lets you extract non-reactive logic into an [Effect Event](https://react.dev/learn/separating-events-from-effects#declaring-an-effect-event).
|
||||
- [`cacheSignal`](https://react.dev/reference/react/cacheSignal) (for RSCs) lets your know when the `cache()` lifetime is over.
|
||||
- [React Performance tracks](https://react.dev/reference/developer-tooling/react-performance-tracks) appear on the Performance panel’s timeline in your browser developer tools
|
||||
- [React Performance tracks](https://react.dev/reference/dev-tools/react-performance-tracks) appear on the Performance panel’s timeline in your browser developer tools
|
||||
|
||||
### New React DOM Features
|
||||
|
||||
|
||||
@@ -52,8 +52,8 @@
|
||||
"react-dom": "0.0.0-experimental-4beb1fd8-20241118",
|
||||
"ts-jest": "^29.1.1",
|
||||
"ts-node": "^10.9.2",
|
||||
"zod": "^3.22.4 || ^4.0.0",
|
||||
"zod-validation-error": "^3.0.3 || ^4.0.0"
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"resolutions": {
|
||||
"./**/@babel/parser": "7.7.4",
|
||||
|
||||
@@ -988,7 +988,7 @@ function getRuleForCategoryImpl(category: ErrorCategory): LintRule {
|
||||
severity: ErrorSeverity.Error,
|
||||
name: 'void-use-memo',
|
||||
description:
|
||||
'Validates that useMemos always return a value. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
|
||||
'Validates that useMemos always return a value and that the result of the useMemo is used by the component/hook. See [`useMemo()` docs](https://react.dev/reference/react/useMemo) for more information.',
|
||||
preset: LintRulePreset.RecommendedLatest,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import * as t from '@babel/types';
|
||||
import {z} from 'zod';
|
||||
import {z} from 'zod/v4';
|
||||
import {
|
||||
CompilerDiagnostic,
|
||||
CompilerError,
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
tryParseExternalFunction,
|
||||
} from '../HIR/Environment';
|
||||
import {hasOwnProperty} from '../Utils/utils';
|
||||
import {fromZodError} from 'zod-validation-error';
|
||||
import {fromZodError} from 'zod-validation-error/v4';
|
||||
import {CompilerPipelineValue} from './Pipeline';
|
||||
|
||||
const PanicThresholdOptionsSchema = z.enum([
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
*/
|
||||
|
||||
import * as t from '@babel/types';
|
||||
import {ZodError, z} from 'zod';
|
||||
import {fromZodError} from 'zod-validation-error';
|
||||
import {ZodError, z} from 'zod/v4';
|
||||
import {fromZodError} from 'zod-validation-error/v4';
|
||||
import {CompilerError} from '../CompilerError';
|
||||
import {Logger, ProgramContext} from '../Entrypoint';
|
||||
import {Err, Ok, Result} from '../Utils/Result';
|
||||
@@ -659,7 +659,7 @@ export const EnvironmentConfigSchema = z.object({
|
||||
* Invalid:
|
||||
* useMemo(() => { ... }, [...]);
|
||||
*/
|
||||
validateNoVoidUseMemo: z.boolean().default(false),
|
||||
validateNoVoidUseMemo: z.boolean().default(true),
|
||||
|
||||
/**
|
||||
* Validates that Components/Hooks are always defined at module level. This prevents scope
|
||||
|
||||
@@ -16,7 +16,7 @@ import {assertExhaustive} from '../Utils/utils';
|
||||
import {Environment, ReactFunctionType} from './Environment';
|
||||
import type {HookKind} from './ObjectShape';
|
||||
import {Type, makeType} from './Types';
|
||||
import {z} from 'zod';
|
||||
import {z} from 'zod/v4';
|
||||
import type {AliasingEffect} from '../Inference/AliasingEffects';
|
||||
import {isReservedWord} from '../Utils/Keyword';
|
||||
import {Err, Ok, Result} from '../Utils/Result';
|
||||
|
||||
@@ -988,7 +988,7 @@ export function createTemporaryPlace(
|
||||
identifier: makeTemporaryIdentifier(env.nextIdentifierId, loc),
|
||||
reactive: false,
|
||||
effect: Effect.Unknown,
|
||||
loc: GeneratedSource,
|
||||
loc,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import {isValidIdentifier} from '@babel/types';
|
||||
import {z} from 'zod';
|
||||
import {z} from 'zod/v4';
|
||||
import {Effect, ValueKind} from '..';
|
||||
import {
|
||||
EffectSchema,
|
||||
|
||||
@@ -11,7 +11,6 @@ import {
|
||||
CallExpression,
|
||||
Effect,
|
||||
Environment,
|
||||
FinishMemoize,
|
||||
FunctionExpression,
|
||||
HIRFunction,
|
||||
IdentifierId,
|
||||
@@ -25,7 +24,6 @@ import {
|
||||
Place,
|
||||
PropertyLoad,
|
||||
SpreadPattern,
|
||||
StartMemoize,
|
||||
TInstruction,
|
||||
getHookKindForType,
|
||||
makeInstructionId,
|
||||
@@ -184,36 +182,52 @@ function makeManualMemoizationMarkers(
|
||||
depsList: Array<ManualMemoDependency> | null,
|
||||
memoDecl: Place,
|
||||
manualMemoId: number,
|
||||
): [TInstruction<StartMemoize>, TInstruction<FinishMemoize>] {
|
||||
): [Array<Instruction>, Array<Instruction>] {
|
||||
const temp = createTemporaryPlace(env, memoDecl.loc);
|
||||
return [
|
||||
{
|
||||
id: makeInstructionId(0),
|
||||
lvalue: createTemporaryPlace(env, fnExpr.loc),
|
||||
value: {
|
||||
kind: 'StartMemoize',
|
||||
manualMemoId,
|
||||
/*
|
||||
* Use deps list from source instead of inferred deps
|
||||
* as dependencies
|
||||
*/
|
||||
deps: depsList,
|
||||
[
|
||||
{
|
||||
id: makeInstructionId(0),
|
||||
lvalue: createTemporaryPlace(env, fnExpr.loc),
|
||||
value: {
|
||||
kind: 'StartMemoize',
|
||||
manualMemoId,
|
||||
/*
|
||||
* Use deps list from source instead of inferred deps
|
||||
* as dependencies
|
||||
*/
|
||||
deps: depsList,
|
||||
loc: fnExpr.loc,
|
||||
},
|
||||
effects: null,
|
||||
loc: fnExpr.loc,
|
||||
},
|
||||
effects: null,
|
||||
loc: fnExpr.loc,
|
||||
},
|
||||
{
|
||||
id: makeInstructionId(0),
|
||||
lvalue: createTemporaryPlace(env, fnExpr.loc),
|
||||
value: {
|
||||
kind: 'FinishMemoize',
|
||||
manualMemoId,
|
||||
decl: {...memoDecl},
|
||||
loc: fnExpr.loc,
|
||||
],
|
||||
[
|
||||
{
|
||||
id: makeInstructionId(0),
|
||||
lvalue: {...temp},
|
||||
value: {
|
||||
kind: 'LoadLocal',
|
||||
place: {...memoDecl},
|
||||
loc: memoDecl.loc,
|
||||
},
|
||||
effects: null,
|
||||
loc: memoDecl.loc,
|
||||
},
|
||||
effects: null,
|
||||
loc: fnExpr.loc,
|
||||
},
|
||||
{
|
||||
id: makeInstructionId(0),
|
||||
lvalue: createTemporaryPlace(env, memoDecl.loc),
|
||||
value: {
|
||||
kind: 'FinishMemoize',
|
||||
manualMemoId,
|
||||
decl: {...temp},
|
||||
loc: memoDecl.loc,
|
||||
},
|
||||
effects: null,
|
||||
loc: memoDecl.loc,
|
||||
},
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -409,10 +423,7 @@ export function dropManualMemoization(
|
||||
* LoadLocal fnArg
|
||||
* - (if validation is enabled) collect manual memoization markers
|
||||
*/
|
||||
const queuedInserts: Map<
|
||||
InstructionId,
|
||||
TInstruction<StartMemoize> | TInstruction<FinishMemoize>
|
||||
> = new Map();
|
||||
const queuedInserts: Map<InstructionId, Array<Instruction>> = new Map();
|
||||
for (const [_, block] of func.body.blocks) {
|
||||
for (let i = 0; i < block.instructions.length; i++) {
|
||||
const instr = block.instructions[i]!;
|
||||
@@ -438,40 +449,6 @@ export function dropManualMemoization(
|
||||
continue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bailout on void return useMemos. This is an anti-pattern where code might be using
|
||||
* useMemo like useEffect: running arbirtary side-effects synced to changes in specific
|
||||
* values.
|
||||
*/
|
||||
if (
|
||||
func.env.config.validateNoVoidUseMemo &&
|
||||
manualMemo.kind === 'useMemo'
|
||||
) {
|
||||
const funcToCheck = sidemap.functions.get(
|
||||
fnPlace.identifier.id,
|
||||
)?.value;
|
||||
if (funcToCheck !== undefined && funcToCheck.loweredFunc.func) {
|
||||
if (!hasNonVoidReturn(funcToCheck.loweredFunc.func)) {
|
||||
errors.pushDiagnostic(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.VoidUseMemo,
|
||||
reason: 'useMemo() callbacks must return a value',
|
||||
description: `This ${
|
||||
manualMemo.loadInstr.value.kind === 'PropertyLoad'
|
||||
? 'React.useMemo'
|
||||
: 'useMemo'
|
||||
} callback doesn't return a value. useMemo is for computing and caching values, not for arbitrary side effects`,
|
||||
suggestions: null,
|
||||
}).withDetails({
|
||||
kind: 'error',
|
||||
loc: instr.value.loc,
|
||||
message: 'useMemo() callbacks must return a value',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
instr.value = getManualMemoizationReplacement(
|
||||
fnPlace,
|
||||
instr.value.loc,
|
||||
@@ -557,11 +534,11 @@ export function dropManualMemoization(
|
||||
let nextInstructions: Array<Instruction> | null = null;
|
||||
for (let i = 0; i < block.instructions.length; i++) {
|
||||
const instr = block.instructions[i];
|
||||
const insertInstr = queuedInserts.get(instr.id);
|
||||
if (insertInstr != null) {
|
||||
const insertInstructions = queuedInserts.get(instr.id);
|
||||
if (insertInstructions != null) {
|
||||
nextInstructions = nextInstructions ?? block.instructions.slice(0, i);
|
||||
nextInstructions.push(instr);
|
||||
nextInstructions.push(insertInstr);
|
||||
nextInstructions.push(...insertInstructions);
|
||||
} else if (nextInstructions != null) {
|
||||
nextInstructions.push(instr);
|
||||
}
|
||||
@@ -629,17 +606,3 @@ function findOptionalPlaces(fn: HIRFunction): Set<IdentifierId> {
|
||||
}
|
||||
return optionals;
|
||||
}
|
||||
|
||||
function hasNonVoidReturn(func: HIRFunction): boolean {
|
||||
for (const [, block] of func.body.blocks) {
|
||||
if (block.terminal.kind === 'return') {
|
||||
if (
|
||||
block.terminal.returnVariant === 'Explicit' ||
|
||||
block.terminal.returnVariant === 'Implicit'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -7,14 +7,17 @@
|
||||
|
||||
import {
|
||||
HIRFunction,
|
||||
Identifier,
|
||||
IdentifierId,
|
||||
InstructionValue,
|
||||
makeInstructionId,
|
||||
MutableRange,
|
||||
Place,
|
||||
ReactiveValue,
|
||||
ReactiveScope,
|
||||
} from '../HIR';
|
||||
import {Macro, MacroMethod} from '../HIR/Environment';
|
||||
import {eachReactiveValueOperand} from './visitors';
|
||||
import {eachInstructionValueOperand} from '../HIR/visitors';
|
||||
import {Iterable_some} from '../Utils/utils';
|
||||
|
||||
/**
|
||||
* This pass supports the `fbt` translation system (https://facebook.github.io/fbt/)
|
||||
@@ -48,24 +51,49 @@ export function memoizeFbtAndMacroOperandsInSameScope(
|
||||
...Array.from(FBT_TAGS).map((tag): Macro => [tag, []]),
|
||||
...(fn.env.config.customMacros ?? []),
|
||||
]);
|
||||
const fbtValues: Set<IdentifierId> = new Set();
|
||||
/**
|
||||
* Set of all identifiers that load fbt or other macro functions or their nested
|
||||
* properties, as well as values known to be the results of invoking macros
|
||||
*/
|
||||
const macroTagsCalls: Set<IdentifierId> = new Set();
|
||||
/**
|
||||
* Mapping of lvalue => list of operands for all expressions where either
|
||||
* the lvalue is a known fbt/macro call and/or the operands transitively
|
||||
* contain fbt/macro calls.
|
||||
*
|
||||
* This is the key data structure that powers the scope merging: we start
|
||||
* at the lvalues and merge operands into the lvalue's scope.
|
||||
*/
|
||||
const macroValues: Map<Identifier, Array<Identifier>> = new Map();
|
||||
// Tracks methods loaded from macros, like fbt.param or idx.foo
|
||||
const macroMethods = new Map<IdentifierId, Array<Array<MacroMethod>>>();
|
||||
while (true) {
|
||||
let vsize = fbtValues.size;
|
||||
let msize = macroMethods.size;
|
||||
visit(fn, fbtMacroTags, fbtValues, macroMethods);
|
||||
if (vsize === fbtValues.size && msize === macroMethods.size) {
|
||||
break;
|
||||
|
||||
visit(fn, fbtMacroTags, macroTagsCalls, macroMethods, macroValues);
|
||||
|
||||
for (const root of macroValues.keys()) {
|
||||
const scope = root.scope;
|
||||
if (scope == null) {
|
||||
continue;
|
||||
}
|
||||
// Merge the operands into the same scope if this is a known macro invocation
|
||||
if (!macroTagsCalls.has(root.id)) {
|
||||
continue;
|
||||
}
|
||||
mergeScopes(root, scope, macroValues, macroTagsCalls);
|
||||
}
|
||||
return fbtValues;
|
||||
|
||||
return macroTagsCalls;
|
||||
}
|
||||
|
||||
export const FBT_TAGS: Set<string> = new Set([
|
||||
'fbt',
|
||||
'fbt:param',
|
||||
'fbt:enum',
|
||||
'fbt:plural',
|
||||
'fbs',
|
||||
'fbs:param',
|
||||
'fbs:enum',
|
||||
'fbs:plural',
|
||||
]);
|
||||
export const SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
|
||||
'fbt:param',
|
||||
@@ -75,10 +103,22 @@ export const SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
|
||||
function visit(
|
||||
fn: HIRFunction,
|
||||
fbtMacroTags: Set<Macro>,
|
||||
fbtValues: Set<IdentifierId>,
|
||||
macroTagsCalls: Set<IdentifierId>,
|
||||
macroMethods: Map<IdentifierId, Array<Array<MacroMethod>>>,
|
||||
macroValues: Map<Identifier, Array<Identifier>>,
|
||||
): void {
|
||||
for (const [, block] of fn.body.blocks) {
|
||||
for (const phi of block.phis) {
|
||||
const macroOperands: Array<Identifier> = [];
|
||||
for (const operand of phi.operands.values()) {
|
||||
if (macroValues.has(operand.identifier)) {
|
||||
macroOperands.push(operand.identifier);
|
||||
}
|
||||
}
|
||||
if (macroOperands.length !== 0) {
|
||||
macroValues.set(phi.place.identifier, macroOperands);
|
||||
}
|
||||
}
|
||||
for (const instruction of block.instructions) {
|
||||
const {lvalue, value} = instruction;
|
||||
if (lvalue === null) {
|
||||
@@ -93,13 +133,13 @@ function visit(
|
||||
* We don't distinguish between tag names and strings, so record
|
||||
* all `fbt` string literals in case they are used as a jsx tag.
|
||||
*/
|
||||
fbtValues.add(lvalue.identifier.id);
|
||||
macroTagsCalls.add(lvalue.identifier.id);
|
||||
} else if (
|
||||
value.kind === 'LoadGlobal' &&
|
||||
matchesExactTag(value.binding.name, fbtMacroTags)
|
||||
) {
|
||||
// Record references to `fbt` as a global
|
||||
fbtValues.add(lvalue.identifier.id);
|
||||
macroTagsCalls.add(lvalue.identifier.id);
|
||||
} else if (
|
||||
value.kind === 'LoadGlobal' &&
|
||||
matchTagRoot(value.binding.name, fbtMacroTags) !== null
|
||||
@@ -121,84 +161,66 @@ function visit(
|
||||
if (method.length > 1) {
|
||||
newMethods.push(method.slice(1));
|
||||
} else {
|
||||
fbtValues.add(lvalue.identifier.id);
|
||||
macroTagsCalls.add(lvalue.identifier.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (newMethods.length > 0) {
|
||||
macroMethods.set(lvalue.identifier.id, newMethods);
|
||||
}
|
||||
} else if (isFbtCallExpression(fbtValues, value)) {
|
||||
const fbtScope = lvalue.identifier.scope;
|
||||
if (fbtScope === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* if the JSX element's tag was `fbt`, mark all its operands
|
||||
* to ensure that they end up in the same scope as the jsx element
|
||||
* itself.
|
||||
*/
|
||||
for (const operand of eachReactiveValueOperand(value)) {
|
||||
operand.identifier.scope = fbtScope;
|
||||
|
||||
// Expand the jsx element's range to account for its operands
|
||||
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
|
||||
fbtValues.add(operand.identifier.id);
|
||||
}
|
||||
} else if (
|
||||
isFbtJsxExpression(fbtMacroTags, fbtValues, value) ||
|
||||
isFbtJsxChild(fbtValues, lvalue, value)
|
||||
value.kind === 'PropertyLoad' &&
|
||||
macroTagsCalls.has(value.object.identifier.id)
|
||||
) {
|
||||
const fbtScope = lvalue.identifier.scope;
|
||||
if (fbtScope === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
/*
|
||||
* if the JSX element's tag was `fbt`, mark all its operands
|
||||
* to ensure that they end up in the same scope as the jsx element
|
||||
* itself.
|
||||
*/
|
||||
for (const operand of eachReactiveValueOperand(value)) {
|
||||
operand.identifier.scope = fbtScope;
|
||||
|
||||
// Expand the jsx element's range to account for its operands
|
||||
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
|
||||
|
||||
/*
|
||||
* NOTE: we add the operands as fbt values so that they are also
|
||||
* grouped with this expression
|
||||
*/
|
||||
fbtValues.add(operand.identifier.id);
|
||||
}
|
||||
} else if (fbtValues.has(lvalue.identifier.id)) {
|
||||
const fbtScope = lvalue.identifier.scope;
|
||||
if (fbtScope === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const operand of eachReactiveValueOperand(value)) {
|
||||
if (
|
||||
operand.identifier.name !== null &&
|
||||
operand.identifier.name.kind === 'named'
|
||||
) {
|
||||
/*
|
||||
* named identifiers were already locals, we only have to force temporaries
|
||||
* into the same scope
|
||||
*/
|
||||
continue;
|
||||
macroTagsCalls.add(lvalue.identifier.id);
|
||||
} else if (
|
||||
isFbtJsxExpression(fbtMacroTags, macroTagsCalls, value) ||
|
||||
isFbtJsxChild(macroTagsCalls, lvalue, value) ||
|
||||
isFbtCallExpression(macroTagsCalls, value)
|
||||
) {
|
||||
macroTagsCalls.add(lvalue.identifier.id);
|
||||
macroValues.set(
|
||||
lvalue.identifier,
|
||||
Array.from(
|
||||
eachInstructionValueOperand(value),
|
||||
operand => operand.identifier,
|
||||
),
|
||||
);
|
||||
} else if (
|
||||
Iterable_some(eachInstructionValueOperand(value), operand =>
|
||||
macroValues.has(operand.identifier),
|
||||
)
|
||||
) {
|
||||
const macroOperands: Array<Identifier> = [];
|
||||
for (const operand of eachInstructionValueOperand(value)) {
|
||||
if (macroValues.has(operand.identifier)) {
|
||||
macroOperands.push(operand.identifier);
|
||||
}
|
||||
operand.identifier.scope = fbtScope;
|
||||
|
||||
// Expand the jsx element's range to account for its operands
|
||||
expandFbtScopeRange(fbtScope.range, operand.identifier.mutableRange);
|
||||
}
|
||||
macroValues.set(lvalue.identifier, macroOperands);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function mergeScopes(
|
||||
root: Identifier,
|
||||
scope: ReactiveScope,
|
||||
macroValues: Map<Identifier, Array<Identifier>>,
|
||||
macroTagsCalls: Set<IdentifierId>,
|
||||
): void {
|
||||
const operands = macroValues.get(root);
|
||||
if (operands == null) {
|
||||
return;
|
||||
}
|
||||
for (const operand of operands) {
|
||||
operand.scope = scope;
|
||||
expandFbtScopeRange(scope.range, operand.mutableRange);
|
||||
macroTagsCalls.add(operand.id);
|
||||
mergeScopes(operand, scope, macroValues, macroTagsCalls);
|
||||
}
|
||||
}
|
||||
|
||||
function matchesExactTag(s: string, tags: Set<Macro>): boolean {
|
||||
return Array.from(tags).some(macro =>
|
||||
typeof macro === 'string'
|
||||
@@ -229,39 +251,40 @@ function matchTagRoot(
|
||||
}
|
||||
|
||||
function isFbtCallExpression(
|
||||
fbtValues: Set<IdentifierId>,
|
||||
value: ReactiveValue,
|
||||
macroTagsCalls: Set<IdentifierId>,
|
||||
value: InstructionValue,
|
||||
): boolean {
|
||||
return (
|
||||
(value.kind === 'CallExpression' &&
|
||||
fbtValues.has(value.callee.identifier.id)) ||
|
||||
(value.kind === 'MethodCall' && fbtValues.has(value.property.identifier.id))
|
||||
macroTagsCalls.has(value.callee.identifier.id)) ||
|
||||
(value.kind === 'MethodCall' &&
|
||||
macroTagsCalls.has(value.property.identifier.id))
|
||||
);
|
||||
}
|
||||
|
||||
function isFbtJsxExpression(
|
||||
fbtMacroTags: Set<Macro>,
|
||||
fbtValues: Set<IdentifierId>,
|
||||
value: ReactiveValue,
|
||||
macroTagsCalls: Set<IdentifierId>,
|
||||
value: InstructionValue,
|
||||
): boolean {
|
||||
return (
|
||||
value.kind === 'JsxExpression' &&
|
||||
((value.tag.kind === 'Identifier' &&
|
||||
fbtValues.has(value.tag.identifier.id)) ||
|
||||
macroTagsCalls.has(value.tag.identifier.id)) ||
|
||||
(value.tag.kind === 'BuiltinTag' &&
|
||||
matchesExactTag(value.tag.name, fbtMacroTags)))
|
||||
);
|
||||
}
|
||||
|
||||
function isFbtJsxChild(
|
||||
fbtValues: Set<IdentifierId>,
|
||||
macroTagsCalls: Set<IdentifierId>,
|
||||
lvalue: Place | null,
|
||||
value: ReactiveValue,
|
||||
value: InstructionValue,
|
||||
): boolean {
|
||||
return (
|
||||
(value.kind === 'JsxExpression' || value.kind === 'JsxFragment') &&
|
||||
lvalue !== null &&
|
||||
fbtValues.has(lvalue.identifier.id)
|
||||
macroTagsCalls.has(lvalue.identifier.id)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -77,6 +77,15 @@ class Transform extends ReactiveFunctionTransform<boolean> {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'FinishMemoize': {
|
||||
if (
|
||||
!withinScope &&
|
||||
this.alwaysInvalidatingValues.has(value.decl.identifier)
|
||||
) {
|
||||
value.pruned = true;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return {kind: 'keep'};
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* LICENSE file in the root directory of this source tree.
|
||||
*/
|
||||
|
||||
import {fromZodError} from 'zod-validation-error';
|
||||
import {fromZodError} from 'zod-validation-error/v4';
|
||||
import {CompilerError} from '../CompilerError';
|
||||
import {
|
||||
CompilationMode,
|
||||
|
||||
@@ -10,16 +10,37 @@ import {
|
||||
CompilerError,
|
||||
ErrorCategory,
|
||||
} from '../CompilerError';
|
||||
import {FunctionExpression, HIRFunction, IdentifierId} from '../HIR';
|
||||
import {
|
||||
FunctionExpression,
|
||||
HIRFunction,
|
||||
IdentifierId,
|
||||
SourceLocation,
|
||||
} from '../HIR';
|
||||
import {
|
||||
eachInstructionValueOperand,
|
||||
eachTerminalOperand,
|
||||
} from '../HIR/visitors';
|
||||
import {Result} from '../Utils/Result';
|
||||
|
||||
export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
|
||||
const errors = new CompilerError();
|
||||
const voidMemoErrors = new CompilerError();
|
||||
const useMemos = new Set<IdentifierId>();
|
||||
const react = new Set<IdentifierId>();
|
||||
const functions = new Map<IdentifierId, FunctionExpression>();
|
||||
const unusedUseMemos = new Map<IdentifierId, SourceLocation>();
|
||||
for (const [, block] of fn.body.blocks) {
|
||||
for (const {lvalue, value} of block.instructions) {
|
||||
if (unusedUseMemos.size !== 0) {
|
||||
/**
|
||||
* Most of the time useMemo results are referenced immediately. Don't bother
|
||||
* scanning instruction operands for useMemos unless there is an as-yet-unused
|
||||
* useMemo.
|
||||
*/
|
||||
for (const operand of eachInstructionValueOperand(value)) {
|
||||
unusedUseMemos.delete(operand.identifier.id);
|
||||
}
|
||||
}
|
||||
switch (value.kind) {
|
||||
case 'LoadGlobal': {
|
||||
if (value.binding.name === 'useMemo') {
|
||||
@@ -45,10 +66,8 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
|
||||
case 'CallExpression': {
|
||||
// Is the function being called useMemo, with at least 1 argument?
|
||||
const callee =
|
||||
value.kind === 'CallExpression'
|
||||
? value.callee.identifier.id
|
||||
: value.property.identifier.id;
|
||||
const isUseMemo = useMemos.has(callee);
|
||||
value.kind === 'CallExpression' ? value.callee : value.property;
|
||||
const isUseMemo = useMemos.has(callee.identifier.id);
|
||||
if (!isUseMemo || value.args.length === 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -104,10 +123,103 @@ export function validateUseMemo(fn: HIRFunction): Result<void, CompilerError> {
|
||||
);
|
||||
}
|
||||
|
||||
validateNoContextVariableAssignment(body.loweredFunc.func, errors);
|
||||
|
||||
if (fn.env.config.validateNoVoidUseMemo) {
|
||||
if (!hasNonVoidReturn(body.loweredFunc.func)) {
|
||||
voidMemoErrors.pushDiagnostic(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.VoidUseMemo,
|
||||
reason: 'useMemo() callbacks must return a value',
|
||||
description: `This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects`,
|
||||
suggestions: null,
|
||||
}).withDetails({
|
||||
kind: 'error',
|
||||
loc: body.loc,
|
||||
message: 'useMemo() callbacks must return a value',
|
||||
}),
|
||||
);
|
||||
} else {
|
||||
unusedUseMemos.set(lvalue.identifier.id, callee.loc);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unusedUseMemos.size !== 0) {
|
||||
for (const operand of eachTerminalOperand(block.terminal)) {
|
||||
unusedUseMemos.delete(operand.identifier.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (unusedUseMemos.size !== 0) {
|
||||
/**
|
||||
* Basic check for unused memos, where the result of the call is never referenced. This runs
|
||||
* before DCE so it's more of an AST-level check that something, _anything_, cares about the value.
|
||||
*
|
||||
* This is easy to defeat with e.g. `const _ = useMemo(...)` but it at least gives us something to teach.
|
||||
* Even a DCE-based version could be bypassed with `noop(useMemo(...))`.
|
||||
*/
|
||||
for (const loc of unusedUseMemos.values()) {
|
||||
voidMemoErrors.pushDiagnostic(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.VoidUseMemo,
|
||||
reason: 'useMemo() result is unused',
|
||||
description: `This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects`,
|
||||
suggestions: null,
|
||||
}).withDetails({
|
||||
kind: 'error',
|
||||
loc,
|
||||
message: 'useMemo() result is unused',
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
fn.env.logErrors(voidMemoErrors.asResult());
|
||||
return errors.asResult();
|
||||
}
|
||||
|
||||
function validateNoContextVariableAssignment(
|
||||
fn: HIRFunction,
|
||||
errors: CompilerError,
|
||||
): void {
|
||||
for (const block of fn.body.blocks.values()) {
|
||||
for (const instr of block.instructions) {
|
||||
const value = instr.value;
|
||||
switch (value.kind) {
|
||||
case 'StoreContext': {
|
||||
errors.pushDiagnostic(
|
||||
CompilerDiagnostic.create({
|
||||
category: ErrorCategory.UseMemo,
|
||||
reason:
|
||||
'useMemo() callbacks may not reassign variables declared outside of the callback',
|
||||
description:
|
||||
'useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function',
|
||||
suggestions: null,
|
||||
}).withDetails({
|
||||
kind: 'error',
|
||||
loc: value.lvalue.place.loc,
|
||||
message: 'Cannot reassign variable',
|
||||
}),
|
||||
);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.asResult();
|
||||
}
|
||||
|
||||
function hasNonVoidReturn(func: HIRFunction): boolean {
|
||||
for (const [, block] of func.body.blocks) {
|
||||
if (block.terminal.kind === 'return') {
|
||||
if (
|
||||
block.terminal.returnVariant === 'Explicit' ||
|
||||
block.terminal.returnVariant === 'Implicit'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ Found 1 error:
|
||||
|
||||
Invariant: Expected consistent kind for destructuring
|
||||
|
||||
Other places were `Reassign` but 'mutate? #t8$46[7:9]{reactive}' is const.
|
||||
Other places were `Reassign` but 'mutate? #t8$47[7:9]{reactive}' is const.
|
||||
|
||||
error.bug-invariant-expected-consistent-destructuring.ts:9:9
|
||||
7 |
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
function Component() {
|
||||
let x;
|
||||
const y = useMemo(() => {
|
||||
let z;
|
||||
x = [];
|
||||
z = true;
|
||||
return z;
|
||||
}, []);
|
||||
return [x, y];
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 1 error:
|
||||
|
||||
Error: useMemo() callbacks may not reassign variables declared outside of the callback
|
||||
|
||||
useMemo() callbacks must be pure functions and cannot reassign variables defined outside of the callback function.
|
||||
|
||||
error.invalid-reassign-variable-in-usememo.ts:5:4
|
||||
3 | const y = useMemo(() => {
|
||||
4 | let z;
|
||||
> 5 | x = [];
|
||||
| ^ Cannot reassign variable
|
||||
6 | z = true;
|
||||
7 | return z;
|
||||
8 | }, []);
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
function Component() {
|
||||
let x;
|
||||
const y = useMemo(() => {
|
||||
let z;
|
||||
x = [];
|
||||
z = true;
|
||||
return z;
|
||||
}, []);
|
||||
return [x, y];
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateNoVoidUseMemo
|
||||
function Component() {
|
||||
const value = useMemo(() => {
|
||||
console.log('computing');
|
||||
}, []);
|
||||
const value2 = React.useMemo(() => {
|
||||
console.log('computing');
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
{value}
|
||||
{value2}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 2 errors:
|
||||
|
||||
Error: useMemo() callbacks must return a value
|
||||
|
||||
This useMemo callback doesn't return a value. useMemo is for computing and caching values, not for arbitrary side effects.
|
||||
|
||||
error.useMemo-no-return-value.ts:3:16
|
||||
1 | // @validateNoVoidUseMemo
|
||||
2 | function Component() {
|
||||
> 3 | const value = useMemo(() => {
|
||||
| ^^^^^^^^^^^^^^^
|
||||
> 4 | console.log('computing');
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 5 | }, []);
|
||||
| ^^^^^^^^^ useMemo() callbacks must return a value
|
||||
6 | const value2 = React.useMemo(() => {
|
||||
7 | console.log('computing');
|
||||
8 | }, []);
|
||||
|
||||
Error: useMemo() callbacks must return a value
|
||||
|
||||
This React.useMemo callback doesn't return a value. useMemo is for computing and caching values, not for arbitrary side effects.
|
||||
|
||||
error.useMemo-no-return-value.ts:6:17
|
||||
4 | console.log('computing');
|
||||
5 | }, []);
|
||||
> 6 | const value2 = React.useMemo(() => {
|
||||
| ^^^^^^^^^^^^^^^^^^^^^
|
||||
> 7 | console.log('computing');
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
|
||||
> 8 | }, []);
|
||||
| ^^^^^^^^^ useMemo() callbacks must return a value
|
||||
9 | return (
|
||||
10 | <div>
|
||||
11 | {value}
|
||||
```
|
||||
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
import fbt from 'fbt';
|
||||
import {Stringify} from 'shared-runtime';
|
||||
|
||||
/**
|
||||
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
|
||||
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
|
||||
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
|
||||
* ---
|
||||
* t3
|
||||
* ---
|
||||
*/
|
||||
function Component({firstname, lastname}) {
|
||||
'use memo';
|
||||
return (
|
||||
<Stringify>
|
||||
{fbt(
|
||||
[
|
||||
'Name: ',
|
||||
fbt.param('firstname', <Stringify key={0} name={firstname} />),
|
||||
', ',
|
||||
fbt.param(
|
||||
'lastname',
|
||||
<Stringify key={0} name={lastname}>
|
||||
{fbt('(inner fbt)', 'Inner fbt value')}
|
||||
</Stringify>
|
||||
),
|
||||
],
|
||||
'Name'
|
||||
)}
|
||||
</Stringify>
|
||||
);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{firstname: 'first', lastname: 'last'}],
|
||||
sequentialRenders: [{firstname: 'first', lastname: 'last'}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
Line 19 Column 11: fbt: unsupported babel node: Identifier
|
||||
---
|
||||
t3
|
||||
---
|
||||
```
|
||||
|
||||
|
||||
@@ -37,27 +37,31 @@ import { c as _c } from "react/compiler-runtime";
|
||||
import fbt from "fbt";
|
||||
|
||||
function Foo(t0) {
|
||||
const $ = _c(3);
|
||||
const $ = _c(7);
|
||||
const { name1, name2 } = t0;
|
||||
let t1;
|
||||
if ($[0] !== name1 || $[1] !== name2) {
|
||||
let t2;
|
||||
if ($[3] !== name1) {
|
||||
t2 = <b>{name1}</b>;
|
||||
$[3] = name1;
|
||||
$[4] = t2;
|
||||
} else {
|
||||
t2 = $[4];
|
||||
}
|
||||
let t3;
|
||||
if ($[5] !== name2) {
|
||||
t3 = <b>{name2}</b>;
|
||||
$[5] = name2;
|
||||
$[6] = t3;
|
||||
} else {
|
||||
t3 = $[6];
|
||||
}
|
||||
t1 = fbt._(
|
||||
"{user1} and {user2} accepted your PR!",
|
||||
[
|
||||
fbt._param(
|
||||
"user1",
|
||||
|
||||
<span key={name1}>
|
||||
<b>{name1}</b>
|
||||
</span>,
|
||||
),
|
||||
fbt._param(
|
||||
"user2",
|
||||
|
||||
<span key={name2}>
|
||||
<b>{name2}</b>
|
||||
</span>,
|
||||
),
|
||||
fbt._param("user1", <span key={name1}>{t2}</span>),
|
||||
fbt._param("user2", <span key={name2}>{t3}</span>),
|
||||
],
|
||||
{ hk: "2PxMie" },
|
||||
);
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
import fbt from 'fbt';
|
||||
import {Stringify} from 'shared-runtime';
|
||||
|
||||
/**
|
||||
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
|
||||
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
|
||||
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
|
||||
* ---
|
||||
* t3
|
||||
* ---
|
||||
*/
|
||||
function Component({firstname, lastname}) {
|
||||
'use memo';
|
||||
return (
|
||||
<Stringify>
|
||||
{fbt(
|
||||
[
|
||||
'Name: ',
|
||||
fbt.param('firstname', <Stringify key={0} name={firstname} />),
|
||||
', ',
|
||||
fbt.param(
|
||||
'lastname',
|
||||
<Stringify key={0} name={lastname}>
|
||||
{fbt('(inner fbt)', 'Inner fbt value')}
|
||||
</Stringify>
|
||||
),
|
||||
],
|
||||
'Name'
|
||||
)}
|
||||
</Stringify>
|
||||
);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{firstname: 'first', lastname: 'last'}],
|
||||
sequentialRenders: [{firstname: 'first', lastname: 'last'}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import fbt from "fbt";
|
||||
import { Stringify } from "shared-runtime";
|
||||
|
||||
/**
|
||||
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
|
||||
* Expected fixture `fbt-param-call-arguments` to succeed but it failed with error:
|
||||
* /fbt-param-call-arguments.ts: Line 19 Column 11: fbt: unsupported babel node: Identifier
|
||||
* ---
|
||||
* t3
|
||||
* ---
|
||||
*/
|
||||
function Component(t0) {
|
||||
"use memo";
|
||||
const $ = _c(5);
|
||||
const { firstname, lastname } = t0;
|
||||
let t1;
|
||||
if ($[0] !== firstname || $[1] !== lastname) {
|
||||
t1 = fbt._(
|
||||
"Name: {firstname}, {lastname}",
|
||||
[
|
||||
fbt._param(
|
||||
"firstname",
|
||||
|
||||
<Stringify key={0} name={firstname} />,
|
||||
),
|
||||
fbt._param(
|
||||
"lastname",
|
||||
|
||||
<Stringify key={0} name={lastname}>
|
||||
{fbt._("(inner fbt)", null, { hk: "36qNwF" })}
|
||||
</Stringify>,
|
||||
),
|
||||
],
|
||||
{ hk: "3AiIf8" },
|
||||
);
|
||||
$[0] = firstname;
|
||||
$[1] = lastname;
|
||||
$[2] = t1;
|
||||
} else {
|
||||
t1 = $[2];
|
||||
}
|
||||
let t2;
|
||||
if ($[3] !== t1) {
|
||||
t2 = <Stringify>{t1}</Stringify>;
|
||||
$[3] = t1;
|
||||
$[4] = t2;
|
||||
} else {
|
||||
t2 = $[4];
|
||||
}
|
||||
return t2;
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{ firstname: "first", lastname: "last" }],
|
||||
sequentialRenders: [{ firstname: "first", lastname: "last" }],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: ok) <div>{"children":"Name: , "}</div>
|
||||
@@ -0,0 +1,78 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
import {fbt} from 'fbt';
|
||||
import {useState} from 'react';
|
||||
|
||||
const MIN = 10;
|
||||
|
||||
function Component() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return fbt(
|
||||
'Expected at least ' +
|
||||
fbt.param('min', MIN, {number: true}) +
|
||||
' items, but got ' +
|
||||
fbt.param('count', count, {number: true}) +
|
||||
' items.',
|
||||
'Error description'
|
||||
);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { fbt } from "fbt";
|
||||
import { useState } from "react";
|
||||
|
||||
const MIN = 10;
|
||||
|
||||
function Component() {
|
||||
const $ = _c(2);
|
||||
const [count] = useState(0);
|
||||
let t0;
|
||||
if ($[0] !== count) {
|
||||
t0 = fbt._(
|
||||
{ "*": { "*": "Expected at least {min} items, but got {count} items." } },
|
||||
[
|
||||
fbt._param(
|
||||
"min",
|
||||
|
||||
MIN,
|
||||
[0],
|
||||
),
|
||||
fbt._param(
|
||||
"count",
|
||||
|
||||
count,
|
||||
[0],
|
||||
),
|
||||
],
|
||||
{ hk: "36gbz8" },
|
||||
);
|
||||
$[0] = count;
|
||||
$[1] = t0;
|
||||
} else {
|
||||
t0 = $[1];
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: ok) Expected at least 10 items, but got 0 items.
|
||||
@@ -0,0 +1,22 @@
|
||||
import {fbt} from 'fbt';
|
||||
import {useState} from 'react';
|
||||
|
||||
const MIN = 10;
|
||||
|
||||
function Component() {
|
||||
const [count, setCount] = useState(0);
|
||||
|
||||
return fbt(
|
||||
'Expected at least ' +
|
||||
fbt.param('min', MIN, {number: true}) +
|
||||
' items, but got ' +
|
||||
fbt.param('count', count, {number: true}) +
|
||||
' items.',
|
||||
'Error description'
|
||||
);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{}],
|
||||
};
|
||||
@@ -73,7 +73,7 @@ function Component(props) {
|
||||
const groupName4 = t3;
|
||||
let t4;
|
||||
if ($[8] !== props) {
|
||||
t4 = idx.hello_world.b.c(props, _temp3);
|
||||
t4 = idx.hello_world.b.c(props, (__3) => __3.group.label);
|
||||
$[8] = props;
|
||||
$[9] = t4;
|
||||
} else {
|
||||
@@ -108,9 +108,6 @@ function Component(props) {
|
||||
}
|
||||
return t5;
|
||||
}
|
||||
function _temp3(__3) {
|
||||
return __3.group.label;
|
||||
}
|
||||
function _temp2(__0) {
|
||||
return __0.group.label;
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ function Component(props) {
|
||||
const groupName2 = t1;
|
||||
let t2;
|
||||
if ($[4] !== props) {
|
||||
t2 = idx.a.b(props, _temp2);
|
||||
t2 = idx.a.b(props, (__1) => __1.group.label);
|
||||
$[4] = props;
|
||||
$[5] = t2;
|
||||
} else {
|
||||
@@ -74,9 +74,6 @@ function Component(props) {
|
||||
}
|
||||
return t3;
|
||||
}
|
||||
function _temp2(__1) {
|
||||
return __1.group.label;
|
||||
}
|
||||
function _temp(_) {
|
||||
return _.group.label;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateNoVoidUseMemo @loggerTestOnly
|
||||
function Component() {
|
||||
useMemo(() => {
|
||||
return [];
|
||||
}, []);
|
||||
return <div />;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo @loggerTestOnly
|
||||
function Component() {
|
||||
const $ = _c(1);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = <div />;
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```
|
||||
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() result is unused","description":"This useMemo() value is unused. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":2,"index":67},"end":{"line":3,"column":9,"index":74},"filename":"invalid-unused-usememo.ts","identifierName":"useMemo"},"message":"useMemo() result is unused"}]}},"fnLoc":null}
|
||||
{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":42},"end":{"line":7,"column":1,"index":127},"filename":"invalid-unused-usememo.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: exception) Fixture not implemented
|
||||
@@ -0,0 +1,7 @@
|
||||
// @validateNoVoidUseMemo @loggerTestOnly
|
||||
function Component() {
|
||||
useMemo(() => {
|
||||
return [];
|
||||
}, []);
|
||||
return <div />;
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateNoVoidUseMemo @loggerTestOnly
|
||||
function Component() {
|
||||
const value = useMemo(() => {
|
||||
console.log('computing');
|
||||
}, []);
|
||||
const value2 = React.useMemo(() => {
|
||||
console.log('computing');
|
||||
}, []);
|
||||
return (
|
||||
<div>
|
||||
{value}
|
||||
{value2}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo @loggerTestOnly
|
||||
function Component() {
|
||||
const $ = _c(1);
|
||||
|
||||
console.log("computing");
|
||||
|
||||
console.log("computing");
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = (
|
||||
<div>
|
||||
{undefined}
|
||||
{undefined}
|
||||
</div>
|
||||
);
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```
|
||||
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":24,"index":89},"end":{"line":5,"column":3,"index":130},"filename":"invalid-useMemo-no-return-value.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
|
||||
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":6,"column":31,"index":168},"end":{"line":8,"column":3,"index":209},"filename":"invalid-useMemo-no-return-value.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
|
||||
{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":42},"end":{"line":15,"column":1,"index":283},"filename":"invalid-useMemo-no-return-value.ts"},"fnName":"Component","memoSlots":1,"memoBlocks":1,"memoValues":1,"prunedMemoBlocks":0,"prunedMemoValues":0}
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: exception) Fixture not implemented
|
||||
@@ -1,4 +1,4 @@
|
||||
// @validateNoVoidUseMemo
|
||||
// @validateNoVoidUseMemo @loggerTestOnly
|
||||
function Component() {
|
||||
const value = useMemo(() => {
|
||||
console.log('computing');
|
||||
@@ -0,0 +1,33 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @loggerTestOnly
|
||||
function component(a) {
|
||||
let x = useMemo(() => {
|
||||
mutate(a);
|
||||
}, []);
|
||||
return x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
// @loggerTestOnly
|
||||
function component(a) {
|
||||
mutate(a);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Logs
|
||||
|
||||
```
|
||||
{"kind":"CompileError","detail":{"options":{"category":"VoidUseMemo","reason":"useMemo() callbacks must return a value","description":"This useMemo() callback doesn't return a value. useMemo() is for computing and caching values, not for arbitrary side effects","suggestions":null,"details":[{"kind":"error","loc":{"start":{"line":3,"column":18,"index":61},"end":{"line":5,"column":3,"index":87},"filename":"invalid-useMemo-return-empty.ts"},"message":"useMemo() callbacks must return a value"}]}},"fnLoc":null}
|
||||
{"kind":"CompileSuccess","fnLoc":{"start":{"line":2,"column":0,"index":19},"end":{"line":7,"column":1,"index":107},"filename":"invalid-useMemo-return-empty.ts"},"fnName":"component","memoSlots":0,"memoBlocks":0,"memoValues":0,"prunedMemoBlocks":1,"prunedMemoValues":0}
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: exception) Fixture not implemented
|
||||
@@ -1,3 +1,4 @@
|
||||
// @loggerTestOnly
|
||||
function component(a) {
|
||||
let x = useMemo(() => {
|
||||
mutate(a);
|
||||
@@ -1,49 +0,0 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validatePreserveExistingMemoizationGuarantees
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useHook} from 'shared-runtime';
|
||||
|
||||
// useMemo values may not be memoized in Forget output if we
|
||||
// infer that their deps always invalidate.
|
||||
// This is technically a false positive as the useMemo in source
|
||||
// was effectively a no-op
|
||||
function useFoo(props) {
|
||||
const x = [];
|
||||
useHook();
|
||||
x.push(props);
|
||||
|
||||
return useMemo(() => [x], [x]);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: useFoo,
|
||||
params: [{}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
|
||||
## Error
|
||||
|
||||
```
|
||||
Found 1 error:
|
||||
|
||||
Compilation Skipped: Existing memoization could not be preserved
|
||||
|
||||
React Compiler has skipped optimizing this component because the existing manual memoization could not be preserved. This value was memoized in source but not in compilation output.
|
||||
|
||||
error.false-positive-useMemo-dropped-infer-always-invalidating.ts:15:9
|
||||
13 | x.push(props);
|
||||
14 |
|
||||
> 15 | return useMemo(() => [x], [x]);
|
||||
| ^^^^^^^^^^^^^^^^^^^^^^^ Could not preserve existing memoization
|
||||
16 | }
|
||||
17 |
|
||||
18 | export const FIXTURE_ENTRYPOINT = {
|
||||
```
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
// @validatePreserveExistingMemoizationGuarantees
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useHook} from 'shared-runtime';
|
||||
|
||||
// useMemo values may not be memoized in Forget output if we
|
||||
// infer that their deps always invalidate.
|
||||
// This is technically a false positive as the useMemo in source
|
||||
// was effectively a no-op
|
||||
function useFoo(props) {
|
||||
const x = [];
|
||||
useHook();
|
||||
x.push(props);
|
||||
|
||||
return useMemo(() => [x], [x]);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: useFoo,
|
||||
params: [{}],
|
||||
};
|
||||
@@ -0,0 +1,56 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validatePreserveExistingMemoizationGuarantees
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useHook} from 'shared-runtime';
|
||||
|
||||
// If we can prove that a useMemo was ineffective because it would always invalidate,
|
||||
// then we shouldn't throw a "couldn't preserve existing memoization" error
|
||||
// TODO: consider reporting a separate error to the user for this case, if you're going
|
||||
// to memoize manually, then you probably want to know that it's a no-op
|
||||
function useFoo(props) {
|
||||
const x = [];
|
||||
useHook();
|
||||
x.push(props);
|
||||
|
||||
return useMemo(() => [x], [x]);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: useFoo,
|
||||
params: [{}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
// @validatePreserveExistingMemoizationGuarantees
|
||||
|
||||
import { useMemo } from "react";
|
||||
import { useHook } from "shared-runtime";
|
||||
|
||||
// If we can prove that a useMemo was ineffective because it would always invalidate,
|
||||
// then we shouldn't throw a "couldn't preserve existing memoization" error
|
||||
// TODO: consider reporting a separate error to the user for this case, if you're going
|
||||
// to memoize manually, then you probably want to know that it's a no-op
|
||||
function useFoo(props) {
|
||||
const x = [];
|
||||
useHook();
|
||||
x.push(props);
|
||||
return [x];
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: useFoo,
|
||||
params: [{}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: ok) [[{}]]
|
||||
@@ -0,0 +1,21 @@
|
||||
// @validatePreserveExistingMemoizationGuarantees
|
||||
|
||||
import {useMemo} from 'react';
|
||||
import {useHook} from 'shared-runtime';
|
||||
|
||||
// If we can prove that a useMemo was ineffective because it would always invalidate,
|
||||
// then we shouldn't throw a "couldn't preserve existing memoization" error
|
||||
// TODO: consider reporting a separate error to the user for this case, if you're going
|
||||
// to memoize manually, then you probably want to know that it's a no-op
|
||||
function useFoo(props) {
|
||||
const x = [];
|
||||
useHook();
|
||||
x.push(props);
|
||||
|
||||
return useMemo(() => [x], [x]);
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: useFoo,
|
||||
params: [{}],
|
||||
};
|
||||
@@ -0,0 +1,98 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
import {useMemo} from 'react';
|
||||
import {identity, useIdentity} from 'shared-runtime';
|
||||
|
||||
// Adapted from https://github.com/facebook/react/issues/34750
|
||||
function useLocalCampaignBySlug(slug: string) {
|
||||
const campaigns = useIdentity({a: {slug: 'a', name: 'campaign'}});
|
||||
// The useMemo result is never assigned to a local so we did not previously ensure
|
||||
// that there was a variable declaration for it when promoting the result temporary
|
||||
return useMemo(() => {
|
||||
for (const id of Object.keys(campaigns)) {
|
||||
const campaign = campaigns[id];
|
||||
if (campaign.slug === slug) {
|
||||
return identity(campaign);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [campaigns, slug]);
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const campaign = useLocalCampaignBySlug('a');
|
||||
return <div>{campaign.name}</div>;
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { useMemo } from "react";
|
||||
import { identity, useIdentity } from "shared-runtime";
|
||||
|
||||
// Adapted from https://github.com/facebook/react/issues/34750
|
||||
function useLocalCampaignBySlug(slug) {
|
||||
const $ = _c(4);
|
||||
let t0;
|
||||
if ($[0] === Symbol.for("react.memo_cache_sentinel")) {
|
||||
t0 = { a: { slug: "a", name: "campaign" } };
|
||||
$[0] = t0;
|
||||
} else {
|
||||
t0 = $[0];
|
||||
}
|
||||
const campaigns = useIdentity(t0);
|
||||
let t1;
|
||||
if ($[1] !== campaigns || $[2] !== slug) {
|
||||
bb0: {
|
||||
for (const id of Object.keys(campaigns)) {
|
||||
const campaign = campaigns[id];
|
||||
if (campaign.slug === slug) {
|
||||
t1 = identity(campaign);
|
||||
break bb0;
|
||||
}
|
||||
}
|
||||
|
||||
t1 = null;
|
||||
}
|
||||
$[1] = campaigns;
|
||||
$[2] = slug;
|
||||
$[3] = t1;
|
||||
} else {
|
||||
t1 = $[3];
|
||||
}
|
||||
return t1;
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const $ = _c(2);
|
||||
const campaign = useLocalCampaignBySlug("a");
|
||||
let t0;
|
||||
if ($[0] !== campaign.name) {
|
||||
t0 = <div>{campaign.name}</div>;
|
||||
$[0] = campaign.name;
|
||||
$[1] = t0;
|
||||
} else {
|
||||
t0 = $[1];
|
||||
}
|
||||
return t0;
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{}],
|
||||
};
|
||||
|
||||
```
|
||||
|
||||
### Eval output
|
||||
(kind: ok) <div>campaign</div>
|
||||
@@ -0,0 +1,28 @@
|
||||
import {useMemo} from 'react';
|
||||
import {identity, useIdentity} from 'shared-runtime';
|
||||
|
||||
// Adapted from https://github.com/facebook/react/issues/34750
|
||||
function useLocalCampaignBySlug(slug: string) {
|
||||
const campaigns = useIdentity({a: {slug: 'a', name: 'campaign'}});
|
||||
// The useMemo result is never assigned to a local so we did not previously ensure
|
||||
// that there was a variable declaration for it when promoting the result temporary
|
||||
return useMemo(() => {
|
||||
for (const id of Object.keys(campaigns)) {
|
||||
const campaign = campaigns[id];
|
||||
if (campaign.slug === slug) {
|
||||
return identity(campaign);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}, [campaigns, slug]);
|
||||
}
|
||||
|
||||
function Component() {
|
||||
const campaign = useLocalCampaignBySlug('a');
|
||||
return <div>{campaign.name}</div>;
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
fn: Component,
|
||||
params: [{}],
|
||||
};
|
||||
@@ -2,6 +2,7 @@
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
// @validateNoVoidUseMemo:false
|
||||
function Component(props) {
|
||||
const item = props.item;
|
||||
const thumbnails = [];
|
||||
@@ -22,7 +23,7 @@ function Component(props) {
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
import { c as _c } from "react/compiler-runtime";
|
||||
import { c as _c } from "react/compiler-runtime"; // @validateNoVoidUseMemo:false
|
||||
function Component(props) {
|
||||
const $ = _c(6);
|
||||
const item = props.item;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @validateNoVoidUseMemo:false
|
||||
function Component(props) {
|
||||
const item = props.item;
|
||||
const thumbnails = [];
|
||||
|
||||
@@ -6,6 +6,7 @@ function Component(props) {
|
||||
const x = useMemo(() => {
|
||||
if (props.cond) {
|
||||
if (props.cond) {
|
||||
return props.value;
|
||||
}
|
||||
}
|
||||
}, [props.cond]);
|
||||
@@ -24,10 +25,18 @@ export const FIXTURE_ENTRYPOINT = {
|
||||
|
||||
```javascript
|
||||
function Component(props) {
|
||||
if (props.cond) {
|
||||
let t0;
|
||||
bb0: {
|
||||
if (props.cond) {
|
||||
if (props.cond) {
|
||||
t0 = props.value;
|
||||
break bb0;
|
||||
}
|
||||
}
|
||||
t0 = undefined;
|
||||
}
|
||||
const x = t0;
|
||||
return x;
|
||||
}
|
||||
|
||||
export const FIXTURE_ENTRYPOINT = {
|
||||
|
||||
@@ -2,6 +2,7 @@ function Component(props) {
|
||||
const x = useMemo(() => {
|
||||
if (props.cond) {
|
||||
if (props.cond) {
|
||||
return props.value;
|
||||
}
|
||||
}
|
||||
}, [props.cond]);
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
|
||||
## Input
|
||||
|
||||
```javascript
|
||||
function component(a) {
|
||||
let x = useMemo(() => {
|
||||
mutate(a);
|
||||
}, []);
|
||||
return x;
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
## Code
|
||||
|
||||
```javascript
|
||||
function component(a) {
|
||||
mutate(a);
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
@@ -120,7 +120,15 @@ testRule('plugin-recommended', TestRecommendedRules, {
|
||||
|
||||
return <Child x={state} />;
|
||||
}`,
|
||||
errors: [makeTestCaseError('useMemo() callbacks must return a value')],
|
||||
errors: [
|
||||
makeTestCaseError('useMemo() callbacks must return a value'),
|
||||
makeTestCaseError(
|
||||
'Calling setState from useMemo may trigger an infinite loop',
|
||||
),
|
||||
makeTestCaseError(
|
||||
'Calling setState from useMemo may trigger an infinite loop',
|
||||
),
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Pipeline errors are reported',
|
||||
|
||||
@@ -15,8 +15,8 @@
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.22.4 || ^4.0.0",
|
||||
"zod-validation-error": "^3.0.3 || ^4.0.0"
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/preset-env": "^7.22.4",
|
||||
|
||||
@@ -10,7 +10,14 @@ import {defineConfig} from 'tsup';
|
||||
export default defineConfig({
|
||||
entry: ['./src/index.ts'],
|
||||
outDir: './dist',
|
||||
external: ['@babel/core', 'hermes-parser', 'zod', 'zod-validation-error'],
|
||||
external: [
|
||||
'@babel/core',
|
||||
'hermes-parser',
|
||||
'zod',
|
||||
'zod/v4',
|
||||
'zod-validation-error',
|
||||
'zod-validation-error/v4',
|
||||
],
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
dts: false,
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
"fast-glob": "^3.3.2",
|
||||
"ora": "5.4.1",
|
||||
"yargs": "^17.7.2",
|
||||
"zod": "^3.22.4 || ^4.0.0",
|
||||
"zod-validation-error": "^3.0.3 || ^4.0.0"
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {},
|
||||
"engines": {
|
||||
|
||||
@@ -18,7 +18,9 @@ export default defineConfig({
|
||||
'ora',
|
||||
'yargs',
|
||||
'zod',
|
||||
'zod/v4',
|
||||
'zod-validation-error',
|
||||
'zod-validation-error/v4',
|
||||
],
|
||||
splitting: false,
|
||||
sourcemap: false,
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
"html-to-text": "^9.0.5",
|
||||
"prettier": "^3.3.3",
|
||||
"puppeteer": "^24.7.2",
|
||||
"zod": "^3.22.4 || ^4.0.0"
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/html-to-text": "^9.0.4",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
import {McpServer} from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import {StdioServerTransport} from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
import {z} from 'zod';
|
||||
import {z} from 'zod/v4';
|
||||
import {compile, type PrintedCompilerPipelineValue} from './compiler';
|
||||
import {
|
||||
CompilerPipelineValue,
|
||||
|
||||
@@ -37,7 +37,9 @@
|
||||
"react": "0.0.0-experimental-4beb1fd8-20241118",
|
||||
"react-dom": "0.0.0-experimental-4beb1fd8-20241118",
|
||||
"readline": "^1.3.0",
|
||||
"yargs": "^17.7.1"
|
||||
"yargs": "^17.7.1",
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.19.1",
|
||||
|
||||
@@ -9,8 +9,8 @@ import {render} from '@testing-library/react';
|
||||
import {JSDOM} from 'jsdom';
|
||||
import React, {MutableRefObject} from 'react';
|
||||
import util from 'util';
|
||||
import {z} from 'zod';
|
||||
import {fromZodError} from 'zod-validation-error';
|
||||
import {z} from 'zod/v4';
|
||||
import {fromZodError} from 'zod-validation-error/v4';
|
||||
import {initFbt, toJSON} from './shared-runtime';
|
||||
|
||||
/**
|
||||
|
||||
@@ -11505,17 +11505,17 @@ zod-to-json-schema@^3.24.1:
|
||||
resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.24.5.tgz"
|
||||
integrity sha512-/AuWwMP+YqiPbsJx5D6TfgRTc4kTLjsh5SOcd4bLsfUg2RcEXrFMJl1DGgdHy2aCfsIA/cr/1JM0xcB2GZji8g==
|
||||
|
||||
"zod-validation-error@^3.0.3 || ^4.0.0":
|
||||
"zod-validation-error@^3.5.0 || ^4.0.0":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918"
|
||||
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
|
||||
|
||||
"zod@^3.22.4 || ^4.0.0":
|
||||
version "4.1.11"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5"
|
||||
integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==
|
||||
|
||||
zod@^3.23.8, zod@^3.24.1:
|
||||
version "3.24.3"
|
||||
resolved "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz"
|
||||
integrity sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==
|
||||
|
||||
"zod@^3.25.0 || ^4.0.0":
|
||||
version "4.1.12"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0"
|
||||
integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==
|
||||
|
||||
@@ -1555,6 +1555,17 @@ const allTests = {
|
||||
`,
|
||||
errors: [useEffectEventError('onClick', false)],
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
// Invalid because useEffectEvent is being passed down
|
||||
function MyComponent({ theme }) {
|
||||
return <Child onClick={useEffectEvent(() => {
|
||||
showNotification(theme);
|
||||
})} />;
|
||||
}
|
||||
`,
|
||||
errors: [{...useEffectEventError(null, false), line: 4}],
|
||||
},
|
||||
{
|
||||
code: normalizeIndent`
|
||||
// This should error even though it shares an identifier name with the below
|
||||
@@ -1726,6 +1737,14 @@ function classError(hook) {
|
||||
}
|
||||
|
||||
function useEffectEventError(fn, called) {
|
||||
if (fn === null) {
|
||||
return {
|
||||
message:
|
||||
`React Hook "useEffectEvent" can only be called at the top level of your component.` +
|
||||
` It cannot be passed down.`,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message:
|
||||
`\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
"@babel/core": "^7.24.4",
|
||||
"@babel/parser": "^7.24.4",
|
||||
"hermes-parser": "^0.25.1",
|
||||
"zod": "^3.22.4 || ^4.0.0",
|
||||
"zod-validation-error": "^3.0.3 || ^4.0.0"
|
||||
"zod": "^3.25.0 || ^4.0.0",
|
||||
"zod-validation-error": "^3.5.0 || ^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/eslint-parser": "^7.11.4",
|
||||
|
||||
@@ -171,7 +171,15 @@ function isUseEffectEventIdentifier(node: Node): boolean {
|
||||
return node.type === 'Identifier' && node.name === 'useEffectEvent';
|
||||
}
|
||||
|
||||
function useEffectEventError(fn: string, called: boolean): string {
|
||||
function useEffectEventError(fn: string | null, called: boolean): string {
|
||||
// no function identifier, i.e. it is not assigned to a variable
|
||||
if (fn === null) {
|
||||
return (
|
||||
`React Hook "useEffectEvent" can only be called at the top level of your component.` +
|
||||
` It cannot be passed down.`
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
`\`${fn}\` is a function created with React Hook "useEffectEvent", and can only be called from ` +
|
||||
'Effects and Effect Events in the same component.' +
|
||||
@@ -772,6 +780,22 @@ const rule = {
|
||||
// comparison later when we exit
|
||||
lastEffect = node;
|
||||
}
|
||||
|
||||
// Specifically disallow <Child onClick={useEffectEvent(...)} /> because this
|
||||
// case can't be caught by `recordAllUseEffectEventFunctions` as it isn't assigned to a variable
|
||||
if (
|
||||
isUseEffectEventIdentifier(nodeWithoutNamespace) &&
|
||||
node.parent?.type !== 'VariableDeclarator' &&
|
||||
// like in other hooks, calling useEffectEvent at component's top level without assignment is valid
|
||||
node.parent?.type !== 'ExpressionStatement'
|
||||
) {
|
||||
const message = useEffectEventError(null, false);
|
||||
|
||||
context.report({
|
||||
node,
|
||||
message,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
Identifier(node) {
|
||||
|
||||
@@ -1546,7 +1546,7 @@ describe('Store', () => {
|
||||
▸ <Wrapper>
|
||||
`);
|
||||
|
||||
const deepestedNodeID = agent.getIDForHostInstance(ref.current);
|
||||
const deepestedNodeID = agent.getIDForHostInstance(ref.current).id;
|
||||
|
||||
await act(() => store.toggleIsCollapsed(deepestedNodeID, false));
|
||||
expect(store).toMatchInlineSnapshot(`
|
||||
|
||||
100
packages/react-devtools-shared/src/backend/agent.js
vendored
100
packages/react-devtools-shared/src/backend/agent.js
vendored
@@ -455,7 +455,10 @@ export default class Agent extends EventEmitter<{
|
||||
return renderer.getInstanceAndStyle(id);
|
||||
}
|
||||
|
||||
getIDForHostInstance(target: HostInstance): number | null {
|
||||
getIDForHostInstance(
|
||||
target: HostInstance,
|
||||
onlySuspenseNodes?: boolean,
|
||||
): null | {id: number, rendererID: number} {
|
||||
if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
|
||||
// In React Native or non-DOM we simply pick any renderer that has a match.
|
||||
for (const rendererID in this._rendererInterfaces) {
|
||||
@@ -463,9 +466,14 @@ export default class Agent extends EventEmitter<{
|
||||
(rendererID: any)
|
||||
]: any): RendererInterface);
|
||||
try {
|
||||
const match = renderer.getElementIDForHostInstance(target);
|
||||
if (match != null) {
|
||||
return match;
|
||||
const id = onlySuspenseNodes
|
||||
? renderer.getSuspenseNodeIDForHostInstance(target)
|
||||
: renderer.getElementIDForHostInstance(target);
|
||||
if (id !== null) {
|
||||
return {
|
||||
id: id,
|
||||
rendererID: +rendererID,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Some old React versions might throw if they can't find a match.
|
||||
@@ -478,6 +486,7 @@ export default class Agent extends EventEmitter<{
|
||||
// that is registered if there isn't an exact match.
|
||||
let bestMatch: null | Element = null;
|
||||
let bestRenderer: null | RendererInterface = null;
|
||||
let bestRendererID: number = 0;
|
||||
// Find the nearest ancestor which is mounted by a React.
|
||||
for (const rendererID in this._rendererInterfaces) {
|
||||
const renderer = ((this._rendererInterfaces[
|
||||
@@ -491,6 +500,7 @@ export default class Agent extends EventEmitter<{
|
||||
// Exact match we can exit early.
|
||||
bestMatch = nearestNode;
|
||||
bestRenderer = renderer;
|
||||
bestRendererID = +rendererID;
|
||||
break;
|
||||
}
|
||||
if (bestMatch === null || bestMatch.contains(nearestNode)) {
|
||||
@@ -498,12 +508,21 @@ export default class Agent extends EventEmitter<{
|
||||
// so the new match is a deeper and therefore better match.
|
||||
bestMatch = nearestNode;
|
||||
bestRenderer = renderer;
|
||||
bestRendererID = +rendererID;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestRenderer != null && bestMatch != null) {
|
||||
try {
|
||||
return bestRenderer.getElementIDForHostInstance(bestMatch);
|
||||
const id = onlySuspenseNodes
|
||||
? bestRenderer.getSuspenseNodeIDForHostInstance(bestMatch)
|
||||
: bestRenderer.getElementIDForHostInstance(bestMatch);
|
||||
if (id !== null) {
|
||||
return {
|
||||
id,
|
||||
rendererID: bestRendererID,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Some old React versions might throw if they can't find a match.
|
||||
// If so we should ignore it...
|
||||
@@ -514,65 +533,14 @@ export default class Agent extends EventEmitter<{
|
||||
}
|
||||
|
||||
getComponentNameForHostInstance(target: HostInstance): string | null {
|
||||
// We duplicate this code from getIDForHostInstance to avoid an object allocation.
|
||||
if (isReactNativeEnvironment() || typeof target.nodeType !== 'number') {
|
||||
// In React Native or non-DOM we simply pick any renderer that has a match.
|
||||
for (const rendererID in this._rendererInterfaces) {
|
||||
const renderer = ((this._rendererInterfaces[
|
||||
(rendererID: any)
|
||||
]: any): RendererInterface);
|
||||
try {
|
||||
const id = renderer.getElementIDForHostInstance(target);
|
||||
if (id) {
|
||||
return renderer.getDisplayNameForElementID(id);
|
||||
}
|
||||
} catch (error) {
|
||||
// Some old React versions might throw if they can't find a match.
|
||||
// If so we should ignore it...
|
||||
}
|
||||
}
|
||||
return null;
|
||||
} else {
|
||||
// In the DOM we use a smarter mechanism to find the deepest a DOM node
|
||||
// that is registered if there isn't an exact match.
|
||||
let bestMatch: null | Element = null;
|
||||
let bestRenderer: null | RendererInterface = null;
|
||||
// Find the nearest ancestor which is mounted by a React.
|
||||
for (const rendererID in this._rendererInterfaces) {
|
||||
const renderer = ((this._rendererInterfaces[
|
||||
(rendererID: any)
|
||||
]: any): RendererInterface);
|
||||
const nearestNode: null | Element = renderer.getNearestMountedDOMNode(
|
||||
(target: any),
|
||||
);
|
||||
if (nearestNode !== null) {
|
||||
if (nearestNode === target) {
|
||||
// Exact match we can exit early.
|
||||
bestMatch = nearestNode;
|
||||
bestRenderer = renderer;
|
||||
break;
|
||||
}
|
||||
if (bestMatch === null || bestMatch.contains(nearestNode)) {
|
||||
// If this is the first match or the previous match contains the new match,
|
||||
// so the new match is a deeper and therefore better match.
|
||||
bestMatch = nearestNode;
|
||||
bestRenderer = renderer;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (bestRenderer != null && bestMatch != null) {
|
||||
try {
|
||||
const id = bestRenderer.getElementIDForHostInstance(bestMatch);
|
||||
if (id) {
|
||||
return bestRenderer.getDisplayNameForElementID(id);
|
||||
}
|
||||
} catch (error) {
|
||||
// Some old React versions might throw if they can't find a match.
|
||||
// If so we should ignore it...
|
||||
}
|
||||
}
|
||||
return null;
|
||||
const match = this.getIDForHostInstance(target);
|
||||
if (match !== null) {
|
||||
const renderer = ((this._rendererInterfaces[
|
||||
(match.rendererID: any)
|
||||
]: any): RendererInterface);
|
||||
return renderer.getDisplayNameForElementID(match.id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
getBackendVersion: () => void = () => {
|
||||
@@ -971,9 +939,9 @@ export default class Agent extends EventEmitter<{
|
||||
};
|
||||
|
||||
selectNode(target: HostInstance): void {
|
||||
const id = this.getIDForHostInstance(target);
|
||||
if (id !== null) {
|
||||
this._bridge.send('selectElement', id);
|
||||
const match = this.getIDForHostInstance(target);
|
||||
if (match !== null) {
|
||||
this._bridge.send('selectElement', match.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2139,8 +2139,8 @@ export function attach(
|
||||
// Regular operations
|
||||
pendingOperations.length +
|
||||
// All suspender changes are batched in a single message.
|
||||
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders]]
|
||||
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 2 : 0),
|
||||
// [SUSPENSE_TREE_OPERATION_SUSPENDERS, suspenderChangesLength, ...[id, hasUniqueSuspenders, isSuspended]]
|
||||
(numSuspenderChanges > 0 ? 2 + numSuspenderChanges * 3 : 0),
|
||||
);
|
||||
|
||||
// Identify which renderer this update is coming from.
|
||||
@@ -2225,6 +2225,14 @@ export function attach(
|
||||
}
|
||||
operations[i++] = fiberIdWithChanges;
|
||||
operations[i++] = suspense.hasUniqueSuspenders ? 1 : 0;
|
||||
const instance = suspense.instance;
|
||||
const isSuspended =
|
||||
// TODO: Track if other SuspenseNode like SuspenseList rows are suspended.
|
||||
(instance.kind === FIBER_INSTANCE ||
|
||||
instance.kind === FILTERED_FIBER_INSTANCE) &&
|
||||
instance.data.tag === SuspenseComponent &&
|
||||
instance.data.memoizedState !== null;
|
||||
operations[i++] = isSuspended ? 1 : 0;
|
||||
operations[i++] = suspense.environments.size;
|
||||
suspense.environments.forEach((count, env) => {
|
||||
operations[i++] = getStringID(env);
|
||||
@@ -2251,7 +2259,10 @@ export function attach(
|
||||
if (typeof instance !== 'object' || instance === null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof instance.getClientRects === 'function') {
|
||||
if (
|
||||
typeof instance.getClientRects === 'function' ||
|
||||
instance.nodeType === 3
|
||||
) {
|
||||
// DOM
|
||||
const doc = instance.ownerDocument;
|
||||
if (instance === doc.documentElement) {
|
||||
@@ -2273,7 +2284,21 @@ export function attach(
|
||||
const win = doc && doc.defaultView;
|
||||
const scrollX = win ? win.scrollX : 0;
|
||||
const scrollY = win ? win.scrollY : 0;
|
||||
const rects = instance.getClientRects();
|
||||
let rects;
|
||||
if (instance.nodeType === 3) {
|
||||
// Text nodes cannot be measured directly but we can measure a Range.
|
||||
if (typeof doc.createRange !== 'function') {
|
||||
return null;
|
||||
}
|
||||
const range = doc.createRange();
|
||||
if (typeof range.getClientRects !== 'function') {
|
||||
return null;
|
||||
}
|
||||
range.selectNodeContents(instance);
|
||||
rects = range.getClientRects();
|
||||
} else {
|
||||
rects = instance.getClientRects();
|
||||
}
|
||||
for (let i = 0; i < rects.length; i++) {
|
||||
const rect = rects[i];
|
||||
result.push({
|
||||
@@ -2640,9 +2665,15 @@ export function attach(
|
||||
const fiber = fiberInstance.data;
|
||||
const props = fiber.memoizedProps;
|
||||
// TODO: Compute a fallback name based on Owner, key etc.
|
||||
const name = props === null ? null : props.name || null;
|
||||
const name =
|
||||
fiber.tag !== SuspenseComponent || props === null
|
||||
? null
|
||||
: props.name || null;
|
||||
const nameStringID = getStringID(name);
|
||||
|
||||
const isSuspended =
|
||||
fiber.tag === SuspenseComponent && fiber.memoizedState !== null;
|
||||
|
||||
if (__DEBUG__) {
|
||||
console.log('recordSuspenseMount()', suspenseInstance);
|
||||
}
|
||||
@@ -2653,6 +2684,7 @@ export function attach(
|
||||
pushOperation(fiberID);
|
||||
pushOperation(parentID);
|
||||
pushOperation(nameStringID);
|
||||
pushOperation(isSuspended ? 1 : 0);
|
||||
|
||||
const rects = suspenseInstance.rects;
|
||||
if (rects === null) {
|
||||
@@ -2661,10 +2693,10 @@ export function attach(
|
||||
pushOperation(rects.length);
|
||||
for (let i = 0; i < rects.length; ++i) {
|
||||
const rect = rects[i];
|
||||
pushOperation(Math.round(rect.x));
|
||||
pushOperation(Math.round(rect.y));
|
||||
pushOperation(Math.round(rect.width));
|
||||
pushOperation(Math.round(rect.height));
|
||||
pushOperation(Math.round(rect.x * 1000));
|
||||
pushOperation(Math.round(rect.y * 1000));
|
||||
pushOperation(Math.round(rect.width * 1000));
|
||||
pushOperation(Math.round(rect.height * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2733,10 +2765,10 @@ export function attach(
|
||||
pushOperation(rects.length);
|
||||
for (let i = 0; i < rects.length; ++i) {
|
||||
const rect = rects[i];
|
||||
pushOperation(Math.round(rect.x));
|
||||
pushOperation(Math.round(rect.y));
|
||||
pushOperation(Math.round(rect.width));
|
||||
pushOperation(Math.round(rect.height));
|
||||
pushOperation(Math.round(rect.x * 1000));
|
||||
pushOperation(Math.round(rect.y * 1000));
|
||||
pushOperation(Math.round(rect.width * 1000));
|
||||
pushOperation(Math.round(rect.height * 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3262,14 +3294,22 @@ export function attach(
|
||||
// We don't update rects inside disconnected subtrees.
|
||||
return;
|
||||
}
|
||||
const nextRects = measureInstance(suspenseNode.instance);
|
||||
const prevRects = suspenseNode.rects;
|
||||
if (areEqualRects(prevRects, nextRects)) {
|
||||
return; // Unchanged
|
||||
const instance = suspenseNode.instance;
|
||||
|
||||
const isSuspendedSuspenseComponent =
|
||||
(instance.kind === FIBER_INSTANCE ||
|
||||
instance.kind === FILTERED_FIBER_INSTANCE) &&
|
||||
instance.data.tag === SuspenseComponent &&
|
||||
instance.data.memoizedState !== null;
|
||||
if (isSuspendedSuspenseComponent) {
|
||||
// This boundary itself was suspended and we don't measure those since that would measure
|
||||
// the fallback. We want to keep a ghost of the rectangle of the content not currently shown.
|
||||
return;
|
||||
}
|
||||
// The rect has changed. While the bailed out root wasn't in a disconnected subtree,
|
||||
|
||||
// While this boundary wasn't suspended and the bailed out root and wasn't in a disconnected subtree,
|
||||
// it's possible that this node was in one. So we need to check if we're offscreen.
|
||||
let parent = suspenseNode.instance.parent;
|
||||
let parent = instance.parent;
|
||||
while (parent !== null) {
|
||||
if (
|
||||
(parent.kind === FIBER_INSTANCE ||
|
||||
@@ -3285,6 +3325,13 @@ export function attach(
|
||||
}
|
||||
parent = parent.parent;
|
||||
}
|
||||
|
||||
const nextRects = measureInstance(suspenseNode.instance);
|
||||
const prevRects = suspenseNode.rects;
|
||||
if (areEqualRects(prevRects, nextRects)) {
|
||||
return; // Unchanged
|
||||
}
|
||||
|
||||
// We changed inside a visible tree.
|
||||
// Since this boundary changed, it's possible it also affected its children so lets
|
||||
// measure them as well.
|
||||
@@ -5006,15 +5053,24 @@ export function attach(
|
||||
const nextIsSuspended = isSuspendedOffscreen(nextFiber);
|
||||
|
||||
if (isLegacySuspense) {
|
||||
if (
|
||||
fiberInstance !== null &&
|
||||
fiberInstance.suspenseNode !== null &&
|
||||
(prevFiber.stateNode === null) !== (nextFiber.stateNode === null)
|
||||
) {
|
||||
trackThrownPromisesFromRetryCache(
|
||||
fiberInstance.suspenseNode,
|
||||
nextFiber.stateNode,
|
||||
);
|
||||
if (fiberInstance !== null && fiberInstance.suspenseNode !== null) {
|
||||
const suspenseNode = fiberInstance.suspenseNode;
|
||||
if (
|
||||
(prevFiber.stateNode === null) !==
|
||||
(nextFiber.stateNode === null)
|
||||
) {
|
||||
trackThrownPromisesFromRetryCache(
|
||||
suspenseNode,
|
||||
nextFiber.stateNode,
|
||||
);
|
||||
}
|
||||
if (
|
||||
(prevFiber.memoizedState === null) !==
|
||||
(nextFiber.memoizedState === null)
|
||||
) {
|
||||
// Toggle suspended state.
|
||||
recordSuspenseSuspenders(suspenseNode);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The logic below is inspired by the code paths in updateSuspenseComponent()
|
||||
@@ -5162,6 +5218,14 @@ export function attach(
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(prevFiber.memoizedState === null) !==
|
||||
(nextFiber.memoizedState === null)
|
||||
) {
|
||||
// Toggle suspended state.
|
||||
recordSuspenseSuspenders(suspenseNode);
|
||||
}
|
||||
|
||||
shouldMeasureSuspenseNode = false;
|
||||
updateFlags |= updateSuspenseChildrenRecursively(
|
||||
nextContentFiber,
|
||||
@@ -5188,6 +5252,8 @@ export function attach(
|
||||
}
|
||||
|
||||
trackThrownPromisesFromRetryCache(suspenseNode, nextFiber.stateNode);
|
||||
// Toggle suspended state.
|
||||
recordSuspenseSuspenders(suspenseNode);
|
||||
|
||||
mountSuspenseChildrenRecursively(
|
||||
nextContentFiber,
|
||||
@@ -5727,7 +5793,28 @@ export function attach(
|
||||
return null;
|
||||
}
|
||||
if (devtoolsInstance.kind === FIBER_INSTANCE) {
|
||||
return getDisplayNameForFiber(devtoolsInstance.data);
|
||||
const fiber = devtoolsInstance.data;
|
||||
if (fiber.tag === HostRoot) {
|
||||
// The only reason you'd inspect a HostRoot is to show it as a SuspenseNode.
|
||||
return 'Initial Paint';
|
||||
}
|
||||
if (fiber.tag === SuspenseComponent || fiber.tag === ActivityComponent) {
|
||||
// For Suspense and Activity components, we can show a better name
|
||||
// by using the name prop or their owner.
|
||||
const props = fiber.memoizedProps;
|
||||
if (props.name != null) {
|
||||
return props.name;
|
||||
}
|
||||
const owner = getUnfilteredOwner(fiber);
|
||||
if (owner != null) {
|
||||
if (typeof owner.tag === 'number') {
|
||||
return getDisplayNameForFiber((owner: any));
|
||||
} else {
|
||||
return owner.name || '';
|
||||
}
|
||||
}
|
||||
}
|
||||
return getDisplayNameForFiber(fiber);
|
||||
} else {
|
||||
return devtoolsInstance.data.name || '';
|
||||
}
|
||||
@@ -5768,6 +5855,28 @@ export function attach(
|
||||
return null;
|
||||
}
|
||||
|
||||
function getSuspenseNodeIDForHostInstance(
|
||||
publicInstance: HostInstance,
|
||||
): number | null {
|
||||
const instance = publicInstanceToDevToolsInstanceMap.get(publicInstance);
|
||||
if (instance !== undefined) {
|
||||
// Pick nearest unfiltered SuspenseNode instance.
|
||||
let suspenseInstance = instance;
|
||||
while (
|
||||
suspenseInstance.suspenseNode === null ||
|
||||
suspenseInstance.kind === FILTERED_FIBER_INSTANCE
|
||||
) {
|
||||
if (suspenseInstance.parent === null) {
|
||||
// We shouldn't get here since we'll always have a suspenseNode at the root.
|
||||
return null;
|
||||
}
|
||||
suspenseInstance = suspenseInstance.parent;
|
||||
}
|
||||
return suspenseInstance.id;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getElementAttributeByPath(
|
||||
id: number,
|
||||
path: Array<string | number>,
|
||||
@@ -8564,6 +8673,7 @@ export function attach(
|
||||
getDisplayNameForElementID,
|
||||
getNearestMountedDOMNode,
|
||||
getElementIDForHostInstance,
|
||||
getSuspenseNodeIDForHostInstance,
|
||||
getInstanceAndStyle,
|
||||
getOwnersList,
|
||||
getPathForElement,
|
||||
|
||||
@@ -169,6 +169,9 @@ export function attach(
|
||||
getElementIDForHostInstance() {
|
||||
return null;
|
||||
},
|
||||
getSuspenseNodeIDForHostInstance() {
|
||||
return null;
|
||||
},
|
||||
getInstanceAndStyle() {
|
||||
return {
|
||||
instance: null,
|
||||
|
||||
@@ -417,6 +417,7 @@ export function attach(
|
||||
pushOperation(id);
|
||||
pushOperation(parentID);
|
||||
pushOperation(getStringID(null)); // name
|
||||
pushOperation(0); // isSuspended
|
||||
// TODO: Measure rect of root
|
||||
pushOperation(-1);
|
||||
} else {
|
||||
@@ -1268,6 +1269,9 @@ export function attach(
|
||||
getDisplayNameForElementID,
|
||||
getNearestMountedDOMNode,
|
||||
getElementIDForHostInstance,
|
||||
getSuspenseNodeIDForHostInstance(id: number): null {
|
||||
return null;
|
||||
},
|
||||
getInstanceAndStyle,
|
||||
findHostInstancesForElementID: (id: number) => {
|
||||
const hostInstance = findHostInstanceForInternalID(id);
|
||||
|
||||
@@ -427,6 +427,7 @@ export type RendererInterface = {
|
||||
getComponentStack?: GetComponentStack,
|
||||
getNearestMountedDOMNode: (component: Element) => Element | null,
|
||||
getElementIDForHostInstance: GetElementIDForHostInstance,
|
||||
getSuspenseNodeIDForHostInstance: GetElementIDForHostInstance,
|
||||
getDisplayNameForElementID: GetDisplayNameForElementID,
|
||||
getInstanceAndStyle(id: number): InstanceAndStyle,
|
||||
getProfilingData(): ProfilingDataBackend,
|
||||
|
||||
@@ -187,10 +187,13 @@ export default class Overlay {
|
||||
}
|
||||
}
|
||||
|
||||
inspect(nodes: $ReadOnlyArray<HTMLElement>, name?: ?string) {
|
||||
inspect(nodes: $ReadOnlyArray<HTMLElement | Text>, name?: ?string) {
|
||||
// We can't get the size of text nodes or comment nodes. React as of v15
|
||||
// heavily uses comment nodes to delimit text.
|
||||
const elements = nodes.filter(node => node.nodeType === Node.ELEMENT_NODE);
|
||||
// TODO: We actually can measure text nodes. We should.
|
||||
const elements: $ReadOnlyArray<HTMLElement> = (nodes.filter(
|
||||
node => node.nodeType === Node.ELEMENT_NODE,
|
||||
): any);
|
||||
|
||||
while (this.rects.length > elements.length) {
|
||||
const rect = this.rects.pop();
|
||||
|
||||
@@ -20,6 +20,7 @@ import type {RendererInterface} from '../../types';
|
||||
// That is done by the React Native Inspector component.
|
||||
|
||||
let iframesListeningTo: Set<HTMLIFrameElement> = new Set();
|
||||
let inspectOnlySuspenseNodes = false;
|
||||
|
||||
export default function setupHighlighter(
|
||||
bridge: BackendBridge,
|
||||
@@ -33,7 +34,8 @@ export default function setupHighlighter(
|
||||
bridge.addListener('startInspectingHost', startInspectingHost);
|
||||
bridge.addListener('stopInspectingHost', stopInspectingHost);
|
||||
|
||||
function startInspectingHost() {
|
||||
function startInspectingHost(onlySuspenseNodes: boolean) {
|
||||
inspectOnlySuspenseNodes = onlySuspenseNodes;
|
||||
registerListenersOnWindow(window);
|
||||
}
|
||||
|
||||
@@ -363,11 +365,37 @@ export default function setupHighlighter(
|
||||
}
|
||||
}
|
||||
|
||||
// Don't pass the name explicitly.
|
||||
// It will be inferred from DOM tag and Fiber owner.
|
||||
showOverlay([target], null, agent, false);
|
||||
|
||||
selectElementForNode(target);
|
||||
if (inspectOnlySuspenseNodes) {
|
||||
// For Suspense nodes we want to highlight not the actual target but the nodes
|
||||
// that are the root of the Suspense node.
|
||||
// TODO: Consider if we should just do the same for other elements because the
|
||||
// hovered node might just be one child of many in the Component.
|
||||
const match = agent.getIDForHostInstance(
|
||||
target,
|
||||
inspectOnlySuspenseNodes,
|
||||
);
|
||||
if (match !== null) {
|
||||
const renderer = agent.rendererInterfaces[match.rendererID];
|
||||
if (renderer == null) {
|
||||
console.warn(
|
||||
`Invalid renderer id "${match.rendererID}" for element "${match.id}"`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
highlightHostInstance({
|
||||
displayName: renderer.getDisplayNameForElementID(match.id),
|
||||
hideAfterTimeout: false,
|
||||
id: match.id,
|
||||
openBuiltinElementsPanel: false,
|
||||
rendererID: match.rendererID,
|
||||
scrollIntoView: false,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Don't pass the name explicitly.
|
||||
// It will be inferred from DOM tag and Fiber owner.
|
||||
showOverlay([target], null, agent, false);
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerUp(event: MouseEvent) {
|
||||
@@ -376,9 +404,9 @@ export default function setupHighlighter(
|
||||
}
|
||||
|
||||
const selectElementForNode = (node: HTMLElement) => {
|
||||
const id = agent.getIDForHostInstance(node);
|
||||
if (id !== null) {
|
||||
bridge.send('selectElement', id);
|
||||
const match = agent.getIDForHostInstance(node, inspectOnlySuspenseNodes);
|
||||
if (match !== null) {
|
||||
bridge.send('selectElement', match.id);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
23
packages/react-devtools-shared/src/bridge.js
vendored
23
packages/react-devtools-shared/src/bridge.js
vendored
@@ -217,10 +217,15 @@ export type BackendEvents = {
|
||||
selectElement: [number],
|
||||
shutdown: [],
|
||||
stopInspectingHost: [boolean],
|
||||
syncSelectionFromBuiltinElementsPanel: [],
|
||||
syncSelectionToBuiltinElementsPanel: [],
|
||||
unsupportedRendererVersion: [],
|
||||
|
||||
extensionComponentsPanelShown: [],
|
||||
extensionComponentsPanelHidden: [],
|
||||
|
||||
resumeElementPolling: [],
|
||||
pauseElementPolling: [],
|
||||
|
||||
// React Native style editor plug-in.
|
||||
isNativeStyleEditorSupported: [
|
||||
{isSupported: boolean, validAttributes: ?$ReadOnlyArray<string>},
|
||||
@@ -240,8 +245,6 @@ type FrontendEvents = {
|
||||
clearWarningsForElementID: [ElementAndRendererID],
|
||||
copyElementPath: [CopyElementPathParams],
|
||||
deletePath: [DeletePath],
|
||||
extensionComponentsPanelShown: [],
|
||||
extensionComponentsPanelHidden: [],
|
||||
getBackendVersion: [],
|
||||
getBridgeProtocol: [],
|
||||
getIfHasUnsupportedRendererVersion: [],
|
||||
@@ -263,9 +266,9 @@ type FrontendEvents = {
|
||||
savedPreferences: [SavedPreferencesParams],
|
||||
setTraceUpdatesEnabled: [boolean],
|
||||
shutdown: [],
|
||||
startInspectingHost: [],
|
||||
startInspectingHost: [boolean],
|
||||
startProfiling: [StartProfilingParams],
|
||||
stopInspectingHost: [boolean],
|
||||
stopInspectingHost: [],
|
||||
scrollToHostInstance: [ScrollToHostInstance],
|
||||
stopProfiling: [],
|
||||
storeAsGlobal: [StoreAsGlobalParams],
|
||||
@@ -275,6 +278,8 @@ type FrontendEvents = {
|
||||
viewAttributeSource: [ViewAttributeSourceParams],
|
||||
viewElementSource: [ElementAndRendererID],
|
||||
|
||||
syncSelectionFromBuiltinElementsPanel: [],
|
||||
|
||||
// React Native style editor plug-in.
|
||||
NativeStyleEditor_measure: [ElementAndRendererID],
|
||||
NativeStyleEditor_renameAttribute: [NativeStyleEditor_RenameAttributeParams],
|
||||
@@ -295,19 +300,13 @@ type FrontendEvents = {
|
||||
overrideProps: [OverrideValue],
|
||||
overrideState: [OverrideValue],
|
||||
|
||||
resumeElementPolling: [],
|
||||
pauseElementPolling: [],
|
||||
|
||||
getHookSettings: [],
|
||||
};
|
||||
|
||||
class Bridge<
|
||||
OutgoingEvents: Object,
|
||||
IncomingEvents: Object,
|
||||
> extends EventEmitter<{
|
||||
...IncomingEvents,
|
||||
...OutgoingEvents,
|
||||
}> {
|
||||
> extends EventEmitter<IncomingEvents> {
|
||||
_isShutdown: boolean = false;
|
||||
_messageQueue: Array<any> = [];
|
||||
_scheduledFlush: boolean = false;
|
||||
|
||||
@@ -1552,7 +1552,8 @@ export default class Store extends EventEmitter<{
|
||||
const id = operations[i + 1];
|
||||
const parentID = operations[i + 2];
|
||||
const nameStringID = operations[i + 3];
|
||||
const numRects = ((operations[i + 4]: any): number);
|
||||
const isSuspended = operations[i + 4] === 1;
|
||||
const numRects = ((operations[i + 5]: any): number);
|
||||
let name = stringTable[nameStringID];
|
||||
|
||||
if (this._idToSuspense.has(id)) {
|
||||
@@ -1579,17 +1580,17 @@ export default class Store extends EventEmitter<{
|
||||
}
|
||||
}
|
||||
|
||||
i += 5;
|
||||
i += 6;
|
||||
let rects: SuspenseNode['rects'];
|
||||
if (numRects === -1) {
|
||||
rects = null;
|
||||
} else {
|
||||
rects = [];
|
||||
for (let rectIndex = 0; rectIndex < numRects; rectIndex++) {
|
||||
const x = operations[i + 0];
|
||||
const y = operations[i + 1];
|
||||
const width = operations[i + 2];
|
||||
const height = operations[i + 3];
|
||||
const x = operations[i + 0] / 1000;
|
||||
const y = operations[i + 1] / 1000;
|
||||
const width = operations[i + 2] / 1000;
|
||||
const height = operations[i + 3] / 1000;
|
||||
rects.push({x, y, width, height});
|
||||
i += 4;
|
||||
}
|
||||
@@ -1625,6 +1626,7 @@ export default class Store extends EventEmitter<{
|
||||
name,
|
||||
rects,
|
||||
hasUniqueSuspenders: false,
|
||||
isSuspended: isSuspended,
|
||||
});
|
||||
|
||||
hasSuspenseTreeChanged = true;
|
||||
@@ -1761,10 +1763,10 @@ export default class Store extends EventEmitter<{
|
||||
} else {
|
||||
nextRects = [];
|
||||
for (let rectIndex = 0; rectIndex < numRects; rectIndex++) {
|
||||
const x = operations[i + 0];
|
||||
const y = operations[i + 1];
|
||||
const width = operations[i + 2];
|
||||
const height = operations[i + 3];
|
||||
const x = operations[i + 0] / 1000;
|
||||
const y = operations[i + 1] / 1000;
|
||||
const width = operations[i + 2] / 1000;
|
||||
const height = operations[i + 3] / 1000;
|
||||
|
||||
nextRects.push({x, y, width, height});
|
||||
|
||||
@@ -1801,6 +1803,7 @@ export default class Store extends EventEmitter<{
|
||||
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
|
||||
const id = operations[i++];
|
||||
const hasUniqueSuspenders = operations[i++] === 1;
|
||||
const isSuspended = operations[i++] === 1;
|
||||
const environmentNamesLength = operations[i++];
|
||||
const environmentNames = [];
|
||||
for (
|
||||
@@ -1832,6 +1835,7 @@ export default class Store extends EventEmitter<{
|
||||
}
|
||||
|
||||
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
|
||||
suspense.isSuspended = isSuspended;
|
||||
// TODO: Recompute the environment names.
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,11 @@ import Toggle from '../Toggle';
|
||||
import ButtonIcon from '../ButtonIcon';
|
||||
import {logEvent} from 'react-devtools-shared/src/Logger';
|
||||
|
||||
export default function InspectHostNodesToggle(): React.Node {
|
||||
export default function InspectHostNodesToggle({
|
||||
onlySuspenseNodes,
|
||||
}: {
|
||||
onlySuspenseNodes?: boolean,
|
||||
}): React.Node {
|
||||
const [isInspecting, setIsInspecting] = useState(false);
|
||||
const bridge = useContext(BridgeContext);
|
||||
|
||||
@@ -24,9 +28,9 @@ export default function InspectHostNodesToggle(): React.Node {
|
||||
|
||||
if (isChecked) {
|
||||
logEvent({event_name: 'inspect-element-button-clicked'});
|
||||
bridge.send('startInspectingHost');
|
||||
bridge.send('startInspectingHost', !!onlySuspenseNodes);
|
||||
} else {
|
||||
bridge.send('stopInspectingHost', false);
|
||||
bridge.send('stopInspectingHost');
|
||||
}
|
||||
},
|
||||
[bridge],
|
||||
|
||||
@@ -378,7 +378,8 @@ function updateTree(
|
||||
const fiberID = operations[i + 1];
|
||||
const parentID = operations[i + 2];
|
||||
const nameStringID = operations[i + 3];
|
||||
const numRects = operations[i + 4];
|
||||
const isSuspended = operations[i + 4];
|
||||
const numRects = operations[i + 5];
|
||||
const name = stringTable[nameStringID];
|
||||
|
||||
if (__DEBUG__) {
|
||||
@@ -388,16 +389,16 @@ function updateTree(
|
||||
} else {
|
||||
rects =
|
||||
'[' +
|
||||
operations.slice(i + 5, i + 5 + numRects * 4).join(',') +
|
||||
operations.slice(i + 6, i + 6 + numRects * 4).join(',') +
|
||||
']';
|
||||
}
|
||||
debug(
|
||||
'Add suspense',
|
||||
`node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID}`,
|
||||
`node ${fiberID} (name=${JSON.stringify(name)}, rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
|
||||
);
|
||||
}
|
||||
|
||||
i += 5 + (numRects === -1 ? 0 : numRects * 4);
|
||||
i += 6 + (numRects === -1 ? 0 : numRects * 4);
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -459,12 +460,13 @@ function updateTree(
|
||||
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
|
||||
const suspenseNodeId = operations[i++];
|
||||
const hasUniqueSuspenders = operations[i++] === 1;
|
||||
const isSuspended = operations[i++] === 1;
|
||||
const environmentNamesLength = operations[i++];
|
||||
i += environmentNamesLength;
|
||||
if (__DEBUG__) {
|
||||
debug(
|
||||
'Suspender changes',
|
||||
`Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
|
||||
`Suspense node ${suspenseNodeId} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,11 +19,6 @@
|
||||
|
||||
.SuspenseRectsBoundaryChildren {
|
||||
pointer-events: none;
|
||||
/**
|
||||
* So that the shadow of Boundaries within is clipped off.
|
||||
* Otherwise it would look like this boundary is further elevated.
|
||||
*/
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.SuspenseRectsScaledRect[data-visible='false'] > .SuspenseRectsBoundaryChildren {
|
||||
@@ -49,6 +44,10 @@
|
||||
outline-width: 0;
|
||||
}
|
||||
|
||||
.SuspenseRectsScaledRect[data-suspended='true'] {
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
/* highlight this boundary */
|
||||
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
|
||||
background-color: var(--color-background-hover);
|
||||
|
||||
@@ -35,11 +35,15 @@ function ScaledRect({
|
||||
className,
|
||||
rect,
|
||||
visible,
|
||||
suspended,
|
||||
adjust,
|
||||
...props
|
||||
}: {
|
||||
className: string,
|
||||
rect: Rect,
|
||||
visible: boolean,
|
||||
suspended: boolean,
|
||||
adjust?: boolean,
|
||||
...
|
||||
}): React$Node {
|
||||
const viewBox = useContext(ViewBox);
|
||||
@@ -53,9 +57,11 @@ function ScaledRect({
|
||||
{...props}
|
||||
className={styles.SuspenseRectsScaledRect + ' ' + className}
|
||||
data-visible={visible}
|
||||
data-suspended={suspended}
|
||||
style={{
|
||||
width,
|
||||
height,
|
||||
// Shrink one pixel so that the bottom outline will line up with the top outline of the next one.
|
||||
width: adjust ? 'calc(' + width + ' - 1px)' : width,
|
||||
height: adjust ? 'calc(' + height + ' - 1px)' : height,
|
||||
top: y,
|
||||
left: x,
|
||||
}}
|
||||
@@ -145,7 +151,8 @@ function SuspenseRects({
|
||||
<ScaledRect
|
||||
rect={boundingBox}
|
||||
className={styles.SuspenseRectsBoundary}
|
||||
visible={visible}>
|
||||
visible={visible}
|
||||
suspended={suspense.isSuspended}>
|
||||
<ViewBox.Provider value={boundingBox}>
|
||||
{visible &&
|
||||
suspense.rects !== null &&
|
||||
@@ -156,6 +163,7 @@ function SuspenseRects({
|
||||
className={styles.SuspenseRectsRect}
|
||||
rect={rect}
|
||||
data-highlighted={selected}
|
||||
adjust={true}
|
||||
onClick={handleClick}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
onPointerOver={handlePointerOver}
|
||||
|
||||
@@ -31,9 +31,9 @@ export default function SuspenseScrubber({
|
||||
max: number,
|
||||
value: number,
|
||||
highlight: number,
|
||||
onBlur: () => void,
|
||||
onBlur?: () => void,
|
||||
onChange: (index: number) => void,
|
||||
onFocus: () => void,
|
||||
onFocus?: () => void,
|
||||
onHoverSegment: (index: number) => void,
|
||||
onHoverLeave: () => void,
|
||||
}): React$Node {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
useLayoutEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
Fragment,
|
||||
} from 'react';
|
||||
|
||||
import {
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
localStorageSetItem,
|
||||
} from 'react-devtools-shared/src/storage';
|
||||
import ButtonIcon, {type IconType} from '../ButtonIcon';
|
||||
import InspectHostNodesToggle from '../Components/InspectHostNodesToggle';
|
||||
import InspectedElementErrorBoundary from '../Components/InspectedElementErrorBoundary';
|
||||
import InspectedElement from '../Components/InspectedElement';
|
||||
import portaledContent from '../portaledContent';
|
||||
@@ -85,7 +87,9 @@ function ToggleUniqueSuspenders() {
|
||||
<Toggle
|
||||
isChecked={uniqueSuspendersOnly}
|
||||
onChange={handleToggleUniqueSuspenders}
|
||||
title={'Only include boundaries with unique suspenders'}>
|
||||
title={
|
||||
'Filter Suspense which does not suspend, or if the parent also suspend on the same.'
|
||||
}>
|
||||
<ButtonIcon type={uniqueSuspendersOnly ? 'filter-on' : 'filter-off'} />
|
||||
</Toggle>
|
||||
);
|
||||
@@ -154,6 +158,7 @@ function ToggleInspectedElement({
|
||||
}
|
||||
|
||||
function SuspenseTab(_: {}) {
|
||||
const store = useContext(StoreContext);
|
||||
const {hideSettings} = useContext(OptionsContext);
|
||||
const [state, dispatch] = useReducer<LayoutState, null, LayoutAction>(
|
||||
layoutReducer,
|
||||
@@ -365,6 +370,12 @@ function SuspenseTab(_: {}) {
|
||||
) : (
|
||||
<ToggleTreeList dispatch={dispatch} state={state} />
|
||||
)}
|
||||
{store.supportsClickToInspect && (
|
||||
<Fragment>
|
||||
<InspectHostNodesToggle onlySuspenseNodes={true} />
|
||||
<div className={styles.VRule} />
|
||||
</Fragment>
|
||||
)}
|
||||
<div className={styles.SuspenseBreadcrumbs}>
|
||||
<SuspenseBreadcrumbs />
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@ import * as React from 'react';
|
||||
import {useContext, useEffect} from 'react';
|
||||
import {BridgeContext} from '../context';
|
||||
import {TreeDispatcherContext} from '../Components/TreeContext';
|
||||
import {useHighlightHostInstance, useScrollToHostInstance} from '../hooks';
|
||||
import {useScrollToHostInstance} from '../hooks';
|
||||
import {
|
||||
SuspenseTreeDispatcherContext,
|
||||
SuspenseTreeStateContext,
|
||||
@@ -25,8 +25,6 @@ function SuspenseTimelineInput() {
|
||||
const bridge = useContext(BridgeContext);
|
||||
const treeDispatch = useContext(TreeDispatcherContext);
|
||||
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
|
||||
const {highlightHostInstance, clearHighlightHostInstance} =
|
||||
useHighlightHostInstance();
|
||||
const scrollToHostInstance = useScrollToHostInstance();
|
||||
|
||||
const {timeline, timelineIndex, hoveredTimelineIndex, playing, autoScroll} =
|
||||
@@ -37,7 +35,6 @@ function SuspenseTimelineInput() {
|
||||
|
||||
function switchSuspenseNode(nextTimelineIndex: number) {
|
||||
const nextSelectedSuspenseID = timeline[nextTimelineIndex];
|
||||
highlightHostInstance(nextSelectedSuspenseID);
|
||||
treeDispatch({
|
||||
type: 'SELECT_ELEMENT_BY_ID',
|
||||
payload: nextSelectedSuspenseID,
|
||||
@@ -52,23 +49,14 @@ function SuspenseTimelineInput() {
|
||||
switchSuspenseNode(pendingTimelineIndex);
|
||||
}
|
||||
|
||||
function handleBlur() {
|
||||
clearHighlightHostInstance();
|
||||
}
|
||||
|
||||
function handleFocus() {
|
||||
switchSuspenseNode(timelineIndex);
|
||||
}
|
||||
|
||||
function handleHoverSegment(hoveredValue: number) {
|
||||
const suspenseID = timeline[hoveredValue];
|
||||
if (suspenseID === undefined) {
|
||||
throw new Error(
|
||||
`Suspense node not found for value ${hoveredValue} in timeline.`,
|
||||
);
|
||||
}
|
||||
highlightHostInstance(suspenseID);
|
||||
// TODO: Consider highlighting the rect instead.
|
||||
}
|
||||
function handleUnhoverSegment() {}
|
||||
|
||||
function skipPrevious() {
|
||||
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
|
||||
@@ -172,19 +160,16 @@ function SuspenseTimelineInput() {
|
||||
onClick={skipForward}>
|
||||
<ButtonIcon type={'skip-next'} />
|
||||
</Button>
|
||||
<div
|
||||
className={styles.SuspenseTimelineInput}
|
||||
title={timelineIndex + '/' + max}>
|
||||
<div className={styles.SuspenseTimelineInput}>
|
||||
<SuspenseScrubber
|
||||
min={min}
|
||||
max={max}
|
||||
value={timelineIndex}
|
||||
highlight={hoveredTimelineIndex}
|
||||
onBlur={handleBlur}
|
||||
onChange={handleChange}
|
||||
onFocus={handleFocus}
|
||||
onHoverSegment={handleHoverSegment}
|
||||
onHoverLeave={clearHighlightHostInstance}
|
||||
onHoverLeave={handleUnhoverSegment}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -205,6 +205,27 @@ export function pluralize(word: string): string {
|
||||
return word;
|
||||
}
|
||||
|
||||
// Bail out if it's already plural.
|
||||
switch (word) {
|
||||
case 'men':
|
||||
case 'women':
|
||||
case 'children':
|
||||
case 'feet':
|
||||
case 'teeth':
|
||||
case 'mice':
|
||||
case 'people':
|
||||
return word;
|
||||
}
|
||||
|
||||
if (
|
||||
/(ches|shes|ses|xes|zes)$/i.test(word) ||
|
||||
/[^s]ies$/i.test(word) ||
|
||||
/ves$/i.test(word) ||
|
||||
/[^s]s$/i.test(word)
|
||||
) {
|
||||
return word;
|
||||
}
|
||||
|
||||
switch (word) {
|
||||
case 'man':
|
||||
return 'men';
|
||||
|
||||
@@ -200,6 +200,7 @@ export type SuspenseNode = {
|
||||
name: string | null,
|
||||
rects: null | Array<Rect>,
|
||||
hasUniqueSuspenders: boolean,
|
||||
isSuspended: boolean,
|
||||
};
|
||||
|
||||
// Serialized version of ReactIOInfo
|
||||
|
||||
@@ -475,23 +475,25 @@ function loadSourceFiles(
|
||||
|
||||
const fetchPromise =
|
||||
dedupedFetchPromises.get(runtimeSourceURL) ||
|
||||
fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => {
|
||||
// TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps,
|
||||
// because then we need to parse the full source file as an AST.
|
||||
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
|
||||
throw Error('Source code too large to parse');
|
||||
}
|
||||
(runtimeSourceURL && !runtimeSourceURL.startsWith('<anonymous')
|
||||
? fetchFileFunction(runtimeSourceURL).then(runtimeSourceCode => {
|
||||
// TODO (named hooks) Re-think this; the main case where it matters is when there's no source-maps,
|
||||
// because then we need to parse the full source file as an AST.
|
||||
if (runtimeSourceCode.length > MAX_SOURCE_LENGTH) {
|
||||
throw Error('Source code too large to parse');
|
||||
}
|
||||
|
||||
if (__DEBUG__) {
|
||||
console.groupCollapsed(
|
||||
`loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
|
||||
);
|
||||
console.log(runtimeSourceCode);
|
||||
console.groupEnd();
|
||||
}
|
||||
if (__DEBUG__) {
|
||||
console.groupCollapsed(
|
||||
`loadSourceFiles() runtimeSourceURL "${runtimeSourceURL}"`,
|
||||
);
|
||||
console.log(runtimeSourceCode);
|
||||
console.groupEnd();
|
||||
}
|
||||
|
||||
return runtimeSourceCode;
|
||||
});
|
||||
return runtimeSourceCode;
|
||||
})
|
||||
: Promise.reject(new Error('Empty url')));
|
||||
dedupedFetchPromises.set(runtimeSourceURL, fetchPromise);
|
||||
|
||||
setterPromises.push(
|
||||
|
||||
@@ -52,6 +52,9 @@ export async function symbolicateSource(
|
||||
lineNumber: number, // 1-based
|
||||
columnNumber: number, // 1-based
|
||||
): Promise<SourceMappedLocation | null> {
|
||||
if (!sourceURL || sourceURL.startsWith('<anonymous')) {
|
||||
return null;
|
||||
}
|
||||
const resource = await fetchFileWithCaching(sourceURL).catch(() => null);
|
||||
if (resource == null) {
|
||||
return null;
|
||||
|
||||
10
packages/react-devtools-shared/src/utils.js
vendored
10
packages/react-devtools-shared/src/utils.js
vendored
@@ -340,9 +340,10 @@ export function printOperationsArray(operations: Array<number>) {
|
||||
const fiberID = operations[i + 1];
|
||||
const parentID = operations[i + 2];
|
||||
const nameStringID = operations[i + 3];
|
||||
const numRects = operations[i + 4];
|
||||
const isSuspended = operations[i + 4];
|
||||
const numRects = operations[i + 5];
|
||||
|
||||
i += 5;
|
||||
i += 6;
|
||||
|
||||
const name = stringTable[nameStringID];
|
||||
let rects: string;
|
||||
@@ -368,7 +369,7 @@ export function printOperationsArray(operations: Array<number>) {
|
||||
}
|
||||
|
||||
logs.push(
|
||||
`Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID}`,
|
||||
`Add suspense node ${fiberID} (${String(name)},rects={${rects}}) under ${parentID} suspended ${isSuspended}`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
@@ -431,10 +432,11 @@ export function printOperationsArray(operations: Array<number>) {
|
||||
for (let changeIndex = 0; changeIndex < changeLength; changeIndex++) {
|
||||
const id = operations[i++];
|
||||
const hasUniqueSuspenders = operations[i++] === 1;
|
||||
const isSuspended = operations[i++] === 1;
|
||||
const environmentNamesLength = operations[i++];
|
||||
i += environmentNamesLength;
|
||||
logs.push(
|
||||
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} with ${String(environmentNamesLength)} environments`,
|
||||
`Suspense node ${id} unique suspenders set to ${String(hasUniqueSuspenders)} is suspended set to ${String(isSuspended)} with ${String(environmentNamesLength)} environments`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ function getOwner() {
|
||||
return null;
|
||||
}
|
||||
|
||||
// v8 (Chromium, Node.js) defaults to 10
|
||||
// SpiderMonkey (Firefox) does not support Error.stackTraceLimit
|
||||
// JSC (Safari) defaults to 100
|
||||
// The lower the limit, the more likely we'll not reach react_stack_bottom_frame
|
||||
// The higher the limit, the slower Error() is when not inspecting with a debugger.
|
||||
// When inspecting with a debugger, Error.stackTraceLimit has no impact on Error() performance (in v8).
|
||||
const ownerStackTraceLimit = 10;
|
||||
|
||||
/** @noinline */
|
||||
function UnknownOwner() {
|
||||
/** @noinline */
|
||||
@@ -352,15 +360,24 @@ export function jsxProdSignatureRunningInDevWithDynamicChildren(
|
||||
const trackActualOwner =
|
||||
__DEV__ &&
|
||||
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
|
||||
let debugStackDEV = false;
|
||||
if (__DEV__) {
|
||||
if (trackActualOwner) {
|
||||
const previousStackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = ownerStackTraceLimit;
|
||||
debugStackDEV = Error('react-stack-top-frame');
|
||||
Error.stackTraceLimit = previousStackTraceLimit;
|
||||
} else {
|
||||
debugStackDEV = unknownOwnerDebugStack;
|
||||
}
|
||||
}
|
||||
|
||||
return jsxDEVImpl(
|
||||
type,
|
||||
config,
|
||||
maybeKey,
|
||||
isStaticChildren,
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? Error('react-stack-top-frame')
|
||||
: unknownOwnerDebugStack),
|
||||
debugStackDEV,
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? createTask(getTaskName(type))
|
||||
@@ -379,15 +396,23 @@ export function jsxProdSignatureRunningInDevWithStaticChildren(
|
||||
const trackActualOwner =
|
||||
__DEV__ &&
|
||||
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
|
||||
let debugStackDEV = false;
|
||||
if (__DEV__) {
|
||||
if (trackActualOwner) {
|
||||
const previousStackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = ownerStackTraceLimit;
|
||||
debugStackDEV = Error('react-stack-top-frame');
|
||||
Error.stackTraceLimit = previousStackTraceLimit;
|
||||
} else {
|
||||
debugStackDEV = unknownOwnerDebugStack;
|
||||
}
|
||||
}
|
||||
return jsxDEVImpl(
|
||||
type,
|
||||
config,
|
||||
maybeKey,
|
||||
isStaticChildren,
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? Error('react-stack-top-frame')
|
||||
: unknownOwnerDebugStack),
|
||||
debugStackDEV,
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? createTask(getTaskName(type))
|
||||
@@ -408,15 +433,23 @@ export function jsxDEV(type, config, maybeKey, isStaticChildren) {
|
||||
const trackActualOwner =
|
||||
__DEV__ &&
|
||||
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
|
||||
let debugStackDEV = false;
|
||||
if (__DEV__) {
|
||||
if (trackActualOwner) {
|
||||
const previousStackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = ownerStackTraceLimit;
|
||||
debugStackDEV = Error('react-stack-top-frame');
|
||||
Error.stackTraceLimit = previousStackTraceLimit;
|
||||
} else {
|
||||
debugStackDEV = unknownOwnerDebugStack;
|
||||
}
|
||||
}
|
||||
return jsxDEVImpl(
|
||||
type,
|
||||
config,
|
||||
maybeKey,
|
||||
isStaticChildren,
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? Error('react-stack-top-frame')
|
||||
: unknownOwnerDebugStack),
|
||||
debugStackDEV,
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? createTask(getTaskName(type))
|
||||
@@ -667,15 +700,23 @@ export function createElement(type, config, children) {
|
||||
const trackActualOwner =
|
||||
__DEV__ &&
|
||||
ReactSharedInternals.recentlyCreatedOwnerStacks++ < ownerStackLimit;
|
||||
let debugStackDEV = false;
|
||||
if (__DEV__) {
|
||||
if (trackActualOwner) {
|
||||
const previousStackTraceLimit = Error.stackTraceLimit;
|
||||
Error.stackTraceLimit = ownerStackTraceLimit;
|
||||
debugStackDEV = Error('react-stack-top-frame');
|
||||
Error.stackTraceLimit = previousStackTraceLimit;
|
||||
} else {
|
||||
debugStackDEV = unknownOwnerDebugStack;
|
||||
}
|
||||
}
|
||||
return ReactElement(
|
||||
type,
|
||||
key,
|
||||
props,
|
||||
getOwner(),
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? Error('react-stack-top-frame')
|
||||
: unknownOwnerDebugStack),
|
||||
debugStackDEV,
|
||||
__DEV__ &&
|
||||
(trackActualOwner
|
||||
? createTask(getTaskName(type))
|
||||
|
||||
@@ -1,3 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
'use strict';
|
||||
|
||||
const babel = require('@babel/register');
|
||||
|
||||
@@ -1255,7 +1255,9 @@ const bundles = [
|
||||
'@babel/core',
|
||||
'hermes-parser',
|
||||
'zod',
|
||||
'zod/v4',
|
||||
'zod-validation-error',
|
||||
'zod-validation-error/v4',
|
||||
'crypto',
|
||||
'util',
|
||||
],
|
||||
|
||||
10
yarn.lock
10
yarn.lock
@@ -18245,12 +18245,12 @@ zip-stream@^2.1.2:
|
||||
compress-commons "^2.1.1"
|
||||
readable-stream "^3.4.0"
|
||||
|
||||
"zod-validation-error@^3.0.3 || ^4.0.0":
|
||||
"zod-validation-error@^3.5.0 || ^4.0.0":
|
||||
version "4.0.2"
|
||||
resolved "https://registry.yarnpkg.com/zod-validation-error/-/zod-validation-error-4.0.2.tgz#bc605eba49ce0fcd598c127fee1c236be3f22918"
|
||||
integrity sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==
|
||||
|
||||
"zod@^3.22.4 || ^4.0.0":
|
||||
version "4.1.11"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.11.tgz#4aab62f76cfd45e6c6166519ba31b2ea019f75f5"
|
||||
integrity sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==
|
||||
"zod@^3.25.0 || ^4.0.0":
|
||||
version "4.1.12"
|
||||
resolved "https://registry.yarnpkg.com/zod/-/zod-4.1.12.tgz#64f1ea53d00eab91853195653b5af9eee68970f0"
|
||||
integrity sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==
|
||||
|
||||
Reference in New Issue
Block a user