Skip to content

Commit 1d1b26c

Browse files
authored
[Flight] Serialize already resolved Promises as debug models (#33588)
We already support serializing the values of instrumented Promises as debug values such as in console logs. However, we don't support plain native promises. This waits a microtask to see if we can read the value within a microtask and if so emit it. This is so that we can still close the connection. Otherwise, we emit a "halted" row into its row id which replaces the old "Infinite Promise" reference. We could potentially wait until the end of the render before cancelling so that if it resolves before we exit we can still include its value but that would require a bit more work. Ideally we'd have a way to get these lazily later anyway.
1 parent fe3f0ec commit 1d1b26c

File tree

4 files changed

+204
-47
lines changed

4 files changed

+204
-47
lines changed

packages/react-client/src/ReactFlightClient.js

Lines changed: 37 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,7 @@ const RESOLVED_MODEL = 'resolved_model';
155155
const RESOLVED_MODULE = 'resolved_module';
156156
const INITIALIZED = 'fulfilled';
157157
const ERRORED = 'rejected';
158+
const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes.
158159

159160
type PendingChunk<T> = {
160161
status: 'pending',
@@ -221,13 +222,23 @@ type ErroredChunk<T> = {
221222
_debugInfo?: null | ReactDebugInfo, // DEV-only
222223
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
223224
};
225+
type HaltedChunk<T> = {
226+
status: 'halted',
227+
value: null,
228+
reason: null,
229+
_response: Response,
230+
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
231+
_debugInfo?: null | ReactDebugInfo, // DEV-only
232+
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
233+
};
224234
type SomeChunk<T> =
225235
| PendingChunk<T>
226236
| BlockedChunk<T>
227237
| ResolvedModelChunk<T>
228238
| ResolvedModuleChunk<T>
229239
| InitializedChunk<T>
230-
| ErroredChunk<T>;
240+
| ErroredChunk<T>
241+
| HaltedChunk<T>;
231242

232243
// $FlowFixMe[missing-this-annot]
233244
function ReactPromise(
@@ -311,6 +322,9 @@ ReactPromise.prototype.then = function <T>(
311322
chunk.reason.push(reject);
312323
}
313324
break;
325+
case HALTED: {
326+
break;
327+
}
314328
default:
315329
if (reject) {
316330
reject(chunk.reason);
@@ -368,6 +382,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
368382
return chunk.value;
369383
case PENDING:
370384
case BLOCKED:
385+
case HALTED:
371386
// eslint-disable-next-line no-throw-literal
372387
throw ((chunk: any): Thenable<T>);
373388
default:
@@ -1367,6 +1382,7 @@ function getOutlinedModel<T>(
13671382
return chunkValue;
13681383
case PENDING:
13691384
case BLOCKED:
1385+
case HALTED:
13701386
return waitForReference(chunk, parentObject, key, response, map, path);
13711387
default:
13721388
// This is an error. Instead of erroring directly, we're going to encode this on
@@ -1470,10 +1486,6 @@ function parseModelString(
14701486
}
14711487
case '@': {
14721488
// Promise
1473-
if (value.length === 2) {
1474-
// Infinite promise that never resolves.
1475-
return new Promise(() => {});
1476-
}
14771489
const id = parseInt(value.slice(2), 16);
14781490
const chunk = getChunk(response, id);
14791491
if (enableProfilerTimer && enableComponentPerformanceTrack) {
@@ -1769,6 +1781,22 @@ export function createResponse(
17691781
);
17701782
}
17711783

1784+
function resolveDebugHalt(response: Response, id: number): void {
1785+
const chunks = response._chunks;
1786+
let chunk = chunks.get(id);
1787+
if (!chunk) {
1788+
chunks.set(id, (chunk = createPendingChunk(response)));
1789+
} else {
1790+
}
1791+
if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
1792+
return;
1793+
}
1794+
const haltedChunk: HaltedChunk<any> = (chunk: any);
1795+
haltedChunk.status = HALTED;
1796+
haltedChunk.value = null;
1797+
haltedChunk.reason = null;
1798+
}
1799+
17721800
function resolveModel(
17731801
response: Response,
17741802
id: number,
@@ -3339,6 +3367,10 @@ function processFullStringRow(
33393367
}
33403368
// Fallthrough
33413369
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
3370+
if (__DEV__ && row === '') {
3371+
resolveDebugHalt(response, id);
3372+
return;
3373+
}
33423374
// We assume anything else is JSON.
33433375
resolveModel(response, id, row);
33443376
return;

packages/react-client/src/__tests__/ReactFlight-test.js

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3213,7 +3213,8 @@ describe('ReactFlight', () => {
32133213
prop: 123,
32143214
fn: foo,
32153215
map: new Map([['foo', foo]]),
3216-
promise: new Promise(() => {}),
3216+
promise: Promise.resolve('yo'),
3217+
infinitePromise: new Promise(() => {}),
32173218
});
32183219
throw new Error('err');
32193220
}
@@ -3258,9 +3259,14 @@ describe('ReactFlight', () => {
32583259
});
32593260
ownerStacks = [];
32603261

3262+
// Let the Promises resolve.
3263+
await 0;
3264+
await 0;
3265+
await 0;
3266+
32613267
// The error should not actually get logged because we're not awaiting the root
32623268
// so it's not thrown but the server log also shouldn't be replayed.
3263-
await ReactNoopFlightClient.read(transport);
3269+
await ReactNoopFlightClient.read(transport, {close: true});
32643270

32653271
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
32663272
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
@@ -3280,6 +3286,23 @@ describe('ReactFlight', () => {
32803286

32813287
const promise = mockConsoleLog.mock.calls[0][1].promise;
32823288
expect(promise).toBeInstanceOf(Promise);
3289+
expect(await promise).toBe('yo');
3290+
3291+
const infinitePromise = mockConsoleLog.mock.calls[0][1].infinitePromise;
3292+
expect(infinitePromise).toBeInstanceOf(Promise);
3293+
let resolved = false;
3294+
infinitePromise.then(
3295+
() => (resolved = true),
3296+
x => {
3297+
console.error(x);
3298+
resolved = true;
3299+
},
3300+
);
3301+
await 0;
3302+
await 0;
3303+
await 0;
3304+
// This should not reject upon aborting the stream.
3305+
expect(resolved).toBe(false);
32833306

32843307
expect(ownerStacks).toEqual(['\n in App (at **)']);
32853308
});

packages/react-noop-renderer/src/ReactNoopFlightClient.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ type Source = Array<Uint8Array>;
2424

2525
const decoderOptions = {stream: true};
2626

27-
const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
27+
const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({
2828
createStringDecoder() {
2929
return new TextDecoder();
3030
},
@@ -56,6 +56,7 @@ const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
5656

5757
type ReadOptions = {|
5858
findSourceMapURL?: FindSourceMapURLCallback,
59+
close?: boolean,
5960
|};
6061

6162
function read<T>(source: Source, options: ReadOptions): Thenable<T> {
@@ -74,6 +75,9 @@ function read<T>(source: Source, options: ReadOptions): Thenable<T> {
7475
for (let i = 0; i < source.length; i++) {
7576
processBinaryChunk(response, source[i], 0);
7677
}
78+
if (options !== undefined && options.close) {
79+
close(response);
80+
}
7781
return getRoot(response);
7882
}
7983

0 commit comments

Comments
 (0)