Compare commits

...

10 Commits

Author SHA1 Message Date
Joe Savona
8a7e8557da [compiler] Support inline enums (flow/ts), type declarations
Supports inline enum declarations in both Flow and TS by treating the node as pass-through (enums can't capture values mutably). Related, this PR extends the set of type-related declarations that we ignore. Previously we threw a todo for things like DeclareClass or DeclareVariable, but these are type related and can simply be dropped just like we dropped TypeAlias.
2025-07-09 22:20:16 -07:00
Joseph Savona
4a3ff8eed6 [compiler] Errors for eval(), with statments, class declarations (#33746)
* Error for `eval()`
* More specific error message for `with (expr) { ... }` syntax
* More specific error message for class declarations

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33746).
* #33752
* #33751
* #33750
* #33748
* #33747
* __->__ #33746
2025-07-09 22:18:30 -07:00
Joseph Savona
ec4374c387 [compiler] Show logged errors in playground (#33740)
In playground it's helpful to show all errors, even those that don't
completely abort compilation. For example, to help demonstrate that the
compiler catches things like setState in effects. This detects these
errors and ensures we show them.
2025-07-09 09:22:49 -07:00
Sebastian Markbåge
60b5271a9a [Flight] Call finishHaltedTask on sync aborted tasks in stream abort listeners (#33743)
This is the same as we do for currently rendering tasks. They get
effectively sync aborted when the listener is invoked.

We potentially miss out on some debug info in that case but that would
only apply to any entries inside the stream which doesn't really have
their own debug info anyway.
2025-07-09 10:43:56 -04:00
Sebastian Markbåge
033edca721 [Flight] Yolo Retention of Promises (#33737)
Follow up to #33736.

If we need to save on CPU/memory pressure, we can instead just pray and
hope that a Promise doesn't get garbage collected before we need to read
it.

This can cause fragile access to the Promise value in devtools
especially if it's a slow and pressured render.

Basically, you'd have to hope that GC doesn't run after the inner await
finishes its microtask callback and before the resolution of the
component being rendered is invoked.
2025-07-09 10:39:08 -04:00
Sebastian Markbåge
e6dc25daea [Flight] Always defer Promise values if they're not already resolved (#33742)
If we have the ability to lazy load Promise values, i.e. if we have a
debug channel, then we should always use it for Promises that aren't
already resolved and instrumented.

There's little downside to this since they're async anyway.

This also lets us avoid adding `.then()` listeners too early. E.g. if
adding the listener would have side-effect. This avoids covering up
"unhandled rejection" errors. Since if we listen to a promise eagerly,
including reject listeners, we'd have marked that Promise's rejection as
handled where as maybe it wouldn't have been otherwise.

In this mode we can also indefinitely wait for the Promise to resolve
instead of just waiting a microtask for it to resolve.
2025-07-09 09:08:27 -04:00
Sebastian Markbåge
150f022444 [Flight] Ignore async stack frames when determining if a Promise was created from user space (#33739)
We use the stack of a Promise as the start of the I/O instead of the
actual I/O since that can symbolize the start of the operation even if
the actual I/O is batched, deduped or pooled. It can also group multiple
I/O operations into one.

We want the deepest possible Promise since otherwise it would just be
the Component's Promise.

However, we don't really need deeper than the boundary between first
party and third party. We can't just take the outer most that has third
party things on the stack though because third party can have callbacks
into first party and then we want the inner one. So we take the inner
most Promise that depends on I/O that has a first party stack on it.

The realization is that for the purposes of determining whether we have
a first party stack we need to ignore async stack frames. They can
appear on the stack when we resume third party code inside a resumption
frame of a first party stack.

<img width="832" alt="Screenshot 2025-07-08 at 6 34 25 PM"
src="https://github.com/user-attachments/assets/1636f980-be4c-4340-ad49-8d2b31953436"
/>

---------

Co-authored-by: Sebastian Sebbie Silbermann <sebastian.silbermann@vercel.com>
2025-07-09 09:08:09 -04:00
Sebastian Markbåge
49ded1d12a [Flight] Optimize Retention of Weak Promises Abit (#33736)
We don't really need to retain a reference to whatever Promise another
Promise was created in. Only awaits need to retain both their trigger
and their previous context.
2025-07-09 09:07:06 -04:00
Sebastian Markbåge
3a43e72d66 [Flight] Create a fast path parseStackTrace which skips generating a string stack (#33735)
When we know that the object that we pass in is immediately parsed, then
we know it couldn't have been reified into a unstructured stack yet. In
this path we assume that we'll trigger `Error.prepareStackTrace`.

Since we know that nobody else will read the stack after us, we can skip
generating a string stack and just return empty. We can also skip
caching.
2025-07-09 09:06:55 -04:00
Sebastian Markbåge
8ba3501cd9 [Flight] Don't dedupe references to deferred objects (#33741)
If we're about to defer an object, then we shouldn't store a reference
to it because then we can end up deduping by referring to the deferred
string. If in a different context, we should still be able to emit the
object.
2025-07-08 21:47:33 -04:00
20 changed files with 701 additions and 80 deletions

View File

@@ -44,6 +44,7 @@ import {
PrintedCompilerPipelineValue,
} from './Output';
import {transformFromAstSync} from '@babel/core';
import {LoggerEvent} from 'babel-plugin-react-compiler/dist/Entrypoint';
function parseInput(
input: string,
@@ -143,6 +144,7 @@ const COMMON_HOOKS: Array<[string, Hook]> = [
function compile(source: string): [CompilerOutput, 'flow' | 'typescript'] {
const results = new Map<string, Array<PrintedCompilerPipelineValue>>();
const error = new CompilerError();
const otherErrors: Array<CompilerErrorDetail> = [];
const upsert: (result: PrintedCompilerPipelineValue) => void = result => {
const entry = results.get(result.name);
if (Array.isArray(entry)) {
@@ -210,7 +212,11 @@ function compile(source: string): [CompilerOutput, 'flow' | 'typescript'] {
},
logger: {
debugLogIRs: logIR,
logEvent: () => {},
logEvent: (_filename: string | null, event: LoggerEvent) => {
if (event.kind === 'CompileError') {
otherErrors.push(new CompilerErrorDetail(event.detail));
}
},
},
});
transformOutput = invokeCompiler(source, language, opts);
@@ -237,6 +243,10 @@ function compile(source: string): [CompilerOutput, 'flow' | 'typescript'] {
);
}
}
// Only include logger errors if there weren't other errors
if (!error.hasErrors() && otherErrors.length !== 0) {
otherErrors.forEach(e => error.push(e));
}
if (error.hasErrors()) {
return [{kind: 'err', results, error: error}, language];
}

View File

@@ -1355,13 +1355,48 @@ function lowerStatement(
return;
}
case 'TypeAlias':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration': {
// We do not preserve type annotations/syntax through transformation
case 'WithStatement': {
builder.errors.push({
reason: `JavaScript 'with' syntax is not supported`,
description: `'with' syntax is considered deprecated and removed from JavaScript standards, consider alternatives`,
severity: ErrorSeverity.InvalidJS,
loc: stmtPath.node.loc ?? null,
suggestions: null,
});
lowerValueToTemporary(builder, {
kind: 'UnsupportedNode',
loc: stmtPath.node.loc ?? GeneratedSource,
node: stmtPath.node,
});
return;
}
case 'ClassDeclaration': {
/*
* We can in theory support nested classes, similarly to functions where we track values
* captured by the class and consider mutations of the instances to mutate the class itself
*/
builder.errors.push({
reason: `Support nested class declarations`,
severity: ErrorSeverity.Todo,
loc: stmtPath.node.loc ?? null,
suggestions: null,
});
lowerValueToTemporary(builder, {
kind: 'UnsupportedNode',
loc: stmtPath.node.loc ?? GeneratedSource,
node: stmtPath.node,
});
return;
}
case 'EnumDeclaration':
case 'TSEnumDeclaration': {
lowerValueToTemporary(builder, {
kind: 'UnsupportedNode',
loc: stmtPath.node.loc ?? GeneratedSource,
node: stmtPath.node,
});
return;
}
case 'ClassDeclaration':
case 'DeclareClass':
case 'DeclareExportAllDeclaration':
case 'DeclareExportDeclaration':
@@ -1372,20 +1407,23 @@ function lowerStatement(
case 'DeclareOpaqueType':
case 'DeclareTypeAlias':
case 'DeclareVariable':
case 'EnumDeclaration':
case 'InterfaceDeclaration':
case 'OpaqueType':
case 'TSDeclareFunction':
case 'TSInterfaceDeclaration':
case 'TSTypeAliasDeclaration':
case 'TypeAlias': {
// We do not preserve type annotations/syntax through transformation
return;
}
case 'ExportAllDeclaration':
case 'ExportDefaultDeclaration':
case 'ExportNamedDeclaration':
case 'ImportDeclaration':
case 'InterfaceDeclaration':
case 'OpaqueType':
case 'TSDeclareFunction':
case 'TSEnumDeclaration':
case 'TSExportAssignment':
case 'TSImportEqualsDeclaration':
case 'TSModuleDeclaration':
case 'TSNamespaceExportDeclaration':
case 'WithStatement': {
case 'TSNamespaceExportDeclaration': {
builder.errors.push({
reason: `(BuildHIR::lowerStatement) Handle ${stmtPath.type} statements`,
severity: ErrorSeverity.Todo,
@@ -3502,6 +3540,16 @@ function lowerIdentifier(
return place;
}
default: {
if (binding.kind === 'Global' && binding.name === 'eval') {
builder.errors.push({
reason: `The 'eval' function is not supported`,
description:
'Eval is an anti-pattern in JavaScript, and the code executed cannot be evaluated by React Compiler',
severity: ErrorSeverity.InvalidJS,
loc: exprPath.node.loc ?? null,
suggestions: null,
});
}
return lowerValueToTemporary(builder, {
kind: 'LoadGlobal',
binding,

View File

@@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree.
*/
import generate from '@babel/generator';
import {CompilerError} from '../CompilerError';
import {printReactiveScopeSummary} from '../ReactiveScopes/PrintReactiveFunction';
import DisjointSet from '../Utils/DisjointSet';
@@ -466,7 +465,7 @@ export function printInstructionValue(instrValue: ReactiveValue): string {
break;
}
case 'UnsupportedNode': {
value = `UnsupportedNode(${generate(instrValue.node).code})`;
value = `UnsupportedNode ${instrValue.node.type}`;
break;
}
case 'LoadLocal': {

View File

@@ -829,12 +829,14 @@ class CollectDependenciesVisitor extends ReactiveFunctionVisitor<
};
}
case 'UnsupportedNode': {
CompilerError.invariant(false, {
reason: `Unexpected unsupported node`,
description: null,
loc: value.loc,
suggestions: null,
});
const lvalues = [];
if (lvalue !== null) {
lvalues.push({place: lvalue, level: MemoizationLevel.Never});
}
return {
lvalues,
rvalues: [],
};
}
default: {
assertExhaustive(

View File

@@ -0,0 +1,24 @@
## Input
```javascript
function Component(props) {
eval('props.x = true');
return <div />;
}
```
## Error
```
1 | function Component(props) {
> 2 | eval('props.x = true');
| ^^^^ InvalidJS: The 'eval' function is not supported. Eval is an anti-pattern in JavaScript, and the code executed cannot be evaluated by React Compiler (2:2)
3 | return <div />;
4 | }
5 |
```

View File

@@ -0,0 +1,4 @@
function Component(props) {
eval('props.x = true');
return <div />;
}

View File

@@ -84,7 +84,7 @@ let moduleLocal = false;
> 3 | var x = [];
| ^^^^^^^^^^^ Todo: (BuildHIR::lowerStatement) Handle var kinds in VariableDeclaration (3:3)
Todo: (BuildHIR::lowerStatement) Handle ClassDeclaration statements (5:10)
Todo: Support nested class declarations (5:10)
Todo: (BuildHIR::lowerStatement) Handle non-variable initialization in ForStatement (20:22)

View File

@@ -0,0 +1,60 @@
## Input
```javascript
// @flow
function Component(props) {
enum Bool {
True = 'true',
False = 'false',
}
let bool: Bool = Bool.False;
if (props.value) {
bool = Bool.True;
}
return <div>{bool}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: true}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
function Component(props) {
const $ = _c(2);
enum Bool {
True = "true",
False = "false",
}
let bool = Bool.False;
if (props.value) {
bool = Bool.True;
}
let t0;
if ($[0] !== bool) {
t0 = <div>{bool}</div>;
$[0] = bool;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: true }],
};
```
### Eval output
(kind: exception) Bool is not defined

View File

@@ -0,0 +1,18 @@
// @flow
function Component(props) {
enum Bool {
True = 'true',
False = 'false',
}
let bool: Bool = Bool.False;
if (props.value) {
bool = Bool.True;
}
return <div>{bool}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: true}],
};

View File

@@ -0,0 +1,59 @@
## Input
```javascript
function Component(props) {
enum Bool {
True = 'true',
False = 'false',
}
let bool: Bool = Bool.False;
if (props.value) {
bool = Bool.True;
}
return <div>{bool}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: true}],
};
```
## Code
```javascript
import { c as _c } from "react/compiler-runtime";
function Component(props) {
const $ = _c(2);
enum Bool {
True = "true",
False = "false",
}
let bool = Bool.False;
if (props.value) {
bool = Bool.True;
}
let t0;
if ($[0] !== bool) {
t0 = <div>{bool}</div>;
$[0] = bool;
$[1] = t0;
} else {
t0 = $[1];
}
return t0;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{ value: true }],
};
```
### Eval output
(kind: ok) <div>true</div>

View File

@@ -0,0 +1,17 @@
function Component(props) {
enum Bool {
True = 'true',
False = 'false',
}
let bool: Bool = Bool.False;
if (props.value) {
bool = Bool.True;
}
return <div>{bool}</div>;
}
export const FIXTURE_ENTRYPOINT = {
fn: Component,
params: [{value: true}],
};

View File

@@ -53,6 +53,15 @@ const React = require('react');
const activeDebugChannels =
process.env.NODE_ENV === 'development' ? new Map() : null;
function filterStackFrame(sourceURL, functionName) {
return (
sourceURL !== '' &&
!sourceURL.startsWith('node:') &&
!sourceURL.includes('node_modules') &&
!sourceURL.endsWith('library.js')
);
}
function getDebugChannel(req) {
if (process.env.NODE_ENV !== 'development') {
return undefined;
@@ -123,6 +132,7 @@ async function renderApp(
const payload = {root, returnValue, formState};
const {pipe} = renderToPipeableStream(payload, moduleMap, {
debugChannel: await promiseForDebugChannel,
filterStackFrame,
});
pipe(res);
}
@@ -178,7 +188,9 @@ async function prerenderApp(res, returnValue, formState, noCache) {
);
// For client-invoked server actions we refresh the tree and return a return value.
const payload = {root, returnValue, formState};
const {prelude} = await prerenderToNodeStream(payload, moduleMap);
const {prelude} = await prerenderToNodeStream(payload, moduleMap, {
filterStackFrame,
});
prelude.pipe(res);
}

View File

@@ -24,6 +24,7 @@ import {GenerateImage} from './GenerateImage.js';
import {like, greet, increment} from './actions.js';
import {getServerState} from './ServerState.js';
import {sdkMethod} from './library.js';
const promisedText = new Promise(resolve =>
setTimeout(() => resolve('deferred text'), 50)
@@ -180,6 +181,7 @@ let veryDeepObject = [
export default async function App({prerender, noCache}) {
const res = await fetch('http://localhost:3001/todos');
const todos = await res.json();
await sdkMethod('http://localhost:3001/todos');
console.log('Expand me:', veryDeepObject);

View File

@@ -0,0 +1,9 @@
export async function sdkMethod(input, init) {
return fetch(input, init).then(async response => {
await new Promise(resolve => {
setTimeout(resolve, 10);
});
return response;
});
}

View File

@@ -2048,6 +2048,18 @@ function parseModelString(
if (value.length > 2) {
const debugChannel = response._debugChannel;
if (debugChannel) {
if (value[2] === '@') {
// This is a deferred Promise.
const ref = value.slice(3); // We assume this doesn't have a path just id.
const id = parseInt(ref, 16);
if (!response._chunks.has(id)) {
// We haven't seen this id before. Query the server to start sending it.
debugChannel('P:' + ref);
}
// Start waiting. This now creates a pending chunk if it doesn't already exist.
// This is the actual Promise we're waiting for.
return getChunk(response, id);
}
const ref = value.slice(2); // We assume this doesn't have a path just id.
const id = parseInt(ref, 16);
if (!response._chunks.has(id)) {

View File

@@ -94,6 +94,7 @@ import {
getCurrentAsyncSequence,
getAsyncSequenceFromPromise,
parseStackTrace,
parseStackTracePrivate,
supportsComponentStorage,
componentStorage,
unbadgeConsole,
@@ -259,7 +260,15 @@ function hasUnfilteredFrame(request: Request, stack: ReactStackTrace): boolean {
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
if (filterStackFrame(url, functionName, lineNumber, columnNumber)) {
// Ignore async stack frames because they're not "real". We'd expect to have at least
// one non-async frame if we're actually executing inside a first party function.
// Otherwise we might just be in the resume of a third party function that resumed
// inside a first party stack.
const isAsync = callsite[6];
if (
!isAsync &&
filterStackFrame(url, functionName, lineNumber, columnNumber)
) {
return true;
}
}
@@ -316,7 +325,7 @@ function patchConsole(consoleInst: typeof console, methodName: string) {
// one stack frame but keeping it simple for now and include all frames.
const stack = filterStackTrace(
request,
parseStackTrace(new Error('react-stack-top-frame'), 1),
parseStackTracePrivate(new Error('react-stack-top-frame'), 1) || [],
);
request.pendingDebugChunks++;
const owner: null | ReactComponentInfo = resolveOwner();
@@ -792,6 +801,19 @@ function serializeDebugThenable(
return ref;
}
const deferredDebugObjects = request.deferredDebugObjects;
if (deferredDebugObjects !== null) {
// For Promises that are not yet resolved, we always defer them. They are async anyway so it's
// safe to defer them. This also ensures that we don't eagerly call .then() on a Promise that
// otherwise wouldn't have initialized. It also ensures that we don't "handle" a rejection
// that otherwise would have triggered unhandled rejection.
deferredDebugObjects.retained.set(id, (thenable: any));
const deferredRef = '$Y@' + id.toString(16);
// We can now refer to the deferred object in the future.
request.writtenDebugObjects.set(thenable, deferredRef);
return deferredRef;
}
let cancelled = false;
thenable.then(
@@ -844,6 +866,36 @@ function serializeDebugThenable(
return ref;
}
function emitRequestedDebugThenable(
request: Request,
id: number,
counter: {objectLimit: number},
thenable: Thenable<any>,
): void {
thenable.then(
value => {
if (request.status === ABORTING) {
emitDebugHaltChunk(request, id);
enqueueFlush(request);
return;
}
emitOutlinedDebugModelChunk(request, id, counter, value);
enqueueFlush(request);
},
reason => {
if (request.status === ABORTING) {
emitDebugHaltChunk(request, id);
enqueueFlush(request);
return;
}
// We don't log these errors since they didn't actually throw into Flight.
const digest = '';
emitErrorChunk(request, id, digest, reason, true);
enqueueFlush(request);
},
);
}
function serializeThenable(
request: Request,
task: Task,
@@ -1027,8 +1079,9 @@ function serializeReadableStream(
signal.removeEventListener('abort', abortStream);
const reason = signal.reason;
if (enableHalt && request.type === PRERENDER) {
haltTask(streamTask, request);
request.abortableTasks.delete(streamTask);
haltTask(streamTask, request);
finishHaltedTask(streamTask, request);
} else {
// TODO: Make this use abortTask() instead.
erroredTask(request, streamTask, reason);
@@ -1156,8 +1209,9 @@ function serializeAsyncIterable(
signal.removeEventListener('abort', abortIterable);
const reason = signal.reason;
if (enableHalt && request.type === PRERENDER) {
haltTask(streamTask, request);
request.abortableTasks.delete(streamTask);
haltTask(streamTask, request);
finishHaltedTask(streamTask, request);
} else {
// TODO: Make this use abortTask() instead.
erroredTask(request, streamTask, signal.reason);
@@ -2916,7 +2970,9 @@ function serializeBlob(request: Request, blob: Blob): string {
signal.removeEventListener('abort', abortBlob);
const reason = signal.reason;
if (enableHalt && request.type === PRERENDER) {
request.abortableTasks.delete(newTask);
haltTask(newTask, request);
finishHaltedTask(newTask, request);
} else {
// TODO: Make this use abortTask() instead.
erroredTask(request, newTask, reason);
@@ -4345,6 +4401,12 @@ function renderDebugModel(
if (parentReference !== undefined) {
// If the parent has a reference, we can refer to this object indirectly
// through the property name inside that parent.
if (counter.objectLimit <= 0 && !doNotLimit.has(value)) {
// If we are going to defer this, don't dedupe it since then we'd dedupe it to be
// deferred in future reference.
return serializeDeferredObject(request, value);
}
let propertyName = parentPropertyName;
if (isArray(parent) && parent[0] === REACT_ELEMENT_TYPE) {
// For elements, we've converted it to an array but we'll have converted
@@ -4369,8 +4431,15 @@ function renderDebugModel(
} else if (debugNoOutline !== value) {
// If this isn't the root object (like meta data) and we don't have an id for it, outline
// it so that we can dedupe it by reference later.
const outlinedId = outlineDebugModel(request, counter, value);
return serializeByValueID(outlinedId);
// $FlowFixMe[method-unbinding]
if (typeof value.then === 'function') {
// If this is a Promise we're going to assign it an external ID anyway which can be deduped.
const thenable: Thenable<any> = (value: any);
return serializeDebugThenable(request, counter, thenable);
} else {
const outlinedId = outlineDebugModel(request, counter, value);
return serializeByValueID(outlinedId);
}
}
}
@@ -5772,6 +5841,25 @@ export function resolveDebugMessage(request: Request, message: string): void {
}
}
break;
case 80 /* "P" */:
// Query Promise IDs
for (let i = 0; i < ids.length; i++) {
const id = ids[i];
const retainedValue = deferredDebugObjects.retained.get(id);
if (retainedValue !== undefined) {
// If we still have this Promise, and haven't emitted it before, wait for it
// and then emit it on the stream.
const counter = {objectLimit: 10};
deferredDebugObjects.retained.delete(id);
emitRequestedDebugThenable(
request,
id,
counter,
(retainedValue: any),
);
}
}
break;
default:
throw new Error(
'Unknown command. The debugChannel was not wired up properly.',

View File

@@ -29,7 +29,7 @@ import {resolveOwner} from './flight/ReactFlightCurrentOwner';
import {resolveRequest, isAwaitInUserspace} from './ReactFlightServer';
import {createHook, executionAsyncId, AsyncResource} from 'async_hooks';
import {enableAsyncDebugInfo} from 'shared/ReactFeatureFlags';
import {parseStackTrace} from './ReactFlightServerConfig';
import {parseStackTracePrivate} from './ReactFlightServerConfig';
// $FlowFixMe[method-unbinding]
const getAsyncId = AsyncResource.prototype.asyncId;
@@ -37,23 +37,6 @@ const getAsyncId = AsyncResource.prototype.asyncId;
const pendingOperations: Map<number, AsyncSequence> =
__DEV__ && enableAsyncDebugInfo ? new Map() : (null: any);
// This is a weird one. This map, keeps a dependent Promise alive if the child Promise is still alive.
// A PromiseNode/AwaitNode cannot hold a strong reference to its own Promise because then it'll never get
// GC:ed. We only need it if a dependent AwaitNode points to it. We could put a reference in the Node
// but that would require a GC pass between every Node that gets destroyed. I.e. the root gets destroy()
// called on it and then that release it from the pendingOperations map which allows the next one to GC
// and so on. By putting this relationship in a WeakMap this could be done as a single pass in the VM.
// We don't actually ever have to read from this map since we have WeakRef reference to these Promises
// if they're still alive. It's also optional information so we could just expose only if GC didn't run.
const awaitedPromise: WeakMap<Promise<any>, Promise<any>> = __DEV__ &&
enableAsyncDebugInfo
? new WeakMap()
: (null: any);
const previousPromise: WeakMap<Promise<any>, Promise<any>> = __DEV__ &&
enableAsyncDebugInfo
? new WeakMap()
: (null: any);
// Keep the last resolved await as a workaround for async functions missing data.
let lastRanAwait: null | AwaitNode = null;
@@ -88,14 +71,6 @@ export function initAsyncDebugInfo(): void {
const trigger = pendingOperations.get(triggerAsyncId);
let node: AsyncSequence;
if (type === 'PROMISE') {
if (trigger !== undefined && trigger.promise !== null) {
const triggerPromise = trigger.promise.deref();
if (triggerPromise !== undefined) {
// Keep the awaited Promise alive as long as the child is alive so we can
// trace its value at the end.
awaitedPromise.set(resource, triggerPromise);
}
}
const currentAsyncId = executionAsyncId();
if (currentAsyncId !== triggerAsyncId) {
// When you call .then() on a native Promise, or await/Promise.all() a thenable,
@@ -104,18 +79,10 @@ export function initAsyncDebugInfo(): void {
// We don't track awaits on things that started outside our tracked scope.
return;
}
const current = pendingOperations.get(currentAsyncId);
if (current !== undefined && current.promise !== null) {
const currentPromise = current.promise.deref();
if (currentPromise !== undefined) {
// Keep the previous Promise alive as long as the child is alive so we can
// trace its value at the end.
previousPromise.set(resource, currentPromise);
}
}
// If the thing we're waiting on is another Await we still track that sequence
// so that we can later pick the best stack trace in user space.
let stack = null;
let promiseRef: WeakRef<Promise<any>>;
if (
trigger.stack !== null &&
(trigger.tag === AWAIT_NODE ||
@@ -124,13 +91,21 @@ export function initAsyncDebugInfo(): void {
// We already had a stack for an await. In a chain of awaits we'll only need one good stack.
// We mark it with an empty stack to signal to any await on this await that we have a stack.
stack = emptyStack;
if (resource._debugInfo !== undefined) {
// We may need to forward this debug info at the end so we need to retain this promise.
promiseRef = new WeakRef((resource: Promise<any>));
} else {
// Otherwise, we can just refer to the inner one since that's the one we'll log anyway.
promiseRef = trigger.promise;
}
} else {
promiseRef = new WeakRef((resource: Promise<any>));
const request = resolveRequest();
if (request === null) {
// We don't collect stacks for awaits that weren't in the scope of a specific render.
} else {
stack = parseStackTrace(new Error(), 5);
if (!isAwaitInUserspace(request, stack)) {
stack = parseStackTracePrivate(new Error(), 5);
if (stack !== null && !isAwaitInUserspace(request, stack)) {
// If this await was not done directly in user space, then clear the stack. We won't use it
// anyway. This lets future awaits on this await know that we still need to get their stacks
// until we find one in user space.
@@ -138,13 +113,14 @@ export function initAsyncDebugInfo(): void {
}
}
}
const current = pendingOperations.get(currentAsyncId);
node = ({
tag: UNRESOLVED_AWAIT_NODE,
owner: resolveOwner(),
stack: stack,
start: performance.now(),
end: -1.1, // set when resolved.
promise: new WeakRef((resource: Promise<any>)),
promise: promiseRef,
awaited: trigger, // The thing we're awaiting on. Might get overrriden when we resolve.
previous: current === undefined ? null : current, // The path that led us here.
}: UnresolvedAwaitNode);
@@ -153,7 +129,8 @@ export function initAsyncDebugInfo(): void {
node = ({
tag: UNRESOLVED_PROMISE_NODE,
owner: owner,
stack: owner === null ? null : parseStackTrace(new Error(), 5),
stack:
owner === null ? null : parseStackTracePrivate(new Error(), 5),
start: performance.now(),
end: -1.1, // Set when we resolve.
promise: new WeakRef((resource: Promise<any>)),
@@ -175,7 +152,8 @@ export function initAsyncDebugInfo(): void {
node = ({
tag: IO_NODE,
owner: owner,
stack: owner === null ? parseStackTrace(new Error(), 3) : null,
stack:
owner === null ? parseStackTracePrivate(new Error(), 3) : null,
start: performance.now(),
end: -1.1, // Only set when pinged.
promise: null,
@@ -191,7 +169,8 @@ export function initAsyncDebugInfo(): void {
node = ({
tag: IO_NODE,
owner: owner,
stack: owner === null ? parseStackTrace(new Error(), 3) : null,
stack:
owner === null ? parseStackTracePrivate(new Error(), 3) : null,
start: performance.now(),
end: -1.1, // Only set when pinged.
promise: null,

View File

@@ -49,7 +49,7 @@ function getMethodCallName(callSite: CallSite): string {
return result;
}
function collectStackTrace(
function collectStackTracePrivate(
error: Error,
structuredStackTrace: CallSite[],
): string {
@@ -63,7 +63,9 @@ function collectStackTrace(
// Skip everything after the bottom frame since it'll be internals.
break;
} else if (callSite.isNative()) {
result.push([name, '', 0, 0, 0, 0]);
// $FlowFixMe[prop-missing]
const isAsync = callSite.isAsync();
result.push([name, '', 0, 0, 0, 0, isAsync]);
} else {
// We encode complex function calls as if they're part of the function
// name since we cannot simulate the complex ones and they look the same
@@ -79,11 +81,11 @@ function collectStackTrace(
let filename = callSite.getScriptNameOrSourceURL() || '<anonymous>';
if (filename === '<anonymous>') {
filename = '';
}
if (callSite.isEval() && !filename) {
const origin = callSite.getEvalOrigin();
if (origin) {
filename = origin.toString() + ', <anonymous>';
if (callSite.isEval()) {
const origin = callSite.getEvalOrigin();
if (origin) {
filename = origin.toString() + ', <anonymous>';
}
}
}
const line = callSite.getLineNumber() || 0;
@@ -98,9 +100,28 @@ function collectStackTrace(
typeof callSite.getEnclosingColumnNumber === 'function'
? (callSite: any).getEnclosingColumnNumber() || 0
: 0;
result.push([name, filename, line, col, enclosingLine, enclosingCol]);
// $FlowFixMe[prop-missing]
const isAsync = callSite.isAsync();
result.push([
name,
filename,
line,
col,
enclosingLine,
enclosingCol,
isAsync,
]);
}
}
collectedStackTrace = result;
return '';
}
function collectStackTrace(
error: Error,
structuredStackTrace: CallSite[],
): string {
collectStackTracePrivate(error, structuredStackTrace);
// At the same time we generate a string stack trace just in case someone
// else reads it. Ideally, we'd call the previous prepareStackTrace to
// ensure it's in the expected format but it's common for that to be
@@ -115,7 +136,6 @@ function collectStackTrace(
for (let i = 0; i < structuredStackTrace.length; i++) {
stack += '\n at ' + structuredStackTrace[i].toString();
}
collectedStackTrace = result;
return stack;
}
@@ -131,6 +151,26 @@ const stackTraceCache: WeakMap<Error, ReactStackTrace> = __DEV__
? new WeakMap()
: (null: any);
// This version is only used when React fully owns the Error object and there's no risk of it having
// been already initialized and no risky that anyone else will initialize it later.
export function parseStackTracePrivate(
error: Error,
skipFrames: number,
): null | ReactStackTrace {
collectedStackTrace = null;
framesToSkip = skipFrames;
const previousPrepare = Error.prepareStackTrace;
Error.prepareStackTrace = collectStackTracePrivate;
try {
if (error.stack !== '') {
return null;
}
} finally {
Error.prepareStackTrace = previousPrepare;
}
return collectedStackTrace;
}
export function parseStackTrace(
error: Error,
skipFrames: number,
@@ -193,8 +233,12 @@ export function parseStackTrace(
continue;
}
let name = parsed[1] || '';
let isAsync = parsed[8] === 'async ';
if (name === '<anonymous>') {
name = '';
} else if (name.startsWith('async ')) {
name = name.slice(5);
isAsync = true;
}
let filename = parsed[2] || parsed[5] || '';
if (filename === '<anonymous>') {
@@ -202,7 +246,7 @@ export function parseStackTrace(
}
const line = +(parsed[3] || parsed[6]);
const col = +(parsed[4] || parsed[7]);
parsedFrames.push([name, filename, line, col, 0, 0]);
parsedFrames.push([name, filename, line, col, 0, 0, isAsync]);
}
stackTraceCache.set(error, parsedFrames);
return parsedFrames;

View File

@@ -2515,4 +2515,237 @@ describe('ReactFlightAsyncDebugInfo', () => {
`);
}
});
it('can track IO in third-party code', async () => {
async function thirdParty(endpoint) {
return new Promise(resolve => {
setTimeout(() => {
resolve('third-party ' + endpoint);
}, 10);
}).then(async value => {
await new Promise(resolve => {
setTimeout(resolve, 10);
});
return value;
});
}
async function Component() {
const value = await thirdParty('hi');
return value;
}
const stream = ReactServerDOMServer.renderToPipeableStream(
<Component />,
{},
{
filterStackFrame(filename, functionName) {
if (functionName === 'thirdParty') {
return false;
}
return filterStackFrame(filename, functionName);
},
},
);
const readable = new Stream.PassThrough(streamOptions);
const result = ReactServerDOMClient.createFromNodeStream(readable, {
moduleMap: {},
moduleLoading: {},
});
stream.pipe(readable);
expect(await result).toBe('third-party hi');
await finishLoadingStream(readable);
if (
__DEV__ &&
gate(
flags =>
flags.enableComponentPerformanceTrack && flags.enableAsyncDebugInfo,
)
) {
expect(getDebugInfo(result)).toMatchInlineSnapshot(`
[
{
"time": 0,
},
{
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
{
"time": 0,
},
{
"awaited": {
"end": 0,
"env": "Server",
"name": "",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2526,
15,
2525,
15,
],
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
19,
2534,
5,
],
],
"start": 0,
"value": {
"value": undefined,
},
},
"env": "Server",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2526,
15,
2525,
15,
],
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
19,
2534,
5,
],
],
},
{
"time": 0,
},
{
"awaited": {
"end": 0,
"env": "Server",
"name": "thirdParty",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
25,
2534,
5,
],
],
"start": 0,
"value": {
"value": "third-party hi",
},
},
"env": "Server",
"owner": {
"env": "Server",
"key": null,
"name": "Component",
"props": {},
"stack": [
[
"Object.<anonymous>",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2540,
40,
2519,
42,
],
],
},
"stack": [
[
"Component",
"/packages/react-server/src/__tests__/ReactFlightAsyncDebugInfo-test.js",
2535,
25,
2534,
5,
],
],
},
{
"time": 0,
},
{
"time": 0,
},
]
`);
}
});
});

View File

@@ -188,6 +188,7 @@ export type ReactCallSite = [
number, // column number
number, // enclosing line number
number, // enclosing column number
boolean, // async resume
];
export type ReactStackTrace = Array<ReactCallSite>;