Compare commits

..

12 Commits

Author SHA1 Message Date
Joe Savona
758301f70f [compiler][rfc] Switch eslint-plugin-react-compiler to single rule for Meta compat
Now that the official way to use React Compiler's linting is via `eslint-plugin-react-hooks` v7, eslint-plugin-react-compiler isn't strictly necessary anymore. However, at Meta our linting setup makes it a bit tedious to use the current eprh setup with lots of rules. Here I'm experimenting with making eslint-plugin-react-compiler just report all issues under one rule, to make it a bit easier to sync internally.

Unclear if we even need to land this or just use it to help figure out the migration.
2025-10-17 16:54:20 -07:00
Joe Savona
48b52d896e [compiler] Fix false positive for useMemo reassigning context vars
Within a function expresssion local variables use StoreContext, not StoreLocal, so the reassignment check here was firing too often. We should only report an error for variables that are declared outside the function, ie part of its `context`.
2025-10-17 16:54:19 -07:00
Sebastian Markbåge
3a669170e9 [DevTools] Assign a different color and label based on environment (#34893)
Stacked on #34892.

In the timeline scrubber each timeline entry gets a label and color
assigned based on the environment computed for that step.

In the rects, we find the timeline step that this boundary is part of
and use that environment to assign a color. This is slightly different
than picking from the boundary itself since it takes into account parent
boundaries.

In the "suspended by" section we color each entry individually based on
the environment that spawned the I/O.

<img width="790" height="813" alt="Screenshot 2025-10-17 at 12 18 56 AM"
src="https://github.com/user-attachments/assets/c902b1fb-0992-4e24-8e94-a97ca8507551"
/>
2025-10-17 19:03:15 -04:00
Sebastian Markbåge
a083344699 [DevTools] Compute environment names for the timeline (#34892)
Stacked on #34885.

This refactors the timeline to store not just an id but a complex object
for each step. This will later represent a group of boundaries.

Each timeline step is assigned an environment name. We pick the last
environment name (assumed to have resolved last) from the union of the
parent and child environment names. I.e. a child step is considered to
be blocked by the parent so if a child isn't blocked on any environment
name it still gets marked as the parent's environment name.

In a follow up, I'd like to reorder the document order timeline based on
environment names to favor loading everything in one environment before
the next.
2025-10-17 18:54:53 -04:00
Sebastian Markbåge
423c44b886 [DevTools] Don't highlight the root rect if no roots has unique suspenders (#34885)
Stacked on #34881.

We don't paint suspense boundaries if there are no suspenders. This does
the same with the root. The root is still selectable so you can confirm
but there's no affordance drawing attention to click the root.

This could happen if you don't use the built-ins of React to load things
like scripts and css. It would never happen in something like Next.js
where code and CSS is loaded through React-native like RSC.

However, it could also happen in the Activity scoped case when all
resources are always loaded early.
2025-10-17 18:53:30 -04:00
Sebastian Markbåge
f970d5ff32 [DevTools] Highlight the rect when the corresponding timeline bean is hovered (#34881)
Stacked on #34880.

In #34861 I removed the highlight of the real view when hovering the
timeline since it was disruptive to stepping through the visuals.

This makes it so that when we hover the timeline we highlight the rect
with the subtle hover effect added in #34880.

We can now just use the one shared state for this and don't need the CSS
psuedo-selectors.

<img width="603" height="813" alt="Screenshot 2025-10-16 at 3 11 17 PM"
src="https://github.com/user-attachments/assets/a018b5ce-dd4d-4e77-ad47-b4ea068f1976"
/>
2025-10-17 18:52:26 -04:00
Sebastian Markbåge
724e7bfb40 [DevTools] Repeat the "name" if there's no short description in groups (#34894)
It looks weird when the row is blank when there's no short description
for the entry in a group.

<img width="328" height="436" alt="Screenshot 2025-10-17 at 12 25 30 AM"
src="https://github.com/user-attachments/assets/12f5c55f-a37f-4b6d-913e-f763cec6b211"
/>
2025-10-17 18:52:07 -04:00
Sebastian Markbåge
ef88c588d5 [DevTools] Tweak the rects design and create multi-environment color scheme (#34880)
<img width="1011" height="811" alt="Screenshot 2025-10-16 at 2 20 46 PM"
src="https://github.com/user-attachments/assets/6dea3962-d369-4823-b44f-2c62b566c8f1"
/>

The selection is now clearer with a wider outline which spans the
bounding box if there are multi rects.

The color now gets darked changes on hover with a slight animation.

The colors are now mixed from constants defined which are consistently
used in the rects, the time span in the "suspended by" side bar and the
scrubber. I also have constants defined for "server" and "other" debug
environments which will be used in a follow up.
2025-10-17 18:51:02 -04:00
Hendrik Liebau
dc485c7303 [Flight] Fix detached ArrayBuffer error when streaming typed arrays (#34849)
Using `renderToReadableStream` in Node.js with binary data from
`fs.readFileSync` (or `Buffer.allocUnsafe`) could cause downstream
consumers (like compression middleware) to fail with "Cannot perform
Construct on a detached ArrayBuffer".

The issue occurs because Node.js uses an 8192-byte Buffer pool for small
allocations (< 4KB). When React's `VIEW_SIZE` was 2KB, files between
~2KB and 4KB would be passed through as views of pooled buffers rather
than copied into `currentView`. ByteStreams (`type: 'bytes'`) detach
ArrayBuffers during transfer, which corrupts the shared Buffer pool and
causes subsequent Buffer operations to fail.

Increasing `VIEW_SIZE` from 2KB to 4KB ensures all chunks smaller than
4KB are copied into `currentView` (which uses a dedicated 4KB buffer
outside the pool), while chunks 4KB or larger don't use the pool anyway.
Thus no pooled buffers are ever exposed to ByteStream detachment.

This adds 2KB memory per active stream, copies chunks in the 2-4KB range
instead of passing them as views (small CPU cost), and buffers up to 2KB
more data before flushing. However, it avoids duplicating large binary
data (which copying everything would require, like the Edge entry point
currently does in `typedArrayToBinaryChunk`).

Related issues:

- https://github.com/vercel/next.js/issues/84753
- https://github.com/vercel/next.js/issues/84858
2025-10-17 22:13:52 +02:00
Joseph Savona
c35f6a3041 [compiler] Optimize props spread for common cases (#34900)
As part of the new inference model we updated to (correctly) treat
destructuring spread as creating a new mutable object. This had the
unfortunate side-effect of reducing precision on destructuring of props,
though:

```js
function Component({x, ...rest}) {
  const z = rest.z;
  identity(z);
  return <Stringify x={x} z={z} />;
}
```

Memoized as the following, where we don't realize that `z` is actually
frozen:

```js
function Component(t0) {
  const $ = _c(6);
  let x;
  let z;
  if ($[0] !== t0) {
    const { x: t1, ...rest } = t0;
    x = t1;
    z = rest.z;
    identity(z);
...
```

#34341 was our first thought of how to do this (thanks @poteto for
exploring this idea!). But during review it became clear that it was a
bit more complicated than I had thought. So this PR explores a more
conservative alternative. The idea is:

* Track known sources of frozen values: component props, hook params,
and hook return values.
* Find all object spreads where the rvalue is a known frozen value.
* Look at how such objects are used, and if they are only used to access
properties (PropertyLoad/Destructure), pass to hooks, or pass to jsx
then we can be very confident the object is not mutated. We consider any
such objects to be frozen, even though technically spread creates a new
object.

See new fixtures for more examples.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34900).
* __->__ #34900
* #34887
2025-10-17 11:59:17 -07:00
Joseph Savona
adbc32de32 [compiler] More fbt compatibility (#34887)
In my previous PR I fixed some cases but broke others. So, new approach.
Two phase algorithm:

* First pass is forward data flow to determine all usages of macros.
This is necessary because many of Meta's macros have variants that can
be accessed via properties, eg you can do `macro(...)` but also
`macro.variant(...)`.
* Second pass is backwards data flow to find macro invocations (JSX and
calls) and then merge their operands into the same scope as the macro
call.

Note that this required updating PromoteUsedTemporaries to avoid
promoting macro calls that have interposing instructions between their
creation and usage. Macro calls in general are pure so it should be safe
to reorder them.

In addition, we're now more precise about `<fb:plural>`, `<fbt:param>`,
`fbt.plural()` and `fbt.param()`, which don't actually require all their
arguments to be inlined. The whole point is that the plural/param value
is an arbitrary value (along with a string name). So we no longer
transitively inline the arguments, we just make sure that they don't get
inadvertently promoted to named variables.

One caveat: we actually don't do anything to treat macro functions as
non-mutating, so `fbt.plural()` and friends (function form) may still
sometimes group arguments just due to mutability inference. In a
follow-up, i'll work to infer the types of nested macro functions as
non-mutating.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34887).
* #34900
* __->__ #34887
2025-10-17 11:37:28 -07:00
Joseph Savona
1324e1bb1f [compiler] Cleanup and enable validateNoVoidUseMemo (#34882)
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)

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/34882).
* #34855
* __->__ #34882
2025-10-16 13:08:57 -07:00
65 changed files with 1840 additions and 987 deletions

View File

@@ -83,21 +83,11 @@ export type ExternalFunction = z.infer<typeof ExternalFunctionSchema>;
export const USE_FIRE_FUNCTION_NAME = 'useFire';
export const EMIT_FREEZE_GLOBAL_GATING = '__DEV__';
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 const MacroSchema = z.string();
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,
loc: GeneratedSource,
};
}

View File

@@ -11,6 +11,7 @@ import {
CallExpression,
Effect,
Environment,
FinishMemoize,
FunctionExpression,
HIRFunction,
IdentifierId,
@@ -24,6 +25,7 @@ import {
Place,
PropertyLoad,
SpreadPattern,
StartMemoize,
TInstruction,
getHookKindForType,
makeInstructionId,
@@ -182,52 +184,36 @@ function makeManualMemoizationMarkers(
depsList: Array<ManualMemoDependency> | null,
memoDecl: Place,
manualMemoId: number,
): [Array<Instruction>, Array<Instruction>] {
const temp = createTemporaryPlace(env, memoDecl.loc);
): [TInstruction<StartMemoize>, TInstruction<FinishMemoize>] {
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,
loc: fnExpr.loc,
},
effects: null,
{
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,
},
],
[
{
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, fnExpr.loc),
value: {
kind: 'FinishMemoize',
manualMemoId,
decl: {...memoDecl},
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,
},
],
effects: null,
loc: fnExpr.loc,
},
];
}
@@ -423,7 +409,10 @@ export function dropManualMemoization(
* LoadLocal fnArg
* - (if validation is enabled) collect manual memoization markers
*/
const queuedInserts: Map<InstructionId, Array<Instruction>> = new Map();
const queuedInserts: Map<
InstructionId,
TInstruction<StartMemoize> | TInstruction<FinishMemoize>
> = new Map();
for (const [_, block] of func.body.blocks) {
for (let i = 0; i < block.instructions.length; i++) {
const instr = block.instructions[i]!;
@@ -534,11 +523,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 insertInstructions = queuedInserts.get(instr.id);
if (insertInstructions != null) {
const insertInstr = queuedInserts.get(instr.id);
if (insertInstr != null) {
nextInstructions = nextInstructions ?? block.instructions.slice(0, i);
nextInstructions.push(instr);
nextInstructions.push(...insertInstructions);
nextInstructions.push(insertInstr);
} else if (nextInstructions != null) {
nextInstructions.push(instr);
}

View File

@@ -19,6 +19,7 @@ import {
Environment,
FunctionExpression,
GeneratedSource,
getHookKind,
HIRFunction,
Hole,
IdentifierId,
@@ -198,6 +199,7 @@ export function inferMutationAliasingEffects(
isFunctionExpression,
fn,
hoistedContextDeclarations,
findNonMutatedDestructureSpreads(fn),
);
let iterationCount = 0;
@@ -287,15 +289,18 @@ 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(
@@ -322,6 +327,161 @@ 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,
@@ -2054,7 +2214,9 @@ function computeSignatureForInstruction(
kind: 'Create',
into: place,
reason: ValueReason.Other,
value: ValueKind.Mutable,
value: context.nonMutatingSpreads.has(place.identifier.id)
? ValueKind.Frozen
: ValueKind.Mutable,
});
effects.push({
kind: 'Capture',

View File

@@ -7,7 +7,6 @@
import {
HIRFunction,
Identifier,
IdentifierId,
InstructionValue,
makeInstructionId,
@@ -15,9 +14,35 @@ import {
Place,
ReactiveScope,
} from '../HIR';
import {Macro, MacroMethod} from '../HIR/Environment';
import {Macro} from '../HIR/Environment';
import {eachInstructionValueOperand} from '../HIR/visitors';
import {Iterable_some} from '../Utils/utils';
/**
* 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);
/**
* This pass supports the `fbt` translation system (https://facebook.github.io/fbt/)
@@ -42,250 +67,210 @@ import {Iterable_some} from '../Utils/utils';
* ## User-defined macro-like function
*
* Users can also specify their own functions to be treated similarly to fbt via the
* `customMacros` environment configuration.
* `customMacros` environment configuration. By default, user-supplied custom macros
* have their arguments transitively inlined.
*/
export function memoizeFbtAndMacroOperandsInSameScope(
fn: HIRFunction,
): Set<IdentifierId> {
const fbtMacroTags = new Set<Macro>([
...Array.from(FBT_TAGS).map((tag): Macro => [tag, []]),
...(fn.env.config.customMacros ?? []),
const macroKinds = new Map<Macro, MacroDefinition>([
...Array.from(FBT_TAGS.entries()),
...(fn.env.config.customMacros ?? []).map(
name => [name, TRANSITIVE_MACRO] as [Macro, MacroDefinition],
),
]);
/**
* 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
* Forward data-flow analysis to identify all macro tags, including
* things like `fbt.foo.bar(...)`
*/
const macroTagsCalls: Set<IdentifierId> = new Set();
const macroTags = populateMacroTags(fn, macroKinds);
/**
* 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.
* Reverse data-flow analysis to merge arguments to macro *invocations*
* based on the kind of the macro
*/
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>>>();
const macroValues = mergeMacroArguments(fn, macroTags, macroKinds);
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;
return macroValues;
}
export const FBT_TAGS: Set<string> = new Set([
'fbt',
'fbt:param',
'fbt:enum',
'fbt:plural',
'fbs',
'fbs:param',
'fbs:enum',
'fbs:plural',
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 SINGLE_CHILD_FBT_TAGS: Set<string> = new Set([
'fbt:param',
'fbs:param',
]);
function visit(
function populateMacroTags(
fn: HIRFunction,
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);
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);
}
}
break;
}
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);
case 'LoadGlobal': {
let macroDefinition = macroKinds.get(value.binding.name);
if (macroDefinition != null) {
macroTags.set(lvalue.identifier.id, macroDefinition);
}
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 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;
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;
}
}
}
const [tag, rest] = macro;
if (tag === s && rest.length > 0) {
methods.push(rest);
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);
}
}
}
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)
);
return macroValues;
}
function expandFbtScopeRange(
@@ -298,3 +283,22 @@ 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,15 +77,6 @@ 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,16 +135,7 @@ function parseConfigPragmaEnvironmentForTest(
} else if (val) {
const parsedVal = tryParseTestPragmaValue(val).unwrap();
if (key === 'customMacros' && typeof parsedVal === 'string') {
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]];
maybeConfig[key] = [parsedVal.split('.')[0]];
continue;
}
maybeConfig[key] = parsedVal;

View File

@@ -184,25 +184,28 @@ 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': {
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',
}),
);
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',
}),
);
}
break;
}
}

View File

@@ -29,7 +29,7 @@ Found 1 error:
Invariant: Expected consistent kind for destructuring
Other places were `Reassign` but 'mutate? #t8$47[7:9]{reactive}' is const.
Other places were `Reassign` but 'mutate? #t8$46[7:9]{reactive}' is const.
error.bug-invariant-expected-consistent-destructuring.ts:9:9
7 |

View File

@@ -44,15 +44,23 @@ import fbt from "fbt";
import { identity } from "shared-runtime";
function Component(props) {
const $ = _c(3);
const $ = _c(5);
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(identity(props.count), "count"),
fbt._plural(t1, "count"),
fbt._param(
"option",

View File

@@ -44,15 +44,23 @@ import fbt from "fbt";
import { identity } from "shared-runtime";
function Component(props) {
const $ = _c(3);
const $ = _c(5);
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(identity(props.count), "count"),
fbt._plural(t1, "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(7);
const $ = _c(13);
const { name1, name2 } = t0;
let t1;
if ($[0] !== name1 || $[1] !== name2) {
@@ -50,19 +50,34 @@ function Foo(t0) {
t2 = $[4];
}
let t3;
if ($[5] !== name2) {
t3 = <b>{name2}</b>;
$[5] = name2;
$[6] = t3;
if ($[5] !== name1 || $[6] !== t2) {
t3 = <span key={name1}>{t2}</span>;
$[5] = name1;
$[6] = t2;
$[7] = t3;
} else {
t3 = $[6];
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];
}
t1 = fbt._(
"{user1} and {user2} accepted your PR!",
[
fbt._param("user1", <span key={name1}>{t2}</span>),
fbt._param("user2", <span key={name2}>{t3}</span>),
],
[fbt._param("user1", t3), fbt._param("user2", t5)],
{ hk: "2PxMie" },
);
$[0] = name1;

View File

@@ -29,20 +29,24 @@ import { c as _c } from "react/compiler-runtime";
import fbt from "fbt";
function Component(t0) {
const $ = _c(4);
const $ = _c(6);
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",
<Text type="h4">{name}</Text>,
),
fbt._param("item author", t2),
fbt._param(
"icon",

View File

@@ -27,16 +27,21 @@ import fbt from "fbt";
import { identity } from "shared-runtime";
function Component(props) {
const $ = _c(2);
const $ = _c(4);
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", <>{identity(props.text)}</>)],
{ hk: "10F5Cc" },
)}
value={fbt._("{value}%", [fbt._param("value", t2)], { hk: "10F5Cc" })}
/>
);
$[0] = props.text;

View File

@@ -0,0 +1,109 @@
## 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

@@ -0,0 +1,35 @@
// @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

@@ -0,0 +1,128 @@
## 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

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

View File

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

View File

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

View File

@@ -0,0 +1,63 @@
## 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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,57 @@
## 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

@@ -0,0 +1,10 @@
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

@@ -0,0 +1,63 @@
## 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

@@ -0,0 +1,13 @@
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

@@ -0,0 +1,61 @@
## 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

@@ -0,0 +1,12 @@
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

@@ -0,0 +1,49 @@
## 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

@@ -0,0 +1,21 @@
// @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

@@ -1,56 +0,0 @@
## 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

@@ -1,21 +0,0 @@
// @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

@@ -0,0 +1,45 @@
## 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

@@ -0,0 +1,12 @@
// @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

@@ -1,98 +0,0 @@
## 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

@@ -1,28 +0,0 @@
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

@@ -5,22 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/
import {
ErrorCategory,
getRuleForCategory,
} from 'babel-plugin-react-compiler/src/CompilerError';
import {normalizeIndent, testRule, makeTestCaseError} from './shared-utils';
import {allRules} from '../src/rules/ReactCompilerRule';
import ReactCompilerRule from '../src/rules/ReactCompilerRule';
testRule(
'no impure function calls rule',
allRules[getRuleForCategory(ErrorCategory.Purity).name].rule,
{
valid: [],
invalid: [
{
name: 'Known impure function calls are caught',
code: normalizeIndent`
testRule('no impure function calls rule', ReactCompilerRule, {
valid: [],
invalid: [
{
name: 'Known impure function calls are caught',
code: normalizeIndent`
function Component() {
const date = Date.now();
const now = performance.now();
@@ -28,12 +21,11 @@ testRule(
return <Foo date={date} now={now} rand={rand} />;
}
`,
errors: [
makeTestCaseError('Cannot call impure function during render'),
makeTestCaseError('Cannot call impure function during render'),
makeTestCaseError('Cannot call impure function during render'),
],
},
],
},
);
errors: [
makeTestCaseError('Cannot call impure function during render'),
makeTestCaseError('Cannot call impure function during render'),
makeTestCaseError('Cannot call impure function during render'),
],
},
],
});

View File

@@ -5,30 +5,23 @@
* LICENSE file in the root directory of this source tree.
*/
import {
ErrorCategory,
getRuleForCategory,
} from 'babel-plugin-react-compiler/src/CompilerError';
import {normalizeIndent, makeTestCaseError, testRule} from './shared-utils';
import {allRules} from '../src/rules/ReactCompilerRule';
import {AllRules} from '../src/rules/ReactCompilerRule';
testRule(
'rules-of-hooks',
allRules[getRuleForCategory(ErrorCategory.Hooks).name].rule,
{
valid: [
{
name: 'Basic example',
code: normalizeIndent`
testRule('rules-of-hooks', AllRules, {
valid: [
{
name: 'Basic example',
code: normalizeIndent`
function Component() {
useHook();
return <div>Hello world</div>;
}
`,
},
{
name: 'Violation with Flow suppression',
code: `
},
{
name: 'Violation with Flow suppression',
code: `
// Valid since error already suppressed with flow.
function useHook() {
if (cond) {
@@ -37,11 +30,11 @@ testRule(
}
}
`,
},
{
// OK because invariants are only meant for the compiler team's consumption
name: '[Invariant] Defined after use',
code: normalizeIndent`
},
{
// OK because invariants are only meant for the compiler team's consumption
name: '[Invariant] Defined after use',
code: normalizeIndent`
function Component(props) {
let y = function () {
m(x);
@@ -52,49 +45,42 @@ testRule(
return y;
}
`,
},
{
name: "Classes don't throw",
code: normalizeIndent`
},
{
name: "Classes don't throw",
code: normalizeIndent`
class Foo {
#bar() {}
}
`,
},
],
invalid: [
{
name: 'Simple violation',
code: normalizeIndent`
},
],
invalid: [
{
name: 'Simple violation',
code: normalizeIndent`
function useConditional() {
if (cond) {
useConditionalHook();
}
}
`,
errors: [
makeTestCaseError(
'Hooks must always be called in a consistent order',
),
],
},
{
name: 'Multiple diagnostics within the same function are surfaced',
code: normalizeIndent`
errors: [
makeTestCaseError('Hooks must always be called in a consistent order'),
],
},
{
name: 'Multiple diagnostics within the same function are surfaced',
code: normalizeIndent`
function useConditional() {
cond ?? useConditionalHook();
props.cond && useConditionalHook();
return <div>Hello world</div>;
}`,
errors: [
makeTestCaseError(
'Hooks must always be called in a consistent order',
),
makeTestCaseError(
'Hooks must always be called in a consistent order',
),
],
},
],
},
);
errors: [
makeTestCaseError('Hooks must always be called in a consistent order'),
makeTestCaseError('Hooks must always be called in a consistent order'),
],
},
],
});

View File

@@ -5,22 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/
import {
ErrorCategory,
getRuleForCategory,
} from 'babel-plugin-react-compiler/src/CompilerError';
import {normalizeIndent, testRule, makeTestCaseError} from './shared-utils';
import {allRules} from '../src/rules/ReactCompilerRule';
import ReactCompilerRule from '../src/rules/ReactCompilerRule';
testRule(
'no ambiguous JSX rule',
allRules[getRuleForCategory(ErrorCategory.ErrorBoundaries).name].rule,
{
valid: [],
invalid: [
{
name: 'JSX in try blocks are warned against',
code: normalizeIndent`
testRule('no ambiguous JSX rule', ReactCompilerRule, {
valid: [],
invalid: [
{
name: 'JSX in try blocks are warned against',
code: normalizeIndent`
function Component(props) {
let el;
try {
@@ -31,8 +24,7 @@ testRule(
return el;
}
`,
errors: [makeTestCaseError('Avoid constructing JSX within try/catch')],
},
],
},
);
errors: [makeTestCaseError('Avoid constructing JSX within try/catch')],
},
],
});

View File

@@ -4,22 +4,15 @@
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
import {
ErrorCategory,
getRuleForCategory,
} from 'babel-plugin-react-compiler/src/CompilerError';
import {normalizeIndent, makeTestCaseError, testRule} from './shared-utils';
import {allRules} from '../src/rules/ReactCompilerRule';
import ReactCompilerRule from '../src/rules/ReactCompilerRule';
testRule(
'no-capitalized-calls',
allRules[getRuleForCategory(ErrorCategory.CapitalizedCalls).name].rule,
{
valid: [],
invalid: [
{
name: 'Simple violation',
code: normalizeIndent`
testRule('no-capitalized-calls', ReactCompilerRule, {
valid: [],
invalid: [
{
name: 'Simple violation',
code: normalizeIndent`
import Child from './Child';
function Component() {
return <>
@@ -27,15 +20,13 @@ testRule(
</>;
}
`,
errors: [
makeTestCaseError(
'Capitalized functions are reserved for components',
),
],
},
{
name: 'Method call violation',
code: normalizeIndent`
errors: [
makeTestCaseError('Capitalized functions are reserved for components'),
],
},
{
name: 'Method call violation',
code: normalizeIndent`
import myModule from './MyModule';
function Component() {
return <>
@@ -43,15 +34,13 @@ testRule(
</>;
}
`,
errors: [
makeTestCaseError(
'Capitalized functions are reserved for components',
),
],
},
{
name: 'Multiple diagnostics within the same function are surfaced',
code: normalizeIndent`
errors: [
makeTestCaseError('Capitalized functions are reserved for components'),
],
},
{
name: 'Multiple diagnostics within the same function are surfaced',
code: normalizeIndent`
import Child1 from './Child1';
import MyModule from './MyModule';
function Component() {
@@ -60,12 +49,9 @@ testRule(
{MyModule.Child2()}
</>;
}`,
errors: [
makeTestCaseError(
'Capitalized functions are reserved for components',
),
],
},
],
},
);
errors: [
makeTestCaseError('Capitalized functions are reserved for components'),
],
},
],
});

View File

@@ -5,30 +5,22 @@
* LICENSE file in the root directory of this source tree.
*/
import {
ErrorCategory,
getRuleForCategory,
} from 'babel-plugin-react-compiler/src/CompilerError';
import {normalizeIndent, testRule, makeTestCaseError} from './shared-utils';
import {allRules} from '../src/rules/ReactCompilerRule';
import ReactCompilerRule from '../src/rules/ReactCompilerRule';
testRule(
'no ref access in render rule',
allRules[getRuleForCategory(ErrorCategory.Refs).name].rule,
{
valid: [],
invalid: [
{
name: 'validate against simple ref access in render',
code: normalizeIndent`
testRule('no ref access in render rule', ReactCompilerRule, {
valid: [],
invalid: [
{
name: 'validate against simple ref access in render',
code: normalizeIndent`
function Component(props) {
const ref = useRef(null);
const value = ref.current;
return value;
}
`,
errors: [makeTestCaseError('Cannot access refs during render')],
},
],
},
);
errors: [makeTestCaseError('Cannot access refs during render')],
},
],
});

View File

@@ -5,19 +5,10 @@
* LICENSE file in the root directory of this source tree.
*/
import {
ErrorCategory,
getRuleForCategory,
} from 'babel-plugin-react-compiler/src/CompilerError';
import {
normalizeIndent,
testRule,
makeTestCaseError,
TestRecommendedRules,
} from './shared-utils';
import {allRules} from '../src/rules/ReactCompilerRule';
import {normalizeIndent, testRule, makeTestCaseError} from './shared-utils';
import {AllRules} from '../src/rules/ReactCompilerRule';
testRule('plugin-recommended', TestRecommendedRules, {
testRule('plugin-recommended', AllRules, {
valid: [
{
name: 'Basic example with component syntax',

View File

@@ -6,11 +6,8 @@
*/
import {RuleTester} from 'eslint';
import {
CompilerTestCases,
normalizeIndent,
TestRecommendedRules,
} from './shared-utils';
import {CompilerTestCases, normalizeIndent} from './shared-utils';
import ReactCompilerRule from '../src/rules/ReactCompilerRule';
const tests: CompilerTestCases = {
valid: [
@@ -62,4 +59,4 @@ const eslintTester = new RuleTester({
// @ts-ignore[2353] - outdated types
parser: require.resolve('@typescript-eslint/parser'),
});
eslintTester.run('react-compiler', TestRecommendedRules, tests);
eslintTester.run('react-compiler', ReactCompilerRule, tests);

View File

@@ -1,8 +1,5 @@
import {RuleTester as ESLintTester, Rule} from 'eslint';
import {type ErrorCategory} from 'babel-plugin-react-compiler/src/CompilerError';
import escape from 'regexp.escape';
import {configs} from '../src/index';
import {allRules} from '../src/rules/ReactCompilerRule';
/**
* A string template tag that removes padding from the left side of multi-line strings
@@ -46,31 +43,4 @@ export function testRule(
eslintTester.run(name, rule, tests);
}
/**
* Aggregates all recommended rules from the plugin.
*/
export const TestRecommendedRules: Rule.RuleModule = {
meta: {
type: 'problem',
docs: {
description: 'Disallow capitalized function calls',
category: 'Possible Errors',
recommended: true,
},
// validation is done at runtime with zod
schema: [{type: 'object', additionalProperties: true}],
},
create(context) {
for (const ruleConfig of Object.values(
configs.recommended.plugins['react-compiler'].rules,
)) {
const listener = ruleConfig.rule.create(context);
if (Object.entries(listener).length !== 0) {
throw new Error('TODO: handle rules that return listeners to eslint');
}
}
return {};
},
};
test('no test', () => {});

View File

@@ -5,37 +5,24 @@
* LICENSE file in the root directory of this source tree.
*/
import {type Linter} from 'eslint';
import {
allRules,
mapErrorSeverityToESlint,
recommendedRules,
} from './rules/ReactCompilerRule';
import ReactCompilerRule from './rules/ReactCompilerRule';
const meta = {
name: 'eslint-plugin-react-compiler',
};
const configs = {
recommended: {
plugins: {
'react-compiler': {
rules: allRules,
module.exports = {
rules: {
'react-compiler': ReactCompilerRule,
},
configs: {
recommended: {
plugins: {
'react-compiler': {
rules: {
'react-compiler': ReactCompilerRule,
},
},
},
rules: {
'react-compiler/react-compiler': 'error',
},
},
rules: Object.fromEntries(
Object.entries(recommendedRules).map(([name, ruleConfig]) => {
return [
'react-compiler/' + name,
mapErrorSeverityToESlint(ruleConfig.severity),
];
}),
) as Record<string, Linter.StringSeverity>,
},
};
const rules = Object.fromEntries(
Object.entries(allRules).map(([name, {rule}]) => [name, rule]),
);
export {configs, rules, meta};

View File

@@ -14,7 +14,7 @@ import {
import type {Linter, Rule} from 'eslint';
import runReactCompiler, {RunCacheEntry} from '../shared/RunReactCompiler';
import {
ErrorSeverity,
ErrorCategory,
LintRulePreset,
LintRules,
type LintRule,
@@ -108,14 +108,15 @@ function hasFlowSuppression(
return false;
}
function makeRule(rule: LintRule): Rule.RuleModule {
function makeRule(rules: Array<LintRule>): Rule.RuleModule {
const categories = new Set(rules.map(rule => rule.category));
const create = (context: Rule.RuleContext): Rule.RuleListener => {
const result = getReactCompilerResult(context);
for (const event of result.events) {
if (event.kind === 'CompileError') {
const detail = event.detail;
if (detail.category === rule.category) {
if (categories.has(detail.category)) {
const loc = detail.primaryLocation();
if (loc == null || typeof loc === 'symbol') {
continue;
@@ -150,8 +151,8 @@ function makeRule(rule: LintRule): Rule.RuleModule {
meta: {
type: 'problem',
docs: {
description: rule.description,
recommended: rule.preset === LintRulePreset.Recommended,
description: 'React Compiler diagnostics',
recommended: true,
},
fixable: 'code',
hasSuggestions: true,
@@ -162,47 +163,13 @@ function makeRule(rule: LintRule): Rule.RuleModule {
};
}
type RulesConfig = {
[name: string]: {rule: Rule.RuleModule; severity: ErrorSeverity};
};
export default makeRule(
LintRules.filter(
rule =>
rule.preset === LintRulePreset.Recommended ||
rule.preset === LintRulePreset.RecommendedLatest ||
rule.category === ErrorCategory.CapitalizedCalls,
),
);
export const allRules: RulesConfig = LintRules.reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
}, {} as RulesConfig);
export const recommendedRules: RulesConfig = LintRules.filter(
rule => rule.preset === LintRulePreset.Recommended,
).reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
}, {} as RulesConfig);
export const recommendedLatestRules: RulesConfig = LintRules.filter(
rule =>
rule.preset === LintRulePreset.Recommended ||
rule.preset === LintRulePreset.RecommendedLatest,
).reduce((acc, rule) => {
acc[rule.name] = {rule: makeRule(rule), severity: rule.severity};
return acc;
}, {} as RulesConfig);
export function mapErrorSeverityToESlint(
severity: ErrorSeverity,
): Linter.StringSeverity {
switch (severity) {
case ErrorSeverity.Error: {
return 'error';
}
case ErrorSeverity.Warning: {
return 'warn';
}
case ErrorSeverity.Hint:
case ErrorSeverity.Off: {
return 'off';
}
default: {
assertExhaustive(severity, `Unhandled severity: ${severity}`);
}
}
}
export const AllRules = makeRule(LintRules);

View File

@@ -136,8 +136,6 @@ 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)',
@@ -156,6 +154,14 @@ 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).
@@ -290,8 +296,6 @@ 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)',
@@ -311,6 +315,14 @@ 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,6 +34,7 @@ import {
shallowDiffers,
utfDecodeStringWithRanges,
parseElementDisplayNameFromBackend,
unionOfTwoArrays,
} from '../utils';
import {localStorageGetItem, localStorageSetItem} from '../storage';
import {__DEBUG__} from '../constants';
@@ -51,6 +52,7 @@ import type {
ComponentFilter,
ElementType,
SuspenseNode,
SuspenseTimelineStep,
Rect,
} from 'react-devtools-shared/src/frontend/types';
import type {
@@ -895,13 +897,10 @@ export default class Store extends EventEmitter<{
*/
getSuspendableDocumentOrderSuspense(
uniqueSuspendersOnly: boolean,
): $ReadOnlyArray<SuspenseNode['id']> {
): $ReadOnlyArray<SuspenseTimelineStep> {
const target: Array<SuspenseTimelineStep> = [];
const roots = this.roots;
if (roots.length === 0) {
return [];
}
const list: SuspenseNode['id'][] = [];
let rootStep: null | SuspenseTimelineStep = null;
for (let i = 0; i < roots.length; i++) {
const rootID = roots[i];
const root = this.getElementByID(rootID);
@@ -912,44 +911,76 @@ export default class Store extends EventEmitter<{
const suspense = this.getSuspenseByID(rootID);
if (suspense !== null) {
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);
}
}
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;
}
this.pushTimelineStepsInDocumentOrder(
suspense.children,
target,
uniqueSuspendersOnly,
environments,
);
}
}
return list;
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,
);
}
}
getRendererIDForElement(id: number): number | null {
@@ -1627,6 +1658,7 @@ export default class Store extends EventEmitter<{
rects,
hasUniqueSuspenders: false,
isSuspended: isSuspended,
environments: [],
});
hasSuspenseTreeChanged = true;
@@ -1812,7 +1844,10 @@ export default class Store extends EventEmitter<{
envIndex++
) {
const environmentNameStringID = operations[i++];
environmentNames.push(stringTable[environmentNameStringID]);
const environmentName = stringTable[environmentNameStringID];
if (environmentName != null) {
environmentNames.push(environmentName);
}
}
const suspense = this._idToSuspense.get(id);
@@ -1836,7 +1871,7 @@ export default class Store extends EventEmitter<{
suspense.hasUniqueSuspenders = hasUniqueSuspenders;
suspense.isSuspended = isSuspended;
// TODO: Recompute the environment names.
suspense.environments = environmentNames;
}
hasSuspenseTreeChanged = true;

View File

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

View File

@@ -22,6 +22,8 @@ import OwnerView from './OwnerView';
import {meta} from '../../../hydration';
import useInferredName from '../useInferredName';
import {getClassNameForEnvironment} from '../SuspenseTab/SuspenseEnvironmentColors.js';
import type {
InspectedElement,
SerializedAsyncInfo,
@@ -169,7 +171,7 @@ function SuspendedByRow({
type={isOpen ? 'expanded' : 'collapsed'}
/>
<span className={styles.CollapsableHeaderTitle}>
{skipName ? shortDescription : name}
{skipName && shortDescription !== '' ? shortDescription : name}
</span>
{skipName || shortDescription === '' ? null : (
<>
@@ -181,7 +183,12 @@ function SuspendedByRow({
</>
)}
<div className={styles.CollapsableHeaderFiller} />
<div className={styles.TimeBarContainer}>
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(ioInfo.env)
}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -341,6 +348,7 @@ type GroupProps = {
inspectedElement: InspectedElement,
store: Store,
name: string,
environment: null | string,
suspendedBy: Array<{
index: number,
value: SerializedAsyncInfo,
@@ -355,6 +363,7 @@ function SuspendedByGroup({
inspectedElement,
store,
name,
environment,
suspendedBy,
minTime,
maxTime,
@@ -407,7 +416,12 @@ function SuspendedByGroup({
<span className={styles.CollapsableHeaderTitle}>{pluralizedName}</span>
<div className={styles.CollapsableHeaderFiller} />
{isOpen ? null : (
<div className={styles.TimeBarContainer}>
<div
className={
styles.TimeBarContainer +
' ' +
getClassNameForEnvironment(environment)
}>
<div
className={
!isRejected ? styles.TimeBarSpan : styles.TimeBarSpanErrored
@@ -502,17 +516,21 @@ 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);
}
@@ -591,6 +609,7 @@ 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

@@ -0,0 +1,14 @@
.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

@@ -0,0 +1,20 @@
/**
* 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,12 +1,25 @@
.SuspenseRectsContainer {
padding: .25rem;
cursor: pointer;
outline: 1px solid var(--color-component-name);
outline-color: transparent;
outline-style: solid;
outline-width: 1px;
border-radius: 0.25rem;
}
.SuspenseRectsContainer[data-highlighted='true'] {
background: var(--color-dimmest);
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);
}
.SuspenseRectsViewBox {
@@ -15,6 +28,11 @@
.SuspenseRectsBoundary {
pointer-events: all;
border-radius: 0.125rem;
}
.SuspenseRectsBoundary[data-visible='false'] {
background-color: transparent;
}
.SuspenseRectsBoundaryChildren {
@@ -28,15 +46,18 @@
.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'] {
@@ -44,15 +65,28 @@
outline-width: 0;
}
.SuspenseRectsScaledRect[data-suspended='true'] {
opacity: 0.3;
.SuspenseRectsBoundary[data-suspended='true'] {
opacity: 0.33;
}
/* highlight this boundary */
.SuspenseRectsBoundary:hover:not(:has(.SuspenseRectsBoundary:hover)) > .SuspenseRectsRect, .SuspenseRectsBoundary[data-highlighted='true'] > .SuspenseRectsRect {
background-color: var(--color-background-hover);
.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;
}
.SuspenseRectsRect[data-highlighted='true'] {
background-color: var(--color-selected-tree-highlight-active);
.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;
}

View File

@@ -30,12 +30,15 @@ import {
SuspenseTreeStateContext,
SuspenseTreeDispatcherContext,
} from './SuspenseTreeContext';
import {getClassNameForEnvironment} from './SuspenseEnvironmentColors.js';
function ScaledRect({
className,
rect,
visible,
suspended,
selected,
hovered,
adjust,
...props
}: {
@@ -43,6 +46,8 @@ function ScaledRect({
rect: Rect,
visible: boolean,
suspended: boolean,
selected?: boolean,
hovered?: boolean,
adjust?: boolean,
...
}): React$Node {
@@ -58,6 +63,8 @@ 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,
@@ -77,7 +84,9 @@ function SuspenseRects({
const store = useContext(StoreContext);
const treeDispatch = useContext(TreeDispatcherContext);
const suspenseTreeDispatch = useContext(SuspenseTreeDispatcherContext);
const {uniqueSuspendersOnly} = useContext(SuspenseTreeStateContext);
const {uniqueSuspendersOnly, timeline, hoveredTimelineIndex} = useContext(
SuspenseTreeStateContext,
);
const {inspectedElementID} = useContext(TreeStateContext);
@@ -145,14 +154,33 @@ 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}
className={
styles.SuspenseRectsBoundary +
' ' +
getClassNameForEnvironment(environment)
}
visible={visible}
suspended={suspense.isSuspended}>
selected={selected}
suspended={suspense.isSuspended}
hovered={hovered}>
<ViewBox.Provider value={boundingBox}>
{visible &&
suspense.rects !== null &&
@@ -162,7 +190,6 @@ function SuspenseRects({
key={index}
className={styles.SuspenseRectsRect}
rect={rect}
data-highlighted={selected}
adjust={true}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
@@ -182,6 +209,13 @@ function SuspenseRects({
})}
</ScaledRect>
)}
{selected ? (
<ScaledRect
className={styles.SuspenseRectOutline}
rect={boundingBox}
adjust={true}
/>
) : null}
</ViewBox.Provider>
</ScaledRect>
);
@@ -307,7 +341,8 @@ 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} = useContext(SuspenseTreeStateContext);
const {roots, timeline, hoveredTimelineIndex, uniqueSuspendersOnly} =
useContext(SuspenseTreeStateContext);
// TODO: bbox does not consider uniqueSuspendersOnly filter
const boundingBox = getDocumentBoundingRect(store, roots);
@@ -351,13 +386,37 @@ 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}
className={
styles.SuspenseRectsContainer +
(hasRootSuspenders ? ' ' + styles.SuspenseRectsRoot : '') +
' ' +
getClassNameForEnvironment(rootEnvironment)
}
onClick={handleClick}
onDoubleClick={handleDoubleClick}
data-highlighted={isRootSelected}>
data-highlighted={isRootSelected}
data-hovered={isRootHovered}>
<ViewBox.Provider value={boundingBox}>
<div
className={styles.SuspenseRectsViewBox}

View File

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

View File

@@ -7,6 +7,8 @@
* @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';
@@ -14,11 +16,14 @@ 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,
@@ -29,6 +34,7 @@ export default function SuspenseScrubber({
}: {
min: number,
max: number,
timeline: $ReadOnlyArray<SuspenseTimelineStep>,
value: number,
highlight: number,
onBlur?: () => void,
@@ -54,17 +60,18 @@ 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={
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'
}>
<Tooltip key={index} label={label}>
<div
className={
styles.SuspenseScrubberStep +
@@ -79,9 +86,10 @@ 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];
const nextSelectedSuspenseID = timeline[nextTimelineIndex].id;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -53,13 +53,22 @@ function SuspenseTimelineInput() {
switchSuspenseNode(timelineIndex);
}
function handleHoverSegment(hoveredValue: number) {
// TODO: Consider highlighting the rect instead.
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 handleUnhoverSegment() {}
function skipPrevious() {
const nextSelectedSuspenseID = timeline[timelineIndex - 1];
const nextSelectedSuspenseID = timeline[timelineIndex - 1].id;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -71,7 +80,7 @@ function SuspenseTimelineInput() {
}
function skipForward() {
const nextSelectedSuspenseID = timeline[timelineIndex + 1];
const nextSelectedSuspenseID = timeline[timelineIndex + 1].id;
treeDispatch({
type: 'SELECT_ELEMENT_BY_ID',
payload: nextSelectedSuspenseID,
@@ -97,7 +106,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);
const suspendedSet = timeline.slice(timelineIndex + 1).map(step => step.id);
bridge.send('overrideSuspenseMilestone', {
suspendedSet,
});
@@ -164,6 +173,7 @@ function SuspenseTimelineInput() {
<SuspenseScrubber
min={min}
max={max}
timeline={timeline}
value={timelineIndex}
highlight={hoveredTimelineIndex}
onChange={handleChange}

View File

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

View File

@@ -193,6 +193,11 @@ 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,
@@ -201,6 +206,7 @@ export type SuspenseNode = {
rects: null | Array<Rect>,
hasUniqueSuspenders: boolean,
isSuspended: boolean,
environments: Array<string>,
};
// Serialized version of ReactIOInfo

View File

@@ -1305,3 +1305,18 @@ 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,4 +1136,37 @@ 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,7 +37,11 @@ export function flushBuffered(destination: Destination) {
// transform streams. https://github.com/whatwg/streams/issues/960
}
const VIEW_SIZE = 2048;
// 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;
let currentView = null;
let writtenBytes = 0;
@@ -147,14 +151,7 @@ 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.
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;
return new Uint8Array(content.buffer, content.byteOffset, content.byteLength);
}
export function byteLengthOfChunk(chunk: Chunk | PrecomputedChunk): number {

View File

@@ -38,7 +38,11 @@ export function flushBuffered(destination: Destination) {
}
}
const VIEW_SIZE = 2048;
// 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;
let currentView = null;
let writtenBytes = 0;
let destinationHasCapacity = true;