Compare commits

..

2 Commits

Author SHA1 Message Date
Joe Savona
55bf051afd [compiler][wip] Fix for ValidatePreserveMemo edge case w multiple return in useMemo
Potential fix for #34750. With a useMemo with multiple returns we can sometimes end up thinking the FinishMemoize value wasn't memoized, even though it definitely is. But the same case works if you rewrite to have a let binding, assign to the let instead of returning, and then have a single return with the let — which is the exact shape we convert to when we inline the useMemo! The only difference is that when we inline, we end up with an extra LoadLocal. Adding an extra LoadLocal "fixes" the issue but this is definitely a hack.

However, along the way this helped me find that we also have a bug in PruneAlwaysInvalidatingScopes, where we were forgetting to set FinishMemoize instructions to pruned if their declaration always invalidates.

Putting up for feedback, not necessarily expecting to land as-is.
2025-10-16 13:08:23 -07:00
Joe Savona
2a18d35301 [compiler] Cleanup and enable validateNoVoidUseMemo
This is a great validation, so let's enable by default. Changes:
* Move the validation logic into ValidateUseMemo alongside the new check that the useMemo result is used
* Update the lint description
* Make the void memo errors lint-only, they don't require us to skip compilation (as evidenced by the fact that we've had this validation off)
2025-10-16 13:08:23 -07:00
55 changed files with 707 additions and 1700 deletions

View File

@@ -83,11 +83,21 @@ export type ExternalFunction = z.infer<typeof ExternalFunctionSchema>;
export const USE_FIRE_FUNCTION_NAME = 'useFire';
export const EMIT_FREEZE_GLOBAL_GATING = '__DEV__';
export const MacroSchema = z.string();
export const MacroMethodSchema = z.union([
z.object({type: z.literal('wildcard')}),
z.object({type: z.literal('name'), name: z.string()}),
]);
// Would like to change this to drop the string option, but breaks compatibility with existing configs
export const MacroSchema = z.union([
z.string(),
z.tuple([z.string(), z.array(MacroMethodSchema)]),
]);
export type CompilerMode = 'all_features' | 'no_inferred_memo';
export type Macro = z.infer<typeof MacroSchema>;
export type MacroMethod = z.infer<typeof MacroMethodSchema>;
const HookSchema = z.object({
/*

View File

@@ -988,7 +988,7 @@ export function createTemporaryPlace(
identifier: makeTemporaryIdentifier(env.nextIdentifierId, loc),
reactive: false,
effect: Effect.Unknown,
loc: GeneratedSource,
loc,
};
}

View File

@@ -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]!;
@@ -523,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);
}

View File

@@ -19,7 +19,6 @@ import {
Environment,
FunctionExpression,
GeneratedSource,
getHookKind,
HIRFunction,
Hole,
IdentifierId,
@@ -199,7 +198,6 @@ export function inferMutationAliasingEffects(
isFunctionExpression,
fn,
hoistedContextDeclarations,
findNonMutatedDestructureSpreads(fn),
);
let iterationCount = 0;
@@ -289,18 +287,15 @@ class Context {
isFuctionExpression: boolean;
fn: HIRFunction;
hoistedContextDeclarations: Map<DeclarationId, Place | null>;
nonMutatingSpreads: Set<IdentifierId>;
constructor(
isFunctionExpression: boolean,
fn: HIRFunction,
hoistedContextDeclarations: Map<DeclarationId, Place | null>,
nonMutatingSpreads: Set<IdentifierId>,
) {
this.isFuctionExpression = isFunctionExpression;
this.fn = fn;
this.hoistedContextDeclarations = hoistedContextDeclarations;
this.nonMutatingSpreads = nonMutatingSpreads;
}
cacheApplySignature(
@@ -327,161 +322,6 @@ class Context {
}
}
/**
* Finds objects created via ObjectPattern spread destructuring
* (`const {x, ...spread} = ...`) where a) the rvalue is known frozen and
* b) the spread value cannot possibly be directly mutated. The idea is that
* for this set of values, we can treat the spread object as frozen.
*
* The primary use case for this is props spreading:
*
* ```
* function Component({prop, ...otherProps}) {
* const transformedProp = transform(prop, otherProps.foo);
* // pass `otherProps` down:
* return <Foo {...otherProps} prop={transformedProp} />;
* }
* ```
*
* Here we know that since `otherProps` cannot be mutated, we don't have to treat
* it as mutable: `otherProps.foo` only reads a value that must be frozen, so it
* can be treated as frozen too.
*/
function findNonMutatedDestructureSpreads(fn: HIRFunction): Set<IdentifierId> {
const knownFrozen = new Set<IdentifierId>();
if (fn.fnType === 'Component') {
const [props] = fn.params;
if (props != null && props.kind === 'Identifier') {
knownFrozen.add(props.identifier.id);
}
} else {
for (const param of fn.params) {
if (param.kind === 'Identifier') {
knownFrozen.add(param.identifier.id);
}
}
}
// Map of temporaries to identifiers for spread objects
const candidateNonMutatingSpreads = new Map<IdentifierId, IdentifierId>();
for (const block of fn.body.blocks.values()) {
if (candidateNonMutatingSpreads.size !== 0) {
for (const phi of block.phis) {
for (const operand of phi.operands.values()) {
const spread = candidateNonMutatingSpreads.get(operand.identifier.id);
if (spread != null) {
candidateNonMutatingSpreads.delete(spread);
}
}
}
}
for (const instr of block.instructions) {
const {lvalue, value} = instr;
switch (value.kind) {
case 'Destructure': {
if (
!knownFrozen.has(value.value.identifier.id) ||
!(
value.lvalue.kind === InstructionKind.Let ||
value.lvalue.kind === InstructionKind.Const
) ||
value.lvalue.pattern.kind !== 'ObjectPattern'
) {
continue;
}
for (const item of value.lvalue.pattern.properties) {
if (item.kind !== 'Spread') {
continue;
}
candidateNonMutatingSpreads.set(
item.place.identifier.id,
item.place.identifier.id,
);
}
break;
}
case 'LoadLocal': {
const spread = candidateNonMutatingSpreads.get(
value.place.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.set(lvalue.identifier.id, spread);
}
break;
}
case 'StoreLocal': {
const spread = candidateNonMutatingSpreads.get(
value.value.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.set(lvalue.identifier.id, spread);
candidateNonMutatingSpreads.set(
value.lvalue.place.identifier.id,
spread,
);
}
break;
}
case 'JsxFragment':
case 'JsxExpression': {
// Passing objects created with spread to jsx can't mutate them
break;
}
case 'PropertyLoad': {
// Properties must be frozen since the original value was frozen
break;
}
case 'CallExpression':
case 'MethodCall': {
const callee =
value.kind === 'CallExpression' ? value.callee : value.property;
if (getHookKind(fn.env, callee.identifier) != null) {
// Hook calls have frozen arguments, and non-ref returns are frozen
if (!isRefOrRefValue(lvalue.identifier)) {
knownFrozen.add(lvalue.identifier.id);
}
} else {
// Non-hook calls check their operands, since they are potentially mutable
if (candidateNonMutatingSpreads.size !== 0) {
// Otherwise any reference to the spread object itself may mutate
for (const operand of eachInstructionValueOperand(value)) {
const spread = candidateNonMutatingSpreads.get(
operand.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.delete(spread);
}
}
}
}
break;
}
default: {
if (candidateNonMutatingSpreads.size !== 0) {
// Otherwise any reference to the spread object itself may mutate
for (const operand of eachInstructionValueOperand(value)) {
const spread = candidateNonMutatingSpreads.get(
operand.identifier.id,
);
if (spread != null) {
candidateNonMutatingSpreads.delete(spread);
}
}
}
}
}
}
}
const nonMutatingSpreads = new Set<IdentifierId>();
for (const [key, value] of candidateNonMutatingSpreads) {
if (key === value) {
nonMutatingSpreads.add(key);
}
}
return nonMutatingSpreads;
}
function inferParam(
param: Place | SpreadPattern,
initialState: InferenceState,
@@ -2214,9 +2054,7 @@ function computeSignatureForInstruction(
kind: 'Create',
into: place,
reason: ValueReason.Other,
value: context.nonMutatingSpreads.has(place.identifier.id)
? ValueKind.Frozen
: ValueKind.Mutable,
value: ValueKind.Mutable,
});
effects.push({
kind: 'Capture',

View File

@@ -7,6 +7,7 @@
import {
HIRFunction,
Identifier,
IdentifierId,
InstructionValue,
makeInstructionId,
@@ -14,35 +15,9 @@ import {
Place,
ReactiveScope,
} from '../HIR';
import {Macro} from '../HIR/Environment';
import {Macro, MacroMethod} from '../HIR/Environment';
import {eachInstructionValueOperand} from '../HIR/visitors';
/**
* Whether a macro requires its arguments to be transitively inlined (eg fbt)
* or just avoid having the top-level values be converted to variables (eg fbt.param)
*/
enum InlineLevel {
Transitive = 'Transitive',
Shallow = 'Shallow',
}
type MacroDefinition = {
level: InlineLevel;
properties: Map<string, MacroDefinition> | null;
};
const SHALLOW_MACRO: MacroDefinition = {
level: InlineLevel.Shallow,
properties: null,
};
const TRANSITIVE_MACRO: MacroDefinition = {
level: InlineLevel.Transitive,
properties: null,
};
const FBT_MACRO: MacroDefinition = {
level: InlineLevel.Transitive,
properties: new Map([['*', SHALLOW_MACRO]]),
};
FBT_MACRO.properties!.set('enum', FBT_MACRO);
import {Iterable_some} from '../Utils/utils';
/**
* This pass supports the `fbt` translation system (https://facebook.github.io/fbt/)
@@ -67,210 +42,250 @@ FBT_MACRO.properties!.set('enum', FBT_MACRO);
* ## User-defined macro-like function
*
* Users can also specify their own functions to be treated similarly to fbt via the
* `customMacros` environment configuration. By default, user-supplied custom macros
* have their arguments transitively inlined.
* `customMacros` environment configuration.
*/
export function memoizeFbtAndMacroOperandsInSameScope(
fn: HIRFunction,
): Set<IdentifierId> {
const macroKinds = new Map<Macro, MacroDefinition>([
...Array.from(FBT_TAGS.entries()),
...(fn.env.config.customMacros ?? []).map(
name => [name, TRANSITIVE_MACRO] as [Macro, MacroDefinition],
),
const fbtMacroTags = new Set<Macro>([
...Array.from(FBT_TAGS).map((tag): Macro => [tag, []]),
...(fn.env.config.customMacros ?? []),
]);
/**
* Forward data-flow analysis to identify all macro tags, including
* things like `fbt.foo.bar(...)`
* 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 macroTags = populateMacroTags(fn, macroKinds);
const macroTagsCalls: Set<IdentifierId> = new Set();
/**
* Reverse data-flow analysis to merge arguments to macro *invocations*
* based on the kind of the macro
* 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 = mergeMacroArguments(fn, macroTags, macroKinds);
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>>>();
return macroValues;
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 macroTagsCalls;
}
const FBT_TAGS: Map<string, MacroDefinition> = new Map([
['fbt', FBT_MACRO],
['fbt:param', SHALLOW_MACRO],
['fbt:enum', FBT_MACRO],
['fbt:plural', SHALLOW_MACRO],
['fbs', FBT_MACRO],
['fbs:param', SHALLOW_MACRO],
['fbs:enum', FBT_MACRO],
['fbs:plural', SHALLOW_MACRO],
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',
'fbs:param',
]);
function populateMacroTags(
function visit(
fn: HIRFunction,
macroKinds: Map<Macro, MacroDefinition>,
): Map<IdentifierId, MacroDefinition> {
const macroTags = new Map<IdentifierId, MacroDefinition>();
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const {lvalue, value} = instr;
switch (value.kind) {
case 'Primitive': {
if (typeof value.value === 'string') {
const macroDefinition = macroKinds.get(value.value);
if (macroDefinition != null) {
/*
* We don't distinguish between tag names and strings, so record
* all `fbt` string literals in case they are used as a jsx tag.
*/
macroTags.set(lvalue.identifier.id, macroDefinition);
fbtMacroTags: Set<Macro>,
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) {
continue;
}
if (
value.kind === 'Primitive' &&
typeof value.value === 'string' &&
matchesExactTag(value.value, fbtMacroTags)
) {
/*
* We don't distinguish between tag names and strings, so record
* all `fbt` string literals in case they are used as a jsx tag.
*/
macroTagsCalls.add(lvalue.identifier.id);
} else if (
value.kind === 'LoadGlobal' &&
matchesExactTag(value.binding.name, fbtMacroTags)
) {
// Record references to `fbt` as a global
macroTagsCalls.add(lvalue.identifier.id);
} else if (
value.kind === 'LoadGlobal' &&
matchTagRoot(value.binding.name, fbtMacroTags) !== null
) {
const methods = matchTagRoot(value.binding.name, fbtMacroTags)!;
macroMethods.set(lvalue.identifier.id, methods);
} else if (
value.kind === 'PropertyLoad' &&
macroMethods.has(value.object.identifier.id)
) {
const methods = macroMethods.get(value.object.identifier.id)!;
const newMethods = [];
for (const method of methods) {
if (
method.length > 0 &&
(method[0].type === 'wildcard' ||
(method[0].type === 'name' && method[0].name === value.property))
) {
if (method.length > 1) {
newMethods.push(method.slice(1));
} else {
macroTagsCalls.add(lvalue.identifier.id);
}
}
break;
}
case 'LoadGlobal': {
let macroDefinition = macroKinds.get(value.binding.name);
if (macroDefinition != null) {
macroTags.set(lvalue.identifier.id, macroDefinition);
if (newMethods.length > 0) {
macroMethods.set(lvalue.identifier.id, newMethods);
}
} else if (
value.kind === 'PropertyLoad' &&
macroTagsCalls.has(value.object.identifier.id)
) {
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);
}
break;
}
case 'PropertyLoad': {
if (typeof value.property === 'string') {
const macroDefinition = macroTags.get(value.object.identifier.id);
if (macroDefinition != null) {
const propertyDefinition =
macroDefinition.properties != null
? (macroDefinition.properties.get(value.property) ??
macroDefinition.properties.get('*'))
: null;
const propertyMacro = propertyDefinition ?? macroDefinition;
macroTags.set(lvalue.identifier.id, propertyMacro);
}
}
break;
}
macroValues.set(lvalue.identifier, macroOperands);
}
}
}
return macroTags;
}
function mergeMacroArguments(
fn: HIRFunction,
macroTags: Map<IdentifierId, MacroDefinition>,
macroKinds: Map<Macro, MacroDefinition>,
): Set<IdentifierId> {
const macroValues = new Set<IdentifierId>(macroTags.keys());
for (const block of Array.from(fn.body.blocks.values()).reverse()) {
for (let i = block.instructions.length - 1; i >= 0; i--) {
const instr = block.instructions[i]!;
const {lvalue, value} = instr;
switch (value.kind) {
case 'DeclareContext':
case 'DeclareLocal':
case 'Destructure':
case 'LoadContext':
case 'LoadLocal':
case 'PostfixUpdate':
case 'PrefixUpdate':
case 'StoreContext':
case 'StoreLocal': {
// Instructions that never need to be merged
break;
}
case 'CallExpression':
case 'MethodCall': {
const scope = lvalue.identifier.scope;
if (scope == null) {
continue;
}
const callee =
value.kind === 'CallExpression' ? value.callee : value.property;
const macroDefinition =
macroTags.get(callee.identifier.id) ??
macroTags.get(lvalue.identifier.id);
if (macroDefinition != null) {
visitOperands(
macroDefinition,
scope,
lvalue,
value,
macroValues,
macroTags,
);
}
break;
}
case 'JsxExpression': {
const scope = lvalue.identifier.scope;
if (scope == null) {
continue;
}
let macroDefinition;
if (value.tag.kind === 'Identifier') {
macroDefinition = macroTags.get(value.tag.identifier.id);
} else {
macroDefinition = macroKinds.get(value.tag.name);
}
macroDefinition ??= macroTags.get(lvalue.identifier.id);
if (macroDefinition != null) {
visitOperands(
macroDefinition,
scope,
lvalue,
value,
macroValues,
macroTags,
);
}
break;
}
default: {
const scope = lvalue.identifier.scope;
if (scope == null) {
continue;
}
const macroDefinition = macroTags.get(lvalue.identifier.id);
if (macroDefinition != null) {
visitOperands(
macroDefinition,
scope,
lvalue,
value,
macroValues,
macroTags,
);
}
break;
}
}
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'
? s === macro
: macro[1].length === 0 && macro[0] === s,
);
}
function matchTagRoot(
s: string,
tags: Set<Macro>,
): Array<Array<MacroMethod>> | null {
const methods: Array<Array<MacroMethod>> = [];
for (const macro of tags) {
if (typeof macro === 'string') {
continue;
}
for (const phi of block.phis) {
const scope = phi.place.identifier.scope;
if (scope == null) {
continue;
}
const macroDefinition = macroTags.get(phi.place.identifier.id);
if (
macroDefinition == null ||
macroDefinition.level === InlineLevel.Shallow
) {
continue;
}
macroValues.add(phi.place.identifier.id);
for (const operand of phi.operands.values()) {
operand.identifier.scope = scope;
expandFbtScopeRange(scope.range, operand.identifier.mutableRange);
macroTags.set(operand.identifier.id, macroDefinition);
macroValues.add(operand.identifier.id);
}
const [tag, rest] = macro;
if (tag === s && rest.length > 0) {
methods.push(rest);
}
}
return macroValues;
if (methods.length > 0) {
return methods;
} else {
return null;
}
}
function isFbtCallExpression(
macroTagsCalls: Set<IdentifierId>,
value: InstructionValue,
): boolean {
return (
(value.kind === 'CallExpression' &&
macroTagsCalls.has(value.callee.identifier.id)) ||
(value.kind === 'MethodCall' &&
macroTagsCalls.has(value.property.identifier.id))
);
}
function isFbtJsxExpression(
fbtMacroTags: Set<Macro>,
macroTagsCalls: Set<IdentifierId>,
value: InstructionValue,
): boolean {
return (
value.kind === 'JsxExpression' &&
((value.tag.kind === 'Identifier' &&
macroTagsCalls.has(value.tag.identifier.id)) ||
(value.tag.kind === 'BuiltinTag' &&
matchesExactTag(value.tag.name, fbtMacroTags)))
);
}
function isFbtJsxChild(
macroTagsCalls: Set<IdentifierId>,
lvalue: Place | null,
value: InstructionValue,
): boolean {
return (
(value.kind === 'JsxExpression' || value.kind === 'JsxFragment') &&
lvalue !== null &&
macroTagsCalls.has(lvalue.identifier.id)
);
}
function expandFbtScopeRange(
@@ -283,22 +298,3 @@ function expandFbtScopeRange(
);
}
}
function visitOperands(
macroDefinition: MacroDefinition,
scope: ReactiveScope,
lvalue: Place,
value: InstructionValue,
macroValues: Set<IdentifierId>,
macroTags: Map<IdentifierId, MacroDefinition>,
): void {
macroValues.add(lvalue.identifier.id);
for (const operand of eachInstructionValueOperand(value)) {
if (macroDefinition.level === InlineLevel.Transitive) {
operand.identifier.scope = scope;
expandFbtScopeRange(scope.range, operand.identifier.mutableRange);
macroTags.set(operand.identifier.id, macroDefinition);
}
macroValues.add(operand.identifier.id);
}
}

View File

@@ -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'};
}

View File

@@ -135,7 +135,16 @@ function parseConfigPragmaEnvironmentForTest(
} else if (val) {
const parsedVal = tryParseTestPragmaValue(val).unwrap();
if (key === 'customMacros' && typeof parsedVal === 'string') {
maybeConfig[key] = [parsedVal.split('.')[0]];
const valSplit = parsedVal.split('.');
const props = [];
for (const elt of valSplit.slice(1)) {
if (elt === '*') {
props.push({type: 'wildcard'});
} else if (elt.length > 0) {
props.push({type: 'name', name: elt});
}
}
maybeConfig[key] = [[valSplit[0], props]];
continue;
}
maybeConfig[key] = parsedVal;

View File

@@ -184,28 +184,25 @@ function validateNoContextVariableAssignment(
fn: HIRFunction,
errors: CompilerError,
): void {
const context = new Set(fn.context.map(place => place.identifier.id));
for (const block of fn.body.blocks.values()) {
for (const instr of block.instructions) {
const value = instr.value;
switch (value.kind) {
case 'StoreContext': {
if (context.has(value.lvalue.place.identifier.id)) {
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',
}),
);
}
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;
}
}

View File

@@ -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 |

View File

@@ -44,23 +44,15 @@ import fbt from "fbt";
import { identity } from "shared-runtime";
function Component(props) {
const $ = _c(5);
const $ = _c(3);
let t0;
if ($[0] !== props.count || $[1] !== props.option) {
let t1;
if ($[3] !== props.count) {
t1 = identity(props.count);
$[3] = props.count;
$[4] = t1;
} else {
t1 = $[4];
}
t0 = (
<span>
{fbt._(
{ "*": "{count} votes for {option}", _1: "1 vote for {option}" },
[
fbt._plural(t1, "count"),
fbt._plural(identity(props.count), "count"),
fbt._param(
"option",

View File

@@ -44,23 +44,15 @@ import fbt from "fbt";
import { identity } from "shared-runtime";
function Component(props) {
const $ = _c(5);
const $ = _c(3);
let t0;
if ($[0] !== props.count || $[1] !== props.option) {
let t1;
if ($[3] !== props.count) {
t1 = identity(props.count);
$[3] = props.count;
$[4] = t1;
} else {
t1 = $[4];
}
t0 = (
<span>
{fbt._(
{ "*": "{count} votes for {option}", _1: "1 vote for {option}" },
[
fbt._plural(t1, "count"),
fbt._plural(identity(props.count), "count"),
fbt._param(
"option",

View File

@@ -37,7 +37,7 @@ import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
function Foo(t0) {
const $ = _c(13);
const $ = _c(7);
const { name1, name2 } = t0;
let t1;
if ($[0] !== name1 || $[1] !== name2) {
@@ -50,34 +50,19 @@ function Foo(t0) {
t2 = $[4];
}
let t3;
if ($[5] !== name1 || $[6] !== t2) {
t3 = <span key={name1}>{t2}</span>;
$[5] = name1;
$[6] = t2;
$[7] = t3;
if ($[5] !== name2) {
t3 = <b>{name2}</b>;
$[5] = name2;
$[6] = t3;
} else {
t3 = $[7];
}
let t4;
if ($[8] !== name2) {
t4 = <b>{name2}</b>;
$[8] = name2;
$[9] = t4;
} else {
t4 = $[9];
}
let t5;
if ($[10] !== name2 || $[11] !== t4) {
t5 = <span key={name2}>{t4}</span>;
$[10] = name2;
$[11] = t4;
$[12] = t5;
} else {
t5 = $[12];
t3 = $[6];
}
t1 = fbt._(
"{user1} and {user2} accepted your PR!",
[fbt._param("user1", t3), fbt._param("user2", t5)],
[
fbt._param("user1", <span key={name1}>{t2}</span>),
fbt._param("user2", <span key={name2}>{t3}</span>),
],
{ hk: "2PxMie" },
);
$[0] = name1;

View File

@@ -29,24 +29,20 @@ import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
function Component(t0) {
const $ = _c(6);
const $ = _c(4);
const { name, data, icon } = t0;
let t1;
if ($[0] !== data || $[1] !== icon || $[2] !== name) {
let t2;
if ($[4] !== name) {
t2 = <Text type="h4">{name}</Text>;
$[4] = name;
$[5] = t2;
} else {
t2 = $[5];
}
t1 = (
<Text type="body4">
{fbt._(
"{item author}{icon}{=m2}",
[
fbt._param("item author", t2),
fbt._param(
"item author",
<Text type="h4">{name}</Text>,
),
fbt._param(
"icon",

View File

@@ -27,21 +27,16 @@ import fbt from "fbt";
import { identity } from "shared-runtime";
function Component(props) {
const $ = _c(4);
const $ = _c(2);
let t0;
if ($[0] !== props.text) {
const t1 = identity(props.text);
let t2;
if ($[2] !== t1) {
t2 = <>{t1}</>;
$[2] = t1;
$[3] = t2;
} else {
t2 = $[3];
}
t0 = (
<Foo
value={fbt._("{value}%", [fbt._param("value", t2)], { hk: "10F5Cc" })}
value={fbt._(
"{value}%",
[fbt._param("value", <>{identity(props.text)}</>)],
{ hk: "10F5Cc" },
)}
/>
);
$[0] = props.text;

View File

@@ -1,109 +0,0 @@
## Input
```javascript
// @flow
import {fbt} from 'fbt';
function Example({x}) {
// "Inner Text" needs to be visible to fbt: the <Bar> element cannot
// be memoized separately
return (
<fbt desc="Description">
Outer Text
<Foo key="b" x={x}>
<Bar key="a">Inner Text</Bar>
</Foo>
</fbt>
);
}
function Foo({x, children}) {
'use no memo';
return (
<>
<div>{x}</div>
<span>{children}</span>
</>
);
}
function Bar({children}) {
'use no memo';
return children;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{x: 'Hello'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { fbt } from "fbt";
function Example(t0) {
const $ = _c(2);
const { x } = t0;
let t1;
if ($[0] !== x) {
t1 = fbt._(
"Outer Text {=m1}",
[
fbt._implicitParam(
"=m1",
<Foo key="b" x={x}>
{fbt._(
"{=m1}",
[
fbt._implicitParam(
"=m1",
<Bar key="a">
{fbt._("Inner Text", null, { hk: "32YB0l" })}
</Bar>,
),
],
{ hk: "23dJsI" },
)}
</Foo>,
),
],
{ hk: "2RVA7V" },
);
$[0] = x;
$[1] = t1;
} else {
t1 = $[1];
}
return t1;
}
function Foo({ x, children }) {
"use no memo";
return (
<>
<div>{x}</div>
<span>{children}</span>
</>
);
}
function Bar({ children }) {
"use no memo";
return children;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{ x: "Hello" }],
};
```
### Eval output
(kind: ok) Outer Text <div>Hello</div><span>Inner Text</span>

View File

@@ -1,35 +0,0 @@
// @flow
import {fbt} from 'fbt';
function Example({x}) {
// "Inner Text" needs to be visible to fbt: the <Bar> element cannot
// be memoized separately
return (
<fbt desc="Description">
Outer Text
<Foo key="b" x={x}>
<Bar key="a">Inner Text</Bar>
</Foo>
</fbt>
);
}
function Foo({x, children}) {
'use no memo';
return (
<>
<div>{x}</div>
<span>{children}</span>
</>
);
}
function Bar({children}) {
'use no memo';
return children;
}
export const FIXTURE_ENTRYPOINT = {
fn: Example,
params: [{x: 'Hello'}],
};

View File

@@ -1,128 +0,0 @@
## Input
```javascript
import fbt from 'fbt';
import {Stringify, identity} 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 (
<div>
{fbt(
[
'Name: ',
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
identity(
fbt(
'(inner)' +
fbt.param('lastname', <Stringify key={1} name={lastname} />),
'Inner fbt value'
)
)
),
],
'Name'
)}
</div>
);
}
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, identity } 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(9);
const { firstname, lastname } = t0;
let t1;
if ($[0] !== firstname || $[1] !== lastname) {
let t2;
if ($[3] !== firstname) {
t2 = <Stringify key={0} name={firstname} />;
$[3] = firstname;
$[4] = t2;
} else {
t2 = $[4];
}
let t3;
if ($[5] !== lastname) {
t3 = <Stringify key={1} name={lastname} />;
$[5] = lastname;
$[6] = t3;
} else {
t3 = $[6];
}
t1 = fbt._(
"Name: {firstname}, {lastname}",
[
fbt._param("firstname", t2),
fbt._param(
"lastname",
identity(
fbt._("(inner){lastname}", [fbt._param("lastname", t3)], {
hk: "1Kdxyo",
}),
),
),
],
{ hk: "3AiIf8" },
);
$[0] = firstname;
$[1] = lastname;
$[2] = t1;
} else {
t1 = $[2];
}
let t2;
if ($[7] !== t1) {
t2 = <div>{t1}</div>;
$[7] = t1;
$[8] = t2;
} else {
t2 = $[8];
}
return t2;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ firstname: "first", lastname: "last" }],
sequentialRenders: [{ firstname: "first", lastname: "last" }],
};
```
### Eval output
(kind: ok) <div>Name: <div>{"name":"first"}</div>, (inner)<div>{"name":"last"}</div></div>

View File

@@ -1,42 +0,0 @@
import fbt from 'fbt';
import {Stringify, identity} 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 (
<div>
{fbt(
[
'Name: ',
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
identity(
fbt(
'(inner)' +
fbt.param('lastname', <Stringify key={1} name={lastname} />),
'Inner fbt value'
)
)
),
],
'Name'
)}
</div>
);
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{firstname: 'first', lastname: 'last'}],
sequentialRenders: [{firstname: 'first', lastname: 'last'}],
};

View File

@@ -3,7 +3,7 @@
```javascript
import fbt from 'fbt';
import {identity} from 'shared-runtime';
import {Stringify} from 'shared-runtime';
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
@@ -16,25 +16,22 @@ import {identity} from 'shared-runtime';
function Component({firstname, lastname}) {
'use memo';
return (
<div>
<Stringify>
{fbt(
[
'Name: ',
fbt.param('firstname', identity(firstname)),
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
identity(
fbt(
'(inner)' + fbt.param('lastname', identity(lastname)),
'Inner fbt value'
)
)
<Stringify key={0} name={lastname}>
{fbt('(inner fbt)', 'Inner fbt value')}
</Stringify>
),
],
'Name'
)}
</div>
</Stringify>
);
}
@@ -51,7 +48,7 @@ export const FIXTURE_ENTRYPOINT = {
```javascript
import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
import { identity } from "shared-runtime";
import { Stringify } from "shared-runtime";
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
@@ -73,24 +70,14 @@ function Component(t0) {
fbt._param(
"firstname",
identity(firstname),
<Stringify key={0} name={firstname} />,
),
fbt._param(
"lastname",
identity(
fbt._(
"(inner){lastname}",
[
fbt._param(
"lastname",
identity(lastname),
),
],
{ hk: "1Kdxyo" },
),
),
<Stringify key={0} name={lastname}>
{fbt._("(inner fbt)", null, { hk: "36qNwF" })}
</Stringify>,
),
],
{ hk: "3AiIf8" },
@@ -103,7 +90,7 @@ function Component(t0) {
}
let t2;
if ($[3] !== t1) {
t2 = <div>{t1}</div>;
t2 = <Stringify>{t1}</Stringify>;
$[3] = t1;
$[4] = t2;
} else {
@@ -121,4 +108,4 @@ export const FIXTURE_ENTRYPOINT = {
```
### Eval output
(kind: ok) <div>Name: first, (inner)last</div>
(kind: ok) <div>{"children":"Name: , "}</div>

View File

@@ -1,5 +1,5 @@
import fbt from 'fbt';
import {identity} from 'shared-runtime';
import {Stringify} from 'shared-runtime';
/**
* MemoizeFbtAndMacroOperands needs to account for nested fbt calls.
@@ -12,25 +12,22 @@ import {identity} from 'shared-runtime';
function Component({firstname, lastname}) {
'use memo';
return (
<div>
<Stringify>
{fbt(
[
'Name: ',
fbt.param('firstname', identity(firstname)),
fbt.param('firstname', <Stringify key={0} name={firstname} />),
', ',
fbt.param(
'lastname',
identity(
fbt(
'(inner)' + fbt.param('lastname', identity(lastname)),
'Inner fbt value'
)
)
<Stringify key={0} name={lastname}>
{fbt('(inner fbt)', 'Inner fbt value')}
</Stringify>
),
],
'Name'
)}
</div>
</Stringify>
);
}

View File

@@ -37,7 +37,7 @@ function Component(props) {
const $ = _c(16);
let t0;
if ($[0] !== props) {
t0 = idx(props, (_) => _.group.label);
t0 = idx(props, _temp);
$[0] = props;
$[1] = t0;
} else {
@@ -46,7 +46,7 @@ function Component(props) {
const groupName1 = t0;
let t1;
if ($[2] !== props) {
t1 = idx.a(props, (__0) => __0.group.label);
t1 = idx.a(props, _temp2);
$[2] = props;
$[3] = t1;
} else {
@@ -108,6 +108,12 @@ function Component(props) {
}
return t5;
}
function _temp2(__0) {
return __0.group.label;
}
function _temp(_) {
return _.group.label;
}
```

View File

@@ -31,7 +31,7 @@ function Component(props) {
const $ = _c(10);
let t0;
if ($[0] !== props) {
t0 = idx(props, (_) => _.group.label);
t0 = idx(props, _temp);
$[0] = props;
$[1] = t0;
} else {
@@ -74,6 +74,9 @@ function Component(props) {
}
return t3;
}
function _temp(_) {
return _.group.label;
}
```

View File

@@ -1,63 +0,0 @@
## Input
```javascript
import {identity, Stringify, useIdentity} from 'shared-runtime';
function Component(props) {
const {x, ...rest} = useIdentity(props);
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify, useIdentity } from "shared-runtime";
function Component(props) {
const $ = _c(6);
const t0 = useIdentity(props);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
const z = rest.z;
identity(z);
let t1;
if ($[3] !== x || $[4] !== z) {
t1 = <Stringify x={x} z={z} />;
$[3] = x;
$[4] = z;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"x":"Hello","z":"World"}</div>

View File

@@ -1,13 +0,0 @@
import {identity, Stringify, useIdentity} from 'shared-runtime';
function Component(props) {
const {x, ...rest} = useIdentity(props);
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -1,57 +0,0 @@
## Input
```javascript
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
return <Stringify {...rest} x={x} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify } from "shared-runtime";
function Component(t0) {
const $ = _c(6);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
let t1;
if ($[3] !== rest || $[4] !== x) {
t1 = <Stringify {...rest} x={x} />;
$[3] = rest;
$[4] = x;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"z":"World","x":"Hello"}</div>

View File

@@ -1,10 +0,0 @@
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
return <Stringify {...rest} x={x} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -1,63 +0,0 @@
## Input
```javascript
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const restAlias = rest;
const z = restAlias.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify } from "shared-runtime";
function Component(t0) {
const $ = _c(6);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
const restAlias = rest;
const z = restAlias.z;
identity(z);
let t1;
if ($[3] !== x || $[4] !== z) {
t1 = <Stringify x={x} z={z} />;
$[3] = x;
$[4] = z;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"x":"Hello","z":"World"}</div>

View File

@@ -1,13 +0,0 @@
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const restAlias = rest;
const z = restAlias.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -1,61 +0,0 @@
## Input
```javascript
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
import { identity, Stringify } from "shared-runtime";
function Component(t0) {
const $ = _c(6);
let rest;
let x;
if ($[0] !== t0) {
({ x, ...rest } = t0);
$[0] = t0;
$[1] = rest;
$[2] = x;
} else {
rest = $[1];
x = $[2];
}
const z = rest.z;
identity(z);
let t1;
if ($[3] !== x || $[4] !== z) {
t1 = <Stringify x={x} z={z} />;
$[3] = x;
$[4] = z;
$[5] = t1;
} else {
t1 = $[5];
}
return t1;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ x: "Hello", z: "World" }],
};
```
### Eval output
(kind: ok) <div>{"x":"Hello","z":"World"}</div>

View File

@@ -1,12 +0,0 @@
import {identity, Stringify} from 'shared-runtime';
function Component({x, ...rest}) {
const z = rest.z;
identity(z);
return <Stringify x={x} z={z} />;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{x: 'Hello', z: 'World'}],
};

View File

@@ -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 = {
```

View File

@@ -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: [{}],
};

View File

@@ -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) [[{}]]

View File

@@ -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: [{}],
};

View File

@@ -1,45 +0,0 @@
## Input
```javascript
// @flow
export hook useItemLanguage(items) {
return useMemo(() => {
let language: ?string = null;
items.forEach(item => {
if (item.language != null) {
language = item.language;
}
});
return language;
}, [items]);
}
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
export function useItemLanguage(items) {
const $ = _c(2);
let language;
if ($[0] !== items) {
language = null;
items.forEach((item) => {
if (item.language != null) {
language = item.language;
}
});
$[0] = items;
$[1] = language;
} else {
language = $[1];
}
return language;
}
```
### Eval output
(kind: exception) Fixture not implemented

View File

@@ -1,12 +0,0 @@
// @flow
export hook useItemLanguage(items) {
return useMemo(() => {
let language: ?string = null;
items.forEach(item => {
if (item.language != null) {
language = item.language;
}
});
return language;
}, [items]);
}

View File

@@ -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>

View File

@@ -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: [{}],
};

View File

@@ -136,6 +136,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-timeline-text-dim-color': '#ccc',
'--color-timeline-react-work-border': '#eeeeee',
'--color-timebar-background': '#f6f6f6',
'--color-timespan-background': '#62bc6a',
'--color-timespan-background-errored': '#d57066',
'--color-search-match': 'yellow',
'--color-search-match-current': '#f7923b',
'--color-selected-tree-highlight-active': 'rgba(0, 136, 250, 0.1)',
@@ -154,14 +156,6 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-warning-text-color': '#ffffff',
'--color-warning-text-color-inverted': '#fd4d69',
'--color-suspense-default': '#0088fa',
'--color-transition-default': '#6a51b2',
'--color-suspense-server': '#62bc6a',
'--color-transition-server': '#3f7844',
'--color-suspense-other': '#f3ce49',
'--color-transition-other': '#917b2c',
'--color-suspense-errored': '#d57066',
// The styles below should be kept in sync with 'root.css'
// They are repeated there because they're used by e.g. tooltips or context menus
// which get rendered outside of the DOM subtree (where normal theme/styles are written).
@@ -296,6 +290,8 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-timeline-text-dim-color': '#555b66',
'--color-timeline-react-work-border': '#3d424a',
'--color-timebar-background': '#1d2129',
'--color-timespan-background': '#62bc6a',
'--color-timespan-background-errored': '#d57066',
'--color-search-match': 'yellow',
'--color-search-match-current': '#f7923b',
'--color-selected-tree-highlight-active': 'rgba(23, 143, 185, 0.15)',
@@ -315,14 +311,6 @@ export const THEME_STYLES: {[style: Theme | DisplayDensity]: any, ...} = {
'--color-warning-text-color': '#ffffff',
'--color-warning-text-color-inverted': '#ee1638',
'--color-suspense-default': '#61dafb',
'--color-transition-default': '#6a51b2',
'--color-suspense-server': '#62bc6a',
'--color-transition-server': '#3f7844',
'--color-suspense-other': '#f3ce49',
'--color-transition-other': '#917b2c',
'--color-suspense-errored': '#d57066',
// The styles below should be kept in sync with 'root.css'
// They are repeated there because they're used by e.g. tooltips or context menus
// which get rendered outside of the DOM subtree (where normal theme/styles are written).

View File

@@ -34,7 +34,6 @@ import {
shallowDiffers,
utfDecodeStringWithRanges,
parseElementDisplayNameFromBackend,
unionOfTwoArrays,
} from '../utils';
import {localStorageGetItem, localStorageSetItem} from '../storage';
import {__DEBUG__} from '../constants';
@@ -52,7 +51,6 @@ import type {
ComponentFilter,
ElementType,
SuspenseNode,
SuspenseTimelineStep,
Rect,
} from 'react-devtools-shared/src/frontend/types';
import type {
@@ -897,10 +895,13 @@ export default class Store extends EventEmitter<{
*/
getSuspendableDocumentOrderSuspense(
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
): $ReadOnlyArray<SuspenseNode['id']> {
const roots = this.roots;
let rootStep: null | SuspenseTimelineStep = null;
if (roots.length === 0) {
return [];
}
const list: SuspenseNode['id'][] = [];
for (let i = 0; i < roots.length; i++) {
const rootID = roots[i];
const root = this.getElementByID(rootID);
@@ -911,76 +912,44 @@ export default class Store extends EventEmitter<{
const suspense = this.getSuspenseByID(rootID);
if (suspense !== null) {
const environments = suspense.environments;
const environmentName =
environments.length > 0
? environments[environments.length - 1]
: null;
if (rootStep === null) {
// Arbitrarily use the first root as the root step id.
rootStep = {
id: suspense.id,
environment: environmentName,
};
target.push(rootStep);
} else if (rootStep.environment === null) {
// If any root has an environment name, then let's use it.
rootStep.environment = environmentName;
if (list.length === 0) {
// start with an arbitrary root that will allow inspection of the Screen
list.push(suspense.id);
}
const stack = [suspense];
while (stack.length > 0) {
const current = stack.pop();
if (current === undefined) {
continue;
}
// Ignore any suspense boundaries that has no visual representation as this is not
// part of the visible loading sequence.
// TODO: Consider making visible meta data and other side-effects get virtual rects.
const hasRects =
current.rects !== null &&
current.rects.length > 0 &&
current.rects.some(isNonZeroRect);
if (
hasRects &&
(!uniqueSuspendersOnly || current.hasUniqueSuspenders) &&
// Roots are already included as part of the Screen
current.id !== rootID
) {
list.push(current.id);
}
// Add children in reverse order to maintain document order
for (let j = current.children.length - 1; j >= 0; j--) {
const childSuspense = this.getSuspenseByID(current.children[j]);
if (childSuspense !== null) {
stack.push(childSuspense);
}
}
}
this.pushTimelineStepsInDocumentOrder(
suspense.children,
target,
uniqueSuspendersOnly,
environments,
);
}
}
return target;
}
pushTimelineStepsInDocumentOrder(
children: Array<SuspenseNode['id']>,
target: Array<SuspenseTimelineStep>,
uniqueSuspendersOnly: boolean,
parentEnvironments: Array<string>,
): void {
for (let i = 0; i < children.length; i++) {
const child = this.getSuspenseByID(children[i]);
if (child === null) {
continue;
}
// Ignore any suspense boundaries that has no visual representation as this is not
// part of the visible loading sequence.
// TODO: Consider making visible meta data and other side-effects get virtual rects.
const hasRects =
child.rects !== null &&
child.rects.length > 0 &&
child.rects.some(isNonZeroRect);
const childEnvironments = child.environments;
// Since children are blocked on the parent, they're also blocked by the parent environments.
// Only if we discover a novel environment do we add that and it becomes the name we use.
const unionEnvironments = unionOfTwoArrays(
parentEnvironments,
childEnvironments,
);
const environmentName =
unionEnvironments.length > 0
? unionEnvironments[unionEnvironments.length - 1]
: null;
if (hasRects && (!uniqueSuspendersOnly || child.hasUniqueSuspenders)) {
target.push({
id: child.id,
environment: environmentName,
});
}
this.pushTimelineStepsInDocumentOrder(
child.children,
target,
uniqueSuspendersOnly,
unionEnvironments,
);
}
return list;
}
getRendererIDForElement(id: number): number | null {
@@ -1658,7 +1627,6 @@ export default class Store extends EventEmitter<{
rects,
hasUniqueSuspenders: false,
isSuspended: isSuspended,
environments: [],
});
hasSuspenseTreeChanged = true;
@@ -1844,10 +1812,7 @@ export default class Store extends EventEmitter<{
envIndex++
) {
const environmentNameStringID = operations[i++];
const environmentName = stringTable[environmentNameStringID];
if (environmentName != null) {
environmentNames.push(environmentName);
}
environmentNames.push(stringTable[environmentNameStringID]);
}
const suspense = this._idToSuspense.get(id);
@@ -1871,7 +1836,7 @@ export default class Store extends EventEmitter<{
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
suspense.isSuspended = isSuspended;
suspense.environments = environmentNames;
// TODO: Recompute the environment names.
}
hasSuspenseTreeChanged = true;

View File

@@ -128,13 +128,13 @@
.TimeBarSpan, .TimeBarSpanErrored {
position: absolute;
border-radius: 0.125rem;
background-color: var(--color-suspense);
background-color: var(--color-timespan-background);
width: 100%;
height: 100%;
}
.TimeBarSpanErrored {
background-color: var(--color-suspense-errored);
background-color: var(--color-timespan-background-errored);
}
.SmallHeader {

View File

@@ -22,8 +22,6 @@ import OwnerView from './OwnerView';
import {meta} from '../../../hydration';
import useInferredName from '../useInferredName';
import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js';
import type {
InspectedElement,
SerializedAsyncInfo,
@@ -171,7 +169,7 @@ function SuspendedByRow({
type={isOpen ? 'expanded' : 'collapsed'}
/>
<span className={styles.CollapsableHeaderTitle}>
{skipName && shortDescription !== '' ? shortDescription : name}
{skipName ? shortDescription : name}
</span>
{skipName || shortDescription === '' ? null : (
<>
@@ -183,12 +181,7 @@ function SuspendedByRow({
</>
)}
<div className={styles.CollapsableHeaderFiller} />
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(ioInfo.env)
}>
<div className={styles.TimeBarContainer}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -348,7 +341,6 @@ type GroupProps = {
inspectedElement: InspectedElement,
store: Store,
name: string,
environment: null | string,
suspendedBy: Array<{
index: number,
value: SerializedAsyncInfo,
@@ -363,7 +355,6 @@ function SuspendedByGroup({
inspectedElement,
store,
name,
environment,
suspendedBy,
minTime,
maxTime,
@@ -416,12 +407,7 @@ function SuspendedByGroup({
<span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
<div className={styles.CollapsableHeaderFiller} />
{isOpen ? null : (
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(environment)
}>
<div className={styles.TimeBarContainer}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -516,21 +502,17 @@ export default function InspectedElementSuspendedBy({
const groups = [];
let currentGroup = null;
let currentGroupName = null;
let currentGroupEnv = null;
for (let i = 0; i < sortedSuspendedBy.length; i++) {
const entry = sortedSuspendedBy[i];
const name = entry.value.awaited.name;
const env = entry.value.awaited.env;
if (
currentGroupName !== name ||
currentGroupEnv !== env ||
!name ||
name === 'Promise' ||
currentGroup === null
) {
// Create a new group.
currentGroupName = name;
currentGroupEnv = env;
currentGroup = [];
groups.push(currentGroup);
}
@@ -609,7 +591,6 @@ export default function InspectedElementSuspendedBy({
<SuspendedByGroup
key={entries[0].index}
name={entries[0].value.awaited.name}
environment={entries[0].value.awaited.env}
suspendedBy={entries}
bridge={bridge}
element={element}

View File

@@ -1,14 +0,0 @@
.SuspenseEnvironmentDefault {
--color-suspense: var(--color-suspense-default);
--color-transition: var(--color-transition-default);
}
.SuspenseEnvironmentServer {
--color-suspense: var(--color-suspense-server);
--color-transition: var(--color-transition-server);
}
.SuspenseEnvironmentOther {
--color-suspense: var(--color-suspense-other);
--color-transition: var(--color-transition-other);
}

View File

@@ -1,20 +0,0 @@
/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import styles from './SuspenseEnvironmentColors.css';
export function getClassNameForEnvironment(environment: null | string): string {
if (environment === null) {
return styles.SuspenseEnvironmentDefault;
}
if (environment === 'Server') {
return styles.SuspenseEnvironmentServer;
}
return styles.SuspenseEnvironmentOther;
}

View File

@@ -1,25 +1,12 @@
.SuspenseRectsContainer {
padding: .25rem;
outline-color: transparent;
outline-style: solid;
outline-width: 1px;
cursor: pointer;
outline: 1px solid var(--color-component-name);
border-radius: 0.25rem;
}
.SuspenseRectsContainer[data-highlighted='true'] {
outline-color: var(--color-transition);
outline-style: solid;
outline-width: 4px;
}
.SuspenseRectsRoot {
cursor: pointer;
outline-color: var(--color-transition);
background-color: color-mix(in srgb, var(--color-transition) 5%, transparent);
}
.SuspenseRectsRoot[data-hovered='true'] {
background-color: color-mix(in srgb, var(--color-transition) 15%, transparent);
background: var(--color-dimmest);
}
.SuspenseRectsViewBox {
@@ -28,11 +15,6 @@
.SuspenseRectsBoundary {
pointer-events: all;
border-radius: 0.125rem;
}
.SuspenseRectsBoundary[data-visible='false'] {
background-color: transparent;
}
.SuspenseRectsBoundaryChildren {
@@ -46,18 +28,15 @@
.SuspenseRectsRect {
box-shadow: var(--elevation-4);
pointer-events: all;
cursor: pointer;
border-radius: 0.125rem;
background-color: color-mix(in srgb, var(--color-background) 50%, var(--color-suspense) 25%);
backdrop-filter: grayscale(100%);
transition: background-color 0.2s ease-in;
outline-color: var(--color-suspense);
outline-style: solid;
outline-width: 1px;
border-radius: 0.125rem;
cursor: pointer;
}
.SuspenseRectsScaledRect {
position: absolute;
outline-color: var(--color-background-selected);
}
.SuspenseRectsScaledRect[data-visible='false'] {
@@ -65,28 +44,15 @@
outline-width: 0;
}
.SuspenseRectsBoundary[data-suspended='true'] {
opacity: 0.33;
.SuspenseRectsScaledRect[data-suspended='true'] {
opacity: 0.3;
}
/* highlight this boundary */
.SuspenseRectsBoundary[data-hovered='true'] > .SuspenseRectsRect {
background-color: color-mix(in srgb, var(--color-background) 50%, var(--color-suspense) 50%);
transition: background-color 0.2s ease-out;
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
background-color: var(--color-background-hover);
}
.SuspenseRectsBoundary[data-selected='true'] {
box-shadow: var(--elevation-4);
}
.SuspenseRectOutline {
outline-color: var(--color-suspense);
outline-style: solid;
outline-width: 4px;
border-radius: 0.125rem;
pointer-events: none;
}
.SuspenseRectsBoundary[data-selected='true'] > .SuspenseRectsRect {
box-shadow: none;
.SuspenseRectsRect[data-highlighted='true'] {
background-color: var(--color-selected-tree-highlight-active);
}

View File

@@ -30,15 +30,12 @@ import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
function ScaledRect({
className,
rect,
visible,
suspended,
selected,
hovered,
adjust,
...props
}: {
@@ -46,8 +43,6 @@ function ScaledRect({
rect: Rect,
visible: boolean,
suspended: boolean,
selected?: boolean,
hovered?: boolean,
adjust?: boolean,
...
}): React$Node {
@@ -63,8 +58,6 @@ function ScaledRect({
className={styles.SuspenseRectsScaledRect + ' ' + className}
data-visible={visible}
data-suspended={suspended}
data-selected={selected}
data-hovered={hovered}
style={{
// Shrink one pixel so that the bottom outline will line up with the top outline of the next one.
width: adjust ? 'calc(' + width + ' - 1px)' : width,
@@ -84,9 +77,7 @@ function SuspenseRects({
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {uniqueSuspendersOnly, timeline, hoveredTimelineIndex} = useContext(
SuspenseTreeStateContext,
);
const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
const {inspectedElementID} = useContext(TreeStateContext);
@@ -154,33 +145,14 @@ function SuspenseRects({
// TODO: Use the nearest Suspense boundary
const selected = inspectedElementID === suspenseID;
const hovered =
hoveredTimelineIndex > -1 &&
timeline[hoveredTimelineIndex].id === suspenseID;
let environment: null | string = null;
for (let i = 0; i < timeline.length; i++) {
const timelineStep = timeline[i];
if (timelineStep.id === suspenseID) {
environment = timelineStep.environment;
break;
}
}
const boundingBox = getBoundingBox(suspense.rects);
return (
<ScaledRect
rect={boundingBox}
className={
styles.SuspenseRectsBoundary +
' ' +
getClassNameForEnvironment(environment)
}
className={styles.SuspenseRectsBoundary}
visible={visible}
selected={selected}
suspended={suspense.isSuspended}
hovered={hovered}>
suspended={suspense.isSuspended}>
<ViewBox.Provider value={boundingBox}>
{visible &&
suspense.rects !== null &&
@@ -190,6 +162,7 @@ function SuspenseRects({
key={index}
className={styles.SuspenseRectsRect}
rect={rect}
data-highlighted={selected}
adjust={true}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -209,13 +182,6 @@ function SuspenseRects({
})}
</ScaledRect>
)}
{selected ? (
<ScaledRect
className={styles.SuspenseRectOutline}
rect={boundingBox}
adjust={true}
/>
) : null}
</ViewBox.Provider>
</ScaledRect>
);
@@ -341,8 +307,7 @@ function SuspenseRectsContainer(): React$Node {
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
// TODO: This relies on a full re-render of all children when the Suspense tree changes.
const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
useContext(SuspenseTreeStateContext);
const {roots} = useContext(SuspenseTreeStateContext);
// TODO: bbox does not consider uniqueSuspendersOnly filter
const boundingBox = getDocumentBoundingRect(store, roots);
@@ -386,37 +351,13 @@ function SuspenseRectsContainer(): React$Node {
}
const isRootSelected = roots.includes(inspectedElementID);
const isRootHovered = hoveredTimelineIndex === 0;
let hasRootSuspenders = false;
if (!uniqueSuspendersOnly) {
hasRootSuspenders = true;
} else {
for (let i = 0; i < roots.length; i++) {
const rootID = roots[i];
const root = store.getSuspenseByID(rootID);
if (root !== null && root.hasUniqueSuspenders) {
hasRootSuspenders = true;
break;
}
}
}
const rootEnvironment =
timeline.length === 0 ? null : timeline[0].environment;
return (
<div
className={
styles.SuspenseRectsContainer +
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
' ' +
getClassNameForEnvironment(rootEnvironment)
}
className={styles.SuspenseRectsContainer}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
data-highlighted={isRootSelected}
data-hovered={isRootHovered}>
data-highlighted={isRootSelected}>
<ViewBox.Provider value={boundingBox}>
<div
className={styles.SuspenseRectsViewBox}

View File

@@ -40,21 +40,22 @@
.SuspenseScrubberBead {
flex: 1;
height: 0.5rem;
background: var(--color-background-selected);
border-radius: 0.5rem;
background: color-mix(in srgb, var(--color-suspense) 10%, transparent);
transition: all 0.3s ease-in;
background: var(--color-selected-tree-highlight-active);
transition: all 0.3s ease-in-out;
}
.SuspenseScrubberBeadSelected {
height: 1rem;
background: var(--color-suspense);
background: var(--color-background-selected);
}
.SuspenseScrubberBeadTransition {
background: var(--color-transition);
background: var(--color-component-name);
}
.SuspenseScrubberStepHighlight > .SuspenseScrubberBead {
.SuspenseScrubberStepHighlight > .SuspenseScrubberBead,
.SuspenseScrubberStep:hover > .SuspenseScrubberBead {
height: 0.75rem;
transition: all 0.3s ease-out;
}

View File

@@ -7,8 +7,6 @@
* @flow
*/
import type {SuspenseTimelineStep} from 'react-devtools-shared/src/frontend/types';
import typeof {SyntheticEvent} from 'react-dom-bindings/src/events/SyntheticEvent';
import * as React from 'react';
@@ -16,14 +14,11 @@ import {useRef} from 'react';
import styles from './SuspenseScrubber.css';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
import Tooltip from '../Components/reach-ui/tooltip';
export default function SuspenseScrubber({
min,
max,
timeline,
value,
highlight,
onBlur,
@@ -34,7 +29,6 @@ export default function SuspenseScrubber({
}: {
min: number,
max: number,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
value: number,
highlight: number,
onBlur?: () => void,
@@ -60,18 +54,17 @@ export default function SuspenseScrubber({
}
const steps = [];
for (let index = min; index <= max; index++) {
const environment = timeline[index].environment;
const label =
index === min
? // The first step in the timeline is always a Transition (Initial Paint).
'Initial Paint' +
(environment === null ? '' : ' (' + environment + ')')
: // TODO: Consider adding the name of this specific boundary if this step has only one.
environment === null
? 'Suspense'
: environment;
steps.push(
<Tooltip key={index} label={label}>
<Tooltip
key={index}
label={
index === min
? // The first step in the timeline is always a Transition (Initial Paint).
// TODO: Support multiple environments.
'Initial Paint'
: // TODO: Consider adding the name of this specific boundary if this step has only one.
'Suspense'
}>
<div
className={
styles.SuspenseScrubberStep +
@@ -86,10 +79,9 @@ export default function SuspenseScrubber({
styles.SuspenseScrubberBead +
(index === min
? // The first step in the timeline is always a Transition (Initial Paint).
// TODO: Support multiple environments.
' ' + styles.SuspenseScrubberBeadTransition
: '') +
' ' +
getClassNameForEnvironment(environment) +
(index <= value ? ' ' + styles.SuspenseScrubberBeadSelected : '')
}
/>

View File

@@ -34,7 +34,7 @@ function SuspenseTimelineInput() {
const max = timeline.length > 0 ? timeline.length - 1 : 0;
function switchSuspenseNode(nextTimelineIndex: number) {
const nextSelectedSuspenseID = timeline[nextTimelineIndex].id;
const nextSelectedSuspenseID = timeline[nextTimelineIndex];
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -53,22 +53,13 @@ function SuspenseTimelineInput() {
switchSuspenseNode(timelineIndex);
}
function handleHoverSegment(hoveredIndex: number) {
const nextSelectedSuspenseID = timeline[hoveredIndex].id;
suspenseTreeDispatch({
type: 'HOVER_TIMELINE_FOR_ID',
payload: nextSelectedSuspenseID,
});
}
function handleUnhoverSegment() {
suspenseTreeDispatch({
type: 'HOVER_TIMELINE_FOR_ID',
payload: -1,
});
function handleHoverSegment(hoveredValue: number) {
// TODO: Consider highlighting the rect instead.
}
function handleUnhoverSegment() {}
function skipPrevious() {
const nextSelectedSuspenseID = timeline[timelineIndex - 1].id;
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -80,7 +71,7 @@ function SuspenseTimelineInput() {
}
function skipForward() {
const nextSelectedSuspenseID = timeline[timelineIndex + 1].id;
const nextSelectedSuspenseID = timeline[timelineIndex + 1];
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -106,7 +97,7 @@ function SuspenseTimelineInput() {
// anything suspended in the root. The step after that should have one less
// thing suspended. I.e. the first suspense boundary should be unsuspended
// when it's selected. This also lets you show everything in the last step.
const suspendedSet = timeline.slice(timelineIndex + 1).map(step => step.id);
const suspendedSet = timeline.slice(timelineIndex + 1);
bridge.send('overrideSuspenseMilestone', {
suspendedSet,
});
@@ -173,7 +164,6 @@ function SuspenseTimelineInput() {
<SuspenseScrubber
min={min}
max={max}
timeline={timeline}
value={timelineIndex}
highlight={hoveredTimelineIndex}
onChange={handleChange}

View File

@@ -7,10 +7,7 @@
* @flow
*/
import type {ReactContext} from 'shared/ReactTypes';
import type {
SuspenseNode,
SuspenseTimelineStep,
} from 'react-devtools-shared/src/frontend/types';
import type {SuspenseNode} from 'react-devtools-shared/src/frontend/types';
import type Store from '../../store';
import * as React from 'react';
@@ -28,7 +25,7 @@ export type SuspenseTreeState = {
lineage: $ReadOnlyArray<SuspenseNode['id']> | null,
roots: $ReadOnlyArray<SuspenseNode['id']>,
selectedSuspenseID: SuspenseNode['id'] | null,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
timeline: $ReadOnlyArray<SuspenseNode['id']>,
timelineIndex: number | -1,
hoveredTimelineIndex: number | -1,
uniqueSuspendersOnly: boolean,
@@ -52,7 +49,7 @@ type ACTION_SELECT_SUSPENSE_BY_ID = {
type ACTION_SET_SUSPENSE_TIMELINE = {
type: 'SET_SUSPENSE_TIMELINE',
payload: [
$ReadOnlyArray<SuspenseTimelineStep>,
$ReadOnlyArray<SuspenseNode['id']>,
// The next Suspense ID to select in the timeline
SuspenseNode['id'] | null,
// Whether this timeline includes only unique suspenders
@@ -114,7 +111,7 @@ function getInitialState(store: Store): SuspenseTreeState {
store.getSuspendableDocumentOrderSuspense(uniqueSuspendersOnly);
const timelineIndex = timeline.length - 1;
const selectedSuspenseID =
timelineIndex === -1 ? null : timeline[timelineIndex].id;
timelineIndex === -1 ? null : timeline[timelineIndex];
const lineage =
selectedSuspenseID !== null
? store.getSuspenseLineage(selectedSuspenseID)
@@ -167,18 +164,16 @@ function SuspenseTreeContextController({children}: Props): React.Node {
selectedSuspenseID = null;
}
const selectedTimelineStep =
state.timeline === null || state.timelineIndex === -1
let selectedTimelineID =
state.timeline === null
? null
: state.timeline[state.timelineIndex];
let selectedTimelineID: null | number = null;
if (selectedTimelineStep !== null) {
selectedTimelineID = selectedTimelineStep.id;
// $FlowFixMe
while (removedIDs.has(selectedTimelineID)) {
// $FlowFixMe
selectedTimelineID = removedIDs.get(selectedTimelineID);
}
while (
selectedTimelineID !== null &&
removedIDs.has(selectedTimelineID)
) {
// $FlowExpectedError[incompatible-type]
selectedTimelineID = removedIDs.get(selectedTimelineID);
}
// TODO: Handle different timeline modes (e.g. random order)
@@ -186,25 +181,20 @@ function SuspenseTreeContextController({children}: Props): React.Node {
state.uniqueSuspendersOnly,
);
let nextTimelineIndex = -1;
if (selectedTimelineID !== null && nextTimeline.length !== 0) {
for (let i = 0; i < nextTimeline.length; i++) {
if (nextTimeline[i].id === selectedTimelineID) {
nextTimelineIndex = i;
break;
}
}
}
let nextTimelineIndex =
selectedTimelineID === null || nextTimeline.length === 0
? -1
: nextTimeline.indexOf(selectedTimelineID);
if (
nextTimeline.length > 0 &&
(nextTimelineIndex === -1 || state.autoSelect)
) {
nextTimelineIndex = nextTimeline.length - 1;
selectedSuspenseID = nextTimeline[nextTimelineIndex].id;
selectedSuspenseID = nextTimeline[nextTimelineIndex];
}
if (selectedSuspenseID === null && nextTimeline.length > 0) {
selectedSuspenseID = nextTimeline[nextTimeline.length - 1].id;
selectedSuspenseID = nextTimeline[nextTimeline.length - 1];
}
const nextLineage =
@@ -266,12 +256,12 @@ function SuspenseTreeContextController({children}: Props): React.Node {
nextMilestoneIndex = nextTimeline.indexOf(previousMilestoneID);
if (nextMilestoneIndex === -1 && nextTimeline.length > 0) {
nextMilestoneIndex = nextTimeline.length - 1;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
} else if (nextRootID !== null) {
nextMilestoneIndex = nextTimeline.length - 1;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex].id;
nextSelectedSuspenseID = nextTimeline[nextMilestoneIndex];
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
@@ -286,7 +276,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
case 'SUSPENSE_SET_TIMELINE_INDEX': {
const nextTimelineIndex = action.payload;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -311,7 +301,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
) {
return state;
}
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -339,7 +329,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
) {
// If we're restarting at the end. Then loop around and start again from the beginning.
nextTimelineIndex = 0;
nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
nextLineage = store.getSuspenseLineage(nextSelectedSuspenseID);
}
@@ -362,7 +352,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
if (nextTimelineIndex > state.timeline.length - 1) {
return state;
}
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -379,14 +369,8 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
case 'TOGGLE_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
let timelineIndexForSuspenseID = -1;
for (let i = 0; i < state.timeline.length; i++) {
if (state.timeline[i].id === suspenseID) {
timelineIndexForSuspenseID = i;
break;
}
}
const timelineIndexForSuspenseID =
state.timeline.indexOf(suspenseID);
if (timelineIndexForSuspenseID === -1) {
// This boundary is no longer in the timeline.
return state;
@@ -403,7 +387,7 @@ function SuspenseTreeContextController({children}: Props): React.Node {
timelineIndexForSuspenseID
: // Otherwise, if we're currently showing it, jump to right before to hide it.
timelineIndexForSuspenseID - 1;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex].id;
const nextSelectedSuspenseID = state.timeline[nextTimelineIndex];
const nextLineage = store.getSuspenseLineage(
nextSelectedSuspenseID,
);
@@ -419,13 +403,8 @@ function SuspenseTreeContextController({children}: Props): React.Node {
}
case 'HOVER_TIMELINE_FOR_ID': {
const suspenseID = action.payload;
let timelineIndexForSuspenseID = -1;
for (let i = 0; i < state.timeline.length; i++) {
if (state.timeline[i].id === suspenseID) {
timelineIndexForSuspenseID = i;
break;
}
}
const timelineIndexForSuspenseID =
state.timeline.indexOf(suspenseID);
return {
...state,
hoveredTimelineIndex: timelineIndexForSuspenseID,

View File

@@ -193,11 +193,6 @@ export type Rect = {
height: number,
};
export type SuspenseTimelineStep = {
id: SuspenseNode['id'], // TODO: Will become a group.
environment: null | string,
};
export type SuspenseNode = {
id: Element['id'],
parentID: SuspenseNode['id'] | 0,
@@ -206,7 +201,6 @@ export type SuspenseNode = {
rects: null | Array<Rect>,
hasUniqueSuspenders: boolean,
isSuspended: boolean,
environments: Array<string>,
};
// Serialized version of ReactIOInfo

View File

@@ -1305,18 +1305,3 @@ export function onReloadAndProfileFlagsReset(): void {
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_CHANGE_DESCRIPTIONS_KEY);
sessionStorageRemoveItem(SESSION_STORAGE_RECORD_TIMELINE_KEY);
}
export function unionOfTwoArrays<T>(a: Array<T>, b: Array<T>): Array<T> {
let result = a;
for (let i = 0; i < b.length; i++) {
const value = b[i];
if (a.indexOf(value) === -1) {
if (result === a) {
// Lazily copy
result = a.slice(0);
}
result.push(value);
}
}
return result;
}

View File

@@ -10,11 +10,11 @@
'use strict';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {patchSetImmediate} from '../../../../scripts/jest/patchSetImmediate';
global.ReadableStream =
require('web-streams-polyfill/ponyfill/es6').ReadableStream;
let clientExports;
let webpackMap;
let webpackModules;
@@ -1136,37 +1136,4 @@ describe('ReactFlightDOMNode', () => {
'Switched to client rendering because the server rendering errored:\n\nssr-throw',
);
});
// This is a regression test for a specific issue where byte Web Streams are
// detaching ArrayBuffers, which caused downstream issues (e.g. "Cannot
// perform Construct on a detached ArrayBuffer") for chunks that are using
// Node's internal Buffer pool.
it('should not corrupt the Node.js Buffer pool by detaching ArrayBuffers when using Web Streams', async () => {
// Create a temp file smaller than 4KB to ensure it uses the Buffer pool.
const file = path.join(os.tmpdir(), 'test.bin');
fs.writeFileSync(file, Buffer.alloc(4095));
const fileChunk = fs.readFileSync(file);
fs.unlinkSync(file);
// Verify this chunk uses the Buffer pool (8192 bytes for files < 4KB).
expect(fileChunk.buffer.byteLength).toBe(8192);
const readable = await serverAct(() =>
ReactServerDOMServer.renderToReadableStream(fileChunk, webpackMap),
);
// Create a Web Streams WritableStream that tries to use Buffer operations.
const writable = new WritableStream({
write(chunk) {
// Only write one byte to ensure Node.js is not creating a new Buffer
// pool. Typically, library code (e.g. a compression middleware) would
// call Buffer.from(chunk) or similar, instead of allocating a new
// Buffer directly. With that, the test file could only be ~2600 bytes.
Buffer.allocUnsafe(1);
},
});
// Must not throw an error.
await readable.pipeTo(writable);
});
});

View File

@@ -37,11 +37,7 @@ export function flushBuffered(destination: Destination) {
// transform streams. https://github.com/whatwg/streams/issues/960
}
// Chunks larger than VIEW_SIZE are written directly, without copying into the
// internal view buffer. This must be at least half of Node's internal Buffer
// pool size (8192) to avoid corrupting the pool when using
// renderToReadableStream, which uses a byte stream that detaches ArrayBuffers.
const VIEW_SIZE = 4096;
const VIEW_SIZE = 2048;
let currentView = null;
let writtenBytes = 0;
@@ -151,7 +147,14 @@ export function typedArrayToBinaryChunk(
// If we passed through this straight to enqueue we wouldn't have to convert it but since
// we need to copy the buffer in that case, we need to convert it to copy it.
// When we copy it into another array using set() it needs to be a Uint8Array.
return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
const buffer = new Uint8Array(
content.buffer,
content.byteOffset,
content.byteLength,
);
// We clone large chunks so that we can transfer them when we write them.
// Others get copied into the target buffer.
return content.byteLength > VIEW_SIZE ? buffer.slice() : buffer;
}
export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {

View File

@@ -38,11 +38,7 @@ export function flushBuffered(destination: Destination) {
}
}
// Chunks larger than VIEW_SIZE are written directly, without copying into the
// internal view buffer. This must be at least half of Node's internal Buffer
// pool size (8192) to avoid corrupting the pool when using
// renderToReadableStream, which uses a byte stream that detaches ArrayBuffers.
const VIEW_SIZE = 4096;
const VIEW_SIZE = 2048;
let currentView = null;
let writtenBytes = 0;
let destinationHasCapacity = true;