Compare commits

..

19 Commits

Author SHA1 Message Date
Joe Savona
d221c0f565 [compiler] More precise errors for invalid import/export/namespace statements
import, export, and TS namespace statements can only be used at the top-level of a module, which is enforced by parsers already. Here we add a backup validation of that. As of this PR, we now have only major statement type (class declarations) listed as a todo.
2025-07-09 22:22:09 -07:00
Joseph Savona
81e1ee7476 [compiler] Support inline enums (flow/ts), type declarations (#33747)
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.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33747).
* #33753
* #33752
* #33751
* #33750
* #33748
* __->__ #33747
2025-07-09 22:21:02 -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
Joseph Savona
956d770adf [compiler] Improve IIFE inlining (#33726)
We currently inline IIFEs by creating a temporary and a labeled block w
the original code. The original return statements turn into an
assignment to the temporary and break out of the label. However, many
cases of IIFEs are due to inlining of manual `useMemo()`, and these
cases often have only a single return statement. Here, the output is
cleaner if we avoid the temporary and label - so that's what we do in
this PR.

Note that the most complex part of the change is actually around
ValidatePreserveExistingMemo - we have some logic to track the IIFE
temporary reassignmetns which needs to be updated to handle the simpler
version of inlining.

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33726).
* __->__ #33726
* #33725
2025-07-08 16:36:57 -07:00
Joseph Savona
d35fef9e21 [compiler] Fix for consecutive DCE'd branches with phis (#33725)
This is an optimized version of @asmjmp0's fix in
https://github.com/facebook/react/pull/31940. When we merge consecutive
blocks we need to take care to rewrite later phis whose operands will
now be different blocks due to merging. Rather than iterate all the
blocks on each merge as in #31940, we can do a single iteration over all
the phis at the end to fix them up.

Note: this is a redo of #31959

---
[//]: # (BEGIN SAPLING FOOTER)
Stack created with [Sapling](https://sapling-scm.com). Best reviewed
with [ReviewStack](https://reviewstack.dev/facebook/react/pull/33725).
* #33726
* __->__ #33725
2025-07-08 16:36:47 -07:00
Sebastian Markbåge
a7a116577d [Flight] Don't track Promise stack if there's no owner (#33734)
This is a compromise because there can be a lot of Promise instances
created. They're useful because they generally provide a better stack
when batching/pooled connections are used.

This restores stack collection for I/O nodes so we have something to
fallback on if there's no owner.

That way we can at least get a name or something out of I/O that was
spawned outside a render but mostly avoids collecting starting I/O
outside of render.
2025-07-08 13:02:29 -04:00
Sebastian Markbåge
777264b4ef [Flight] Fix stack getting object limited (#33733)
Because the object limit is unfortunately depth first due to limitations
of JSON stringify, we need to ensure that things we really don't want
outlined are first in the enumeration order.

We add the stack length to the object limit to ensure that the stack
frames aren't outlined. In console all the user space arguments are at
the end of the args. In server component props, the props are at the end
of the properties of the element.

For the `value` of I/O we had it before the stack so it could steal the
limit from the stack. The fix is to put it at the end.
2025-07-08 12:54:29 -04:00
Josh Story
befc1246b0 [Fizz] Render preamble eagerly (#33730)
We unnecessarily render the preamble in a task. This updates the
implementation to perform this render inline.

Testing this is tricky because one of the only ways you could assert
this was even happening is based on how things error if you abort while
rendering the root.

While adding a test for this I discovered that not all abortable tasks
report errors when aborted during a normal render. I've asserted the
current behavior and will address the other issue at another time and
updated the assertion later as necessary
2025-07-08 08:20:12 -07:00
Sebastian Markbåge
bbea677b77 [Flight] Lazy load objects from the debug channel (#33728)
When a debug channel is available, we now allow objects to be lazily
requested though the debug channel and only then will the server send
it.

The client will actually eagerly ask for the next level of objects once
it parses its payload. That way those objects have likely loaded by the
time you actually expand that deep e.g. in the console repl. This is
needed since the console repl is synchronous when you ask it to invoke
getters.

Each level is lazily parsed which means that we don't parse the next
level even though we eagerly loaded it. We parse it once the getter is
invoked (in Chrome DevTools you have to click a little `(...)` to invoke
the getter). When the getter is invoked, the chunk is initialized and
parsed. This then causes the next level to be asked for through the
debug channel. Ensuring that if you expand one more level you can do so
synchronously.

Currently debug chunks are eagerly parsed, which means that if you have
things like server component props that are lazy they can end up being
immediately asked for, but I'm trying to move to make the debug chunks
lazy.
2025-07-08 10:49:25 -04:00
Sebastian Markbåge
f1ecf82bfb [Flight] Optimize Async Stack Collection (#33727)
We need to optimize the collection of debug info for dev mode. This is
an incredibly hot path since it instruments all I/O and Promises in the
app.

These optimizations focus primarily on the collection of stack traces.
They are expensive to collect because we need to eagerly collect the
stacks since they can otherwise cause memory leaks. We also need to do
some of the processing of them up front. We also end up only using a few
of them in the end but we don't know which ones we'll use.

The first compromise here is that I now only collect the stacks of
"awaits" if they were in a specific request's render. In some cases it's
useful to collect them even outside of this if they're part of a
sequence that started early. I still collect stacks for the created
Promises outside of this though which can still provide some context.

The other optimization to awaits, is that since we'll only use the inner
most one that had an await directly in userspace, we can stop collecting
stacks on a chain of awaits after we find one. This requires a quick
filter on a single callsite to determine. Since we now only collect
stacks from awaits that belongs to a specific Request we can use that
request's specific filter option. Technically this might not be quite
correct if that same thing ends up deduped across Requests but that's an
edge case.

Additionally, I now stop collecting stack for I/O nodes. They're almost
always superseded by the Promise that wraps them anyway. Even if you
write mostly Promise free code, you'll likely end up with a Promise at
the root of the component eventually anyway and then you end up using
its stack anyway. You have to really contort the code to end up with
zero Promises at which point it's not very useful anyway. At best it's
maybe mostly useful for giving a name to the I/O when the rest is just
stuff like `new Promise`.

However, a possible alternative optimization could be to *only* collect
the stack of spawned I/O and not the stack of Promises. The issue with
Promises (not awaits) is that we never know what will end up resolving
them in the end when they're created so we have to always eagerly
collect stacks. This could be an issue when you have a lot of
abstractions that end up not actually be related to I/O at all. The
issue with collecting stacks only for I/O is that the actual I/O can be
pooled or batched so you end up not having the stack when the conceptual
start of each operation within the batch started. Which is why I decided
to keep the Promise stack.
2025-07-08 10:49:08 -04:00
Sebastian Markbåge
b44a99bf58 [Fiber] Name content inside "Suspense fallback" (#33724)
Same as #33723 but for Fiber.
2025-07-08 00:00:00 -04:00
25 changed files with 1470 additions and 548 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,83 @@ 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 'ExportAllDeclaration':
case 'ExportDefaultDeclaration':
case 'ExportNamedDeclaration':
case 'ImportDeclaration':
case 'TSExportAssignment':
case 'TSImportEqualsDeclaration': {
builder.errors.push({
reason:
'JavaScript `import` and `export` statements may only appear at the top level of a module',
severity: ErrorSeverity.InvalidJS,
loc: stmtPath.node.loc ?? null,
suggestions: null,
});
lowerValueToTemporary(builder, {
kind: 'UnsupportedNode',
loc: stmtPath.node.loc ?? GeneratedSource,
node: stmtPath.node,
});
return;
}
case 'TSNamespaceExportDeclaration': {
builder.errors.push({
reason:
'TypeScript `namespace` statements may only appear at the top level of a module',
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':
case 'DeclareClass':
case 'DeclareExportAllDeclaration':
case 'DeclareExportDeclaration':
@@ -1372,31 +1442,14 @@ function lowerStatement(
case 'DeclareOpaqueType':
case 'DeclareTypeAlias':
case 'DeclareVariable':
case 'EnumDeclaration':
case 'ExportAllDeclaration':
case 'ExportDefaultDeclaration':
case 'ExportNamedDeclaration':
case 'ImportDeclaration':
case 'InterfaceDeclaration':
case 'OpaqueType':
case 'TSDeclareFunction':
case 'TSEnumDeclaration':
case 'TSExportAssignment':
case 'TSImportEqualsDeclaration':
case 'TSInterfaceDeclaration':
case 'TSModuleDeclaration':
case 'TSNamespaceExportDeclaration':
case 'WithStatement': {
builder.errors.push({
reason: `(BuildHIR::lowerStatement) Handle ${stmtPath.type} statements`,
severity: ErrorSeverity.Todo,
loc: stmtPath.node.loc ?? null,
suggestions: null,
});
lowerValueToTemporary(builder, {
kind: 'UnsupportedNode',
loc: stmtPath.node.loc ?? GeneratedSource,
node: stmtPath.node,
});
case 'TSTypeAliasDeclaration':
case 'TypeAlias': {
// We do not preserve type annotations/syntax through transformation
return;
}
default: {
@@ -3502,6 +3555,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)
@@ -120,9 +121,69 @@ async function ServerComponent({noCache}) {
return await fetchThirdParty(noCache);
}
let veryDeepObject = [
{
bar: {
baz: {
a: {},
},
},
},
{
bar: {
baz: {
a: {},
},
},
},
{
bar: {
baz: {
a: {},
},
},
},
{
bar: {
baz: {
a: {
b: {
c: {
d: {
e: {
f: {
g: {
h: {
i: {
j: {
k: {
l: {
m: {
yay: 'You reached the end',
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
},
];
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);
const dedupedChild = <ServerComponent noCache={noCache} />;
const message = getServerState();

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

@@ -1774,6 +1774,40 @@ function applyConstructor(
return undefined;
}
function defineLazyGetter<T>(
response: Response,
chunk: SomeChunk<T>,
parentObject: Object,
key: string,
): any {
// We don't immediately initialize it even if it's resolved.
// Instead, we wait for the getter to get accessed.
Object.defineProperty(parentObject, key, {
get: function () {
if (chunk.status === RESOLVED_MODEL) {
// If it was now resolved, then we initialize it. This may then discover
// a new set of lazy references that are then asked for eagerly in case
// we get that deep.
initializeModelChunk(chunk);
}
switch (chunk.status) {
case INITIALIZED: {
return chunk.value;
}
case ERRORED:
throw chunk.reason;
}
// Otherwise, we didn't have enough time to load the object before it was
// accessed or the connection closed. So we just log that it was omitted.
// TODO: We should ideally throw here to indicate a difference.
return OMITTED_PROP_ERROR;
},
enumerable: true,
configurable: false,
});
return null;
}
function extractIterator(response: Response, model: Array<any>): Iterator<any> {
// $FlowFixMe[incompatible-use]: This uses raw Symbols because we're extracting from a native array.
return model[Symbol.iterator]();
@@ -2014,8 +2048,31 @@ function parseModelString(
if (value.length > 2) {
const debugChannel = response._debugChannel;
if (debugChannel) {
const ref = value.slice(2);
debugChannel('R:' + ref); // Release this reference immediately
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)) {
// We haven't seen this id before. Query the server to start sending it.
debugChannel('Q:' + ref);
}
// Start waiting. This now creates a pending chunk if it doesn't already exist.
const chunk = getChunk(response, id);
if (chunk.status === INITIALIZED) {
// We already loaded this before. We can just use the real value.
return chunk.value;
}
return defineLazyGetter(response, chunk, parentObject, key);
}
}

View File

@@ -9544,6 +9544,102 @@ describe('ReactDOMFizzServer', () => {
);
});
it('will attempt to render the preamble inline to allow rendering before a later abort in the same task', async () => {
const promise = new Promise(() => {});
function Pending() {
React.use(promise);
}
const controller = new AbortController();
function Abort() {
controller.abort();
return <Comp />;
}
function Comp() {
return null;
}
function App() {
return (
<html>
<head>
<meta content="here" />
</head>
<body>
<main>hello</main>
<Suspense>
<Pending />
</Suspense>
<Abort />
</body>
</html>
);
}
const signal = controller.signal;
let thrownError = null;
const errors = [];
try {
await act(() => {
const {pipe, abort} = renderToPipeableStream(<App />, {
onError(e, ei) {
errors.push({
error: e,
componentStack: normalizeCodeLocInfo(ei.componentStack),
});
},
});
signal.addEventListener('abort', () => abort('boom'));
pipe(writable);
});
} catch (e) {
thrownError = e;
}
expect(thrownError).toBe('boom');
// TODO there should actually be three errors. One for the pending Suspense, one for the fallback task, and one for the task
// that does the abort itself. At the moment abort will flush queues and if there is no pending tasks will close the request before
// the task which initiated the abort can even be processed. This is a bug but not one that I am fixing with the current change
// so I am asserting the current behavior
expect(errors).toEqual([
{
error: 'boom',
componentStack: componentStack([
'Pending',
'Suspense',
'body',
'html',
'App',
]),
},
{
error: 'boom',
componentStack: componentStack([
'Suspense Fallback',
'body',
'html',
'App',
]),
// }, {
// error: 'boom',
// componentStack: componentStack(['Abort', 'body', 'html', 'App'])
},
]);
// We expect the render to throw before streaming anything so the default
// document is still loaded
expect(getVisibleChildren(document)).toEqual(
<html>
<head />
<body>
<div id="container" />
</body>
</html>,
);
});
it('Will wait to flush Document chunks until all boundaries which might contain a preamble are errored or resolved', async () => {
let rejectFirst;
const firstPromise = new Promise((_, reject) => {

View File

@@ -34,7 +34,7 @@ import {
} from 'shared/ReactComponentStackFrame';
import {formatOwnerStack} from 'shared/ReactOwnerStackFrames';
function describeFiber(fiber: Fiber): string {
function describeFiber(fiber: Fiber, childFiber: null | Fiber): string {
switch (fiber.tag) {
case HostHoistable:
case HostSingleton:
@@ -44,6 +44,10 @@ function describeFiber(fiber: Fiber): string {
// TODO: When we support Thenables as component types we should rename this.
return describeBuiltInComponentFrame('Lazy');
case SuspenseComponent:
if (fiber.child !== childFiber && childFiber !== null) {
// If we came from the second Fiber then we're in the Suspense Fallback.
return describeBuiltInComponentFrame('Suspense Fallback');
}
return describeBuiltInComponentFrame('Suspense');
case SuspenseListComponent:
return describeBuiltInComponentFrame('SuspenseList');
@@ -70,8 +74,9 @@ export function getStackByFiberInDevAndProd(workInProgress: Fiber): string {
try {
let info = '';
let node: Fiber = workInProgress;
let previous: null | Fiber = null;
do {
info += describeFiber(node);
info += describeFiber(node, previous);
if (__DEV__) {
// Add any Server Component stack frames in reverse order.
const debugInfo = node._debugInfo;
@@ -88,6 +93,7 @@ export function getStackByFiberInDevAndProd(workInProgress: Fiber): string {
}
}
}
previous = node;
// $FlowFixMe[incompatible-type] we bail out when we get a null
node = node.return;
} while (node);

View File

@@ -87,7 +87,7 @@ describe('ReactFragment', () => {
function normalizeCodeLocInfo(str) {
return (
str &&
str.replace(/\n +(?:at|in) ([\S]+)[^\n]*/g, function (m, name) {
str.replace(/\n +(?:at|in) ([^\(]+) [^\n]*/g, function (m, name) {
return '\n in ' + name + ' (at **)';
})
);
@@ -168,6 +168,40 @@ describe('ReactFragment', () => {
]);
});
it('includes built-in for Suspense fallbacks', async () => {
const SomethingThatSuspends = React.lazy(() => {
return new Promise(() => {});
});
ReactNoop.createRoot({
onCaughtError,
}).render(
<CatchingBoundary>
<Suspense fallback={<SomethingThatErrors />}>
<SomethingThatSuspends />
</Suspense>
</CatchingBoundary>,
);
await waitForAll([]);
expect(didCatchErrors).toEqual([
'uh oh',
componentStack([
'SomethingThatErrors',
'Suspense Fallback',
'CatchingBoundary',
]),
]);
expect(rootCaughtErrors).toEqual([
'uh oh',
componentStack([
'SomethingThatErrors',
'Suspense Fallback',
'CatchingBoundary',
]),
__DEV__ ? componentStack(['SomethingThatErrors']) : null,
]);
});
// @gate enableActivity
it('includes built-in for Activity', async () => {
ReactNoop.createRoot({

View File

@@ -2206,7 +2206,7 @@ function renderSuspenseList(
function renderPreamble(
request: Request,
task: Task,
task: RenderTask,
blockedSegment: Segment,
node: ReactNodeList,
): void {
@@ -2219,28 +2219,21 @@ function renderPreamble(
false,
);
blockedSegment.preambleChildren.push(preambleSegment);
// @TODO we can just attempt to render in the current task rather than spawning a new one
const preambleTask = createRenderTask(
request,
null,
node,
-1,
task.blockedBoundary,
preambleSegment,
task.blockedPreamble,
task.hoistableState,
request.abortableTasks,
task.keyPath,
task.formatContext,
task.context,
task.treeContext,
task.row,
task.componentStack,
!disableLegacyContext ? task.legacyContext : emptyContextObject,
__DEV__ ? task.debugTask : null,
);
pushComponentStack(preambleTask);
request.pingedTasks.push(preambleTask);
task.blockedSegment = preambleSegment;
try {
preambleSegment.status = RENDERING;
renderNode(request, task, node, -1);
pushSegmentFinale(
preambleSegment.chunks,
request.renderState,
preambleSegment.lastPushedText,
preambleSegment.textEmbedded,
);
preambleSegment.status = COMPLETED;
finishedSegment(request, task.blockedBoundary, preambleSegment);
} finally {
task.blockedSegment = blockedSegment;
}
}
function renderHostElement(
@@ -2292,7 +2285,8 @@ function renderHostElement(
props,
));
if (isPreambleContext(newContext)) {
renderPreamble(request, task, segment, children);
// $FlowFixMe: Refined
renderPreamble(request, (task: RenderTask), segment, children);
} else {
// We use the non-destructive form because if something suspends, we still
// need to pop back up and finish this subtree of HTML.

View File

@@ -26,7 +26,7 @@ type PromiseWithDebugInfo = interface extends Promise<any> {
export type IONode = {
tag: 0,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that spawned the I/O
stack: null | ReactStackTrace, // callsite that spawned the I/O
start: number, // start time when the first part of the I/O sequence started
end: number, // we typically don't use this. only when there's no promise intermediate.
promise: null, // not used on I/O
@@ -37,7 +37,7 @@ export type IONode = {
export type PromiseNode = {
tag: 1,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that created the Promise
stack: null | ReactStackTrace, // callsite that created the Promise
start: number, // start time when the Promise was created
end: number, // end time when the Promise was resolved.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced
@@ -48,7 +48,7 @@ export type PromiseNode = {
export type AwaitNode = {
tag: 2,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
stack: null | ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
start: number, // when we started blocking. This might be later than the I/O started.
end: number, // when we unblocked. This might be later than the I/O resolved if there's CPU time.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced
@@ -59,7 +59,7 @@ export type AwaitNode = {
export type UnresolvedPromiseNode = {
tag: 3,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that created the Promise
stack: null | ReactStackTrace, // callsite that created the Promise
start: number, // start time when the Promise was created
end: -1.1, // set when we resolve.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced
@@ -70,7 +70,7 @@ export type UnresolvedPromiseNode = {
export type UnresolvedAwaitNode = {
tag: 4,
owner: null | ReactComponentInfo,
stack: ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
stack: null | ReactStackTrace, // callsite that awaited (using await, .then(), Promise.all(), ...)
start: number, // when we started blocking. This might be later than the I/O started.
end: -1.1, // set when we resolve.
promise: WeakRef<PromiseWithDebugInfo>, // a reference to this Promise if still referenced

View File

@@ -94,6 +94,7 @@ import {
getCurrentAsyncSequence,
getAsyncSequenceFromPromise,
parseStackTrace,
parseStackTracePrivate,
supportsComponentStorage,
componentStorage,
unbadgeConsole,
@@ -251,6 +252,54 @@ function filterStackTrace(
return filteredStack;
}
function hasUnfilteredFrame(request: Request, stack: ReactStackTrace): boolean {
const filterStackFrame = request.filterStackFrame;
for (let i = 0; i < stack.length; i++) {
const callsite = stack[i];
const functionName = callsite[0];
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
// 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;
}
}
return false;
}
export function isAwaitInUserspace(
request: Request,
stack: ReactStackTrace,
): boolean {
let firstFrame = 0;
while (stack.length > firstFrame && stack[firstFrame][0] === 'Promise.then') {
// Skip Promise.then frame itself.
firstFrame++;
}
if (stack.length > firstFrame) {
// Check if the very first stack frame that awaited this Promise was in user space.
// TODO: This doesn't take into account wrapper functions such as our fake .then()
// in FlightClient which will always be considered third party awaits if you call
// .then directly.
const filterStackFrame = request.filterStackFrame;
const callsite = stack[firstFrame];
const functionName = callsite[0];
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
return filterStackFrame(url, functionName, lineNumber, columnNumber);
}
return false;
}
initAsyncDebugInfo();
function patchConsole(consoleInst: typeof console, methodName: string) {
@@ -276,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();
@@ -752,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(
@@ -804,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,
@@ -987,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);
@@ -1116,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);
@@ -2088,7 +2182,10 @@ function visitAsyncNode(
// If the ioNode was a Promise, then that means we found one in user space since otherwise
// we would've returned an IO node. We assume this has the best stack.
match = ioNode;
} else if (filterStackTrace(request, node.stack).length === 0) {
} else if (
node.stack === null ||
!hasUnfilteredFrame(request, node.stack)
) {
// If this Promise was created inside only third party code, then try to use
// the inner I/O node instead. This could happen if third party calls into first
// party to perform some I/O.
@@ -2101,7 +2198,10 @@ function visitAsyncNode(
// We aborted this render. If this Promise spanned the abort time it was probably the
// Promise that was aborted. This won't necessarily have I/O associated with it but
// it's a point of interest.
if (filterStackTrace(request, node.stack).length > 0) {
if (
node.stack !== null &&
hasUnfilteredFrame(request, node.stack)
) {
match = node;
}
}
@@ -2147,35 +2247,10 @@ function visitAsyncNode(
// just part of a previous component's rendering.
match = ioNode;
} else {
let isAwaitInUserspace = false;
const fullStack = node.stack;
let firstFrame = 0;
while (
fullStack.length > firstFrame &&
fullStack[firstFrame][0] === 'Promise.then'
if (
node.stack === null ||
!isAwaitInUserspace(request, node.stack)
) {
// Skip Promise.then frame itself.
firstFrame++;
}
if (fullStack.length > firstFrame) {
// Check if the very first stack frame that awaited this Promise was in user space.
// TODO: This doesn't take into account wrapper functions such as our fake .then()
// in FlightClient which will always be considered third party awaits if you call
// .then directly.
const filterStackFrame = request.filterStackFrame;
const callsite = fullStack[firstFrame];
const functionName = callsite[0];
const url = devirtualizeURL(callsite[1]);
const lineNumber = callsite[2];
const columnNumber = callsite[3];
isAwaitInUserspace = filterStackFrame(
url,
functionName,
lineNumber,
columnNumber,
);
}
if (!isAwaitInUserspace) {
// If this await was fully filtered out, then it was inside third party code
// such as in an external library. We return the I/O node and try another await.
match = ioNode;
@@ -2204,7 +2279,10 @@ function visitAsyncNode(
awaited: ((ioNode: any): ReactIOInfo), // This is deduped by this reference.
env: env,
owner: node.owner,
stack: filterStackTrace(request, node.stack),
stack:
node.stack === null
? null
: filterStackTrace(request, node.stack),
});
// Mark the end time of the await. If we're aborting then we don't emit this
// to signal that this never resolved inside this render.
@@ -2892,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);
@@ -4048,10 +4128,6 @@ function emitIOInfoChunk(
start: relativeStartTimestamp,
end: relativeEndTimestamp,
};
if (value !== undefined) {
// $FlowFixMe[cannot-write]
debugIOInfo.value = value;
}
if (env != null) {
// $FlowFixMe[cannot-write]
debugIOInfo.env = env;
@@ -4064,6 +4140,10 @@ function emitIOInfoChunk(
// $FlowFixMe[cannot-write]
debugIOInfo.owner = owner;
}
if (value !== undefined) {
// $FlowFixMe[cannot-write]
debugIOInfo.value = value;
}
const json: string = serializeDebugModel(request, objectLimit, debugIOInfo);
const row = id.toString(16) + ':J' + json + '\n';
const processedChunk = stringToChunk(row);
@@ -4321,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
@@ -4345,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);
}
}
}
@@ -4796,10 +4889,15 @@ function emitConsoleChunk(
const payload = [methodName, stackTrace, owner, env];
// $FlowFixMe[method-unbinding]
payload.push.apply(payload, args);
let json = serializeDebugModel(request, 500, payload);
const objectLimit = request.deferredDebugObjects === null ? 500 : 10;
let json = serializeDebugModel(
request,
objectLimit + stackTrace.length,
payload,
);
if (json[0] !== '[') {
// This looks like an error. Try a simpler object.
json = serializeDebugModel(request, 500, [
json = serializeDebugModel(request, 10 + stackTrace.length, [
methodName,
stackTrace,
owner,
@@ -5736,11 +5834,32 @@ export function resolveDebugMessage(request: Request, message: string): void {
if (retainedValue !== undefined) {
// If we still have this object, and haven't emitted it before, emit it on the stream.
const counter = {objectLimit: 10};
deferredDebugObjects.retained.delete(id);
deferredDebugObjects.existing.delete(retainedValue);
emitOutlinedDebugModelChunk(request, id, counter, retainedValue);
enqueueFlush(request);
}
}
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

@@ -7,6 +7,8 @@
* @flow
*/
import type {ReactStackTrace} from 'shared/ReactTypes';
import type {
AsyncSequence,
IONode,
@@ -24,9 +26,10 @@ import {
UNRESOLVED_AWAIT_NODE,
} from './ReactFlightAsyncSequence';
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;
@@ -34,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;
@@ -66,6 +52,8 @@ function resolvePromiseOrAwaitNode(
return resolvedNode;
}
const emptyStack: ReactStackTrace = [];
// Initialize the tracing of async operations.
// We do this globally since the async work can potentially eagerly
// start before the first request and once requests start they can interleave.
@@ -83,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,
@@ -99,32 +79,58 @@ 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 ||
trigger.tag === UNRESOLVED_AWAIT_NODE)
) {
// 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 = 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.
stack = null;
}
}
}
const current = pendingOperations.get(currentAsyncId);
node = ({
tag: UNRESOLVED_AWAIT_NODE,
owner: resolveOwner(),
stack: parseStackTrace(new Error(), 5),
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);
} else {
const owner = resolveOwner();
node = ({
tag: UNRESOLVED_PROMISE_NODE,
owner: resolveOwner(),
stack: parseStackTrace(new Error(), 5),
owner: owner,
stack:
owner === null ? null : parseStackTracePrivate(new Error(), 5),
start: performance.now(),
end: -1.1, // Set when we resolve.
promise: new WeakRef((resource: Promise<any>)),
@@ -142,10 +148,12 @@ export function initAsyncDebugInfo(): void {
) {
if (trigger === undefined) {
// We have begun a new I/O sequence.
const owner = resolveOwner();
node = ({
tag: IO_NODE,
owner: resolveOwner(),
stack: parseStackTrace(new Error(), 3), // This is only used if no native promises are used.
owner: owner,
stack:
owner === null ? parseStackTracePrivate(new Error(), 3) : null,
start: performance.now(),
end: -1.1, // Only set when pinged.
promise: null,
@@ -157,10 +165,12 @@ export function initAsyncDebugInfo(): void {
trigger.tag === UNRESOLVED_AWAIT_NODE
) {
// We have begun a new I/O sequence after the await.
const owner = resolveOwner();
node = ({
tag: IO_NODE,
owner: resolveOwner(),
stack: parseStackTrace(new Error(), 3),
owner: owner,
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;

File diff suppressed because it is too large Load Diff

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