mirror of
https://github.com/zebrajr/react.git
synced 2025-12-06 12:20:20 +01:00
[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.
This commit is contained in:
parent
fe3f0ec037
commit
1d1b26c701
42
packages/react-client/src/ReactFlightClient.js
vendored
42
packages/react-client/src/ReactFlightClient.js
vendored
|
|
@ -155,6 +155,7 @@ const RESOLVED_MODEL = 'resolved_model';
|
||||||
const RESOLVED_MODULE = 'resolved_module';
|
const RESOLVED_MODULE = 'resolved_module';
|
||||||
const INITIALIZED = 'fulfilled';
|
const INITIALIZED = 'fulfilled';
|
||||||
const ERRORED = 'rejected';
|
const ERRORED = 'rejected';
|
||||||
|
const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes.
|
||||||
|
|
||||||
type PendingChunk<T> = {
|
type PendingChunk<T> = {
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
|
|
@ -221,13 +222,23 @@ type ErroredChunk<T> = {
|
||||||
_debugInfo?: null | ReactDebugInfo, // DEV-only
|
_debugInfo?: null | ReactDebugInfo, // DEV-only
|
||||||
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
||||||
};
|
};
|
||||||
|
type HaltedChunk<T> = {
|
||||||
|
status: 'halted',
|
||||||
|
value: null,
|
||||||
|
reason: null,
|
||||||
|
_response: Response,
|
||||||
|
_children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
|
||||||
|
_debugInfo?: null | ReactDebugInfo, // DEV-only
|
||||||
|
then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
|
||||||
|
};
|
||||||
type SomeChunk<T> =
|
type SomeChunk<T> =
|
||||||
| PendingChunk<T>
|
| PendingChunk<T>
|
||||||
| BlockedChunk<T>
|
| BlockedChunk<T>
|
||||||
| ResolvedModelChunk<T>
|
| ResolvedModelChunk<T>
|
||||||
| ResolvedModuleChunk<T>
|
| ResolvedModuleChunk<T>
|
||||||
| InitializedChunk<T>
|
| InitializedChunk<T>
|
||||||
| ErroredChunk<T>;
|
| ErroredChunk<T>
|
||||||
|
| HaltedChunk<T>;
|
||||||
|
|
||||||
// $FlowFixMe[missing-this-annot]
|
// $FlowFixMe[missing-this-annot]
|
||||||
function ReactPromise(
|
function ReactPromise(
|
||||||
|
|
@ -311,6 +322,9 @@ ReactPromise.prototype.then = function <T>(
|
||||||
chunk.reason.push(reject);
|
chunk.reason.push(reject);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
|
case HALTED: {
|
||||||
|
break;
|
||||||
|
}
|
||||||
default:
|
default:
|
||||||
if (reject) {
|
if (reject) {
|
||||||
reject(chunk.reason);
|
reject(chunk.reason);
|
||||||
|
|
@ -368,6 +382,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
|
||||||
return chunk.value;
|
return chunk.value;
|
||||||
case PENDING:
|
case PENDING:
|
||||||
case BLOCKED:
|
case BLOCKED:
|
||||||
|
case HALTED:
|
||||||
// eslint-disable-next-line no-throw-literal
|
// eslint-disable-next-line no-throw-literal
|
||||||
throw ((chunk: any): Thenable<T>);
|
throw ((chunk: any): Thenable<T>);
|
||||||
default:
|
default:
|
||||||
|
|
@ -1367,6 +1382,7 @@ function getOutlinedModel<T>(
|
||||||
return chunkValue;
|
return chunkValue;
|
||||||
case PENDING:
|
case PENDING:
|
||||||
case BLOCKED:
|
case BLOCKED:
|
||||||
|
case HALTED:
|
||||||
return waitForReference(chunk, parentObject, key, response, map, path);
|
return waitForReference(chunk, parentObject, key, response, map, path);
|
||||||
default:
|
default:
|
||||||
// This is an error. Instead of erroring directly, we're going to encode this on
|
// This is an error. Instead of erroring directly, we're going to encode this on
|
||||||
|
|
@ -1470,10 +1486,6 @@ function parseModelString(
|
||||||
}
|
}
|
||||||
case '@': {
|
case '@': {
|
||||||
// Promise
|
// Promise
|
||||||
if (value.length === 2) {
|
|
||||||
// Infinite promise that never resolves.
|
|
||||||
return new Promise(() => {});
|
|
||||||
}
|
|
||||||
const id = parseInt(value.slice(2), 16);
|
const id = parseInt(value.slice(2), 16);
|
||||||
const chunk = getChunk(response, id);
|
const chunk = getChunk(response, id);
|
||||||
if (enableProfilerTimer && enableComponentPerformanceTrack) {
|
if (enableProfilerTimer && enableComponentPerformanceTrack) {
|
||||||
|
|
@ -1769,6 +1781,22 @@ export function createResponse(
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveDebugHalt(response: Response, id: number): void {
|
||||||
|
const chunks = response._chunks;
|
||||||
|
let chunk = chunks.get(id);
|
||||||
|
if (!chunk) {
|
||||||
|
chunks.set(id, (chunk = createPendingChunk(response)));
|
||||||
|
} else {
|
||||||
|
}
|
||||||
|
if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const haltedChunk: HaltedChunk<any> = (chunk: any);
|
||||||
|
haltedChunk.status = HALTED;
|
||||||
|
haltedChunk.value = null;
|
||||||
|
haltedChunk.reason = null;
|
||||||
|
}
|
||||||
|
|
||||||
function resolveModel(
|
function resolveModel(
|
||||||
response: Response,
|
response: Response,
|
||||||
id: number,
|
id: number,
|
||||||
|
|
@ -3339,6 +3367,10 @@ function processFullStringRow(
|
||||||
}
|
}
|
||||||
// Fallthrough
|
// Fallthrough
|
||||||
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
|
default: /* """ "{" "[" "t" "f" "n" "0" - "9" */ {
|
||||||
|
if (__DEV__ && row === '') {
|
||||||
|
resolveDebugHalt(response, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
// We assume anything else is JSON.
|
// We assume anything else is JSON.
|
||||||
resolveModel(response, id, row);
|
resolveModel(response, id, row);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -3213,7 +3213,8 @@ describe('ReactFlight', () => {
|
||||||
prop: 123,
|
prop: 123,
|
||||||
fn: foo,
|
fn: foo,
|
||||||
map: new Map([['foo', foo]]),
|
map: new Map([['foo', foo]]),
|
||||||
promise: new Promise(() => {}),
|
promise: Promise.resolve('yo'),
|
||||||
|
infinitePromise: new Promise(() => {}),
|
||||||
});
|
});
|
||||||
throw new Error('err');
|
throw new Error('err');
|
||||||
}
|
}
|
||||||
|
|
@ -3258,9 +3259,14 @@ describe('ReactFlight', () => {
|
||||||
});
|
});
|
||||||
ownerStacks = [];
|
ownerStacks = [];
|
||||||
|
|
||||||
|
// Let the Promises resolve.
|
||||||
|
await 0;
|
||||||
|
await 0;
|
||||||
|
await 0;
|
||||||
|
|
||||||
// The error should not actually get logged because we're not awaiting the root
|
// The error should not actually get logged because we're not awaiting the root
|
||||||
// so it's not thrown but the server log also shouldn't be replayed.
|
// so it's not thrown but the server log also shouldn't be replayed.
|
||||||
await ReactNoopFlightClient.read(transport);
|
await ReactNoopFlightClient.read(transport, {close: true});
|
||||||
|
|
||||||
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
|
expect(mockConsoleLog).toHaveBeenCalledTimes(1);
|
||||||
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
|
expect(mockConsoleLog.mock.calls[0][0]).toBe('hi');
|
||||||
|
|
@ -3280,6 +3286,23 @@ describe('ReactFlight', () => {
|
||||||
|
|
||||||
const promise = mockConsoleLog.mock.calls[0][1].promise;
|
const promise = mockConsoleLog.mock.calls[0][1].promise;
|
||||||
expect(promise).toBeInstanceOf(Promise);
|
expect(promise).toBeInstanceOf(Promise);
|
||||||
|
expect(await promise).toBe('yo');
|
||||||
|
|
||||||
|
const infinitePromise = mockConsoleLog.mock.calls[0][1].infinitePromise;
|
||||||
|
expect(infinitePromise).toBeInstanceOf(Promise);
|
||||||
|
let resolved = false;
|
||||||
|
infinitePromise.then(
|
||||||
|
() => (resolved = true),
|
||||||
|
x => {
|
||||||
|
console.error(x);
|
||||||
|
resolved = true;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await 0;
|
||||||
|
await 0;
|
||||||
|
await 0;
|
||||||
|
// This should not reject upon aborting the stream.
|
||||||
|
expect(resolved).toBe(false);
|
||||||
|
|
||||||
expect(ownerStacks).toEqual(['\n in App (at **)']);
|
expect(ownerStacks).toEqual(['\n in App (at **)']);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ type Source = Array<Uint8Array>;
|
||||||
|
|
||||||
const decoderOptions = {stream: true};
|
const decoderOptions = {stream: true};
|
||||||
|
|
||||||
const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
|
const {createResponse, processBinaryChunk, getRoot, close} = ReactFlightClient({
|
||||||
createStringDecoder() {
|
createStringDecoder() {
|
||||||
return new TextDecoder();
|
return new TextDecoder();
|
||||||
},
|
},
|
||||||
|
|
@ -56,6 +56,7 @@ const {createResponse, processBinaryChunk, getRoot} = ReactFlightClient({
|
||||||
|
|
||||||
type ReadOptions = {|
|
type ReadOptions = {|
|
||||||
findSourceMapURL?: FindSourceMapURLCallback,
|
findSourceMapURL?: FindSourceMapURLCallback,
|
||||||
|
close?: boolean,
|
||||||
|};
|
|};
|
||||||
|
|
||||||
function read<T>(source: Source, options: ReadOptions): Thenable<T> {
|
function read<T>(source: Source, options: ReadOptions): Thenable<T> {
|
||||||
|
|
@ -74,6 +75,9 @@ function read<T>(source: Source, options: ReadOptions): Thenable<T> {
|
||||||
for (let i = 0; i < source.length; i++) {
|
for (let i = 0; i < source.length; i++) {
|
||||||
processBinaryChunk(response, source[i], 0);
|
processBinaryChunk(response, source[i], 0);
|
||||||
}
|
}
|
||||||
|
if (options !== undefined && options.close) {
|
||||||
|
close(response);
|
||||||
|
}
|
||||||
return getRoot(response);
|
return getRoot(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
176
packages/react-server/src/ReactFlightServer.js
vendored
176
packages/react-server/src/ReactFlightServer.js
vendored
|
|
@ -677,6 +677,105 @@ export function resolveRequest(): null | Request {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function serializeDebugThenable(
|
||||||
|
request: Request,
|
||||||
|
counter: {objectLimit: number},
|
||||||
|
thenable: Thenable<any>,
|
||||||
|
): string {
|
||||||
|
// Like serializeThenable but for renderDebugModel
|
||||||
|
request.pendingChunks++;
|
||||||
|
const id = request.nextChunkId++;
|
||||||
|
const ref = serializePromiseID(id);
|
||||||
|
request.writtenDebugObjects.set(thenable, ref);
|
||||||
|
|
||||||
|
switch (thenable.status) {
|
||||||
|
case 'fulfilled': {
|
||||||
|
emitOutlinedDebugModelChunk(request, id, counter, thenable.value);
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
case 'rejected': {
|
||||||
|
const x = thenable.reason;
|
||||||
|
if (
|
||||||
|
enablePostpone &&
|
||||||
|
typeof x === 'object' &&
|
||||||
|
x !== null &&
|
||||||
|
(x: any).$$typeof === REACT_POSTPONE_TYPE
|
||||||
|
) {
|
||||||
|
const postponeInstance: Postpone = (x: any);
|
||||||
|
// We don't log this postpone.
|
||||||
|
emitPostponeChunk(request, id, postponeInstance);
|
||||||
|
} else {
|
||||||
|
// We don't log these errors since they didn't actually throw into Flight.
|
||||||
|
const digest = '';
|
||||||
|
emitErrorChunk(request, id, digest, x);
|
||||||
|
}
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
thenable.then(
|
||||||
|
value => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelled = true;
|
||||||
|
if (request.status === ABORTING) {
|
||||||
|
emitDebugHaltChunk(request, id);
|
||||||
|
enqueueFlush(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
emitOutlinedDebugModelChunk(request, id, counter, value);
|
||||||
|
enqueueFlush(request);
|
||||||
|
},
|
||||||
|
reason => {
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelled = true;
|
||||||
|
if (request.status === ABORTING) {
|
||||||
|
emitDebugHaltChunk(request, id);
|
||||||
|
enqueueFlush(request);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
enablePostpone &&
|
||||||
|
typeof reason === 'object' &&
|
||||||
|
reason !== null &&
|
||||||
|
(reason: any).$$typeof === REACT_POSTPONE_TYPE
|
||||||
|
) {
|
||||||
|
const postponeInstance: Postpone = (reason: any);
|
||||||
|
// We don't log this postpone.
|
||||||
|
emitPostponeChunk(request, id, postponeInstance);
|
||||||
|
} else {
|
||||||
|
// We don't log these errors since they didn't actually throw into Flight.
|
||||||
|
const digest = '';
|
||||||
|
emitErrorChunk(request, id, digest, reason);
|
||||||
|
}
|
||||||
|
enqueueFlush(request);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// We don't use scheduleMicrotask here because it doesn't actually schedule a microtask
|
||||||
|
// in all our configs which is annoying.
|
||||||
|
Promise.resolve().then(() => {
|
||||||
|
// If we don't resolve the Promise within a microtask. Leave it as hanging since we
|
||||||
|
// don't want to block the render forever on a Promise that might never resolve.
|
||||||
|
if (cancelled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cancelled = true;
|
||||||
|
emitDebugHaltChunk(request, id);
|
||||||
|
enqueueFlush(request);
|
||||||
|
// Clean up the request so we don't leak this forever.
|
||||||
|
request = (null: any);
|
||||||
|
counter = (null: any);
|
||||||
|
});
|
||||||
|
|
||||||
|
return ref;
|
||||||
|
}
|
||||||
|
|
||||||
function serializeThenable(
|
function serializeThenable(
|
||||||
request: Request,
|
request: Request,
|
||||||
task: Task,
|
task: Task,
|
||||||
|
|
@ -2194,10 +2293,6 @@ function serializeLazyID(id: number): string {
|
||||||
return '$L' + id.toString(16);
|
return '$L' + id.toString(16);
|
||||||
}
|
}
|
||||||
|
|
||||||
function serializeInfinitePromise(): string {
|
|
||||||
return '$@';
|
|
||||||
}
|
|
||||||
|
|
||||||
function serializePromiseID(id: number): string {
|
function serializePromiseID(id: number): string {
|
||||||
return '$@' + id.toString(16);
|
return '$@' + id.toString(16);
|
||||||
}
|
}
|
||||||
|
|
@ -3514,6 +3609,21 @@ function emitModelChunk(request: Request, id: number, json: string): void {
|
||||||
request.completedRegularChunks.push(processedChunk);
|
request.completedRegularChunks.push(processedChunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function emitDebugHaltChunk(request: Request, id: number): void {
|
||||||
|
if (!__DEV__) {
|
||||||
|
// These errors should never make it into a build so we don't need to encode them in codes.json
|
||||||
|
// eslint-disable-next-line react-internal/prod-error-codes
|
||||||
|
throw new Error(
|
||||||
|
'emitDebugHaltChunk should never be called in production mode. This is a bug in React.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// This emits a marker that this row will never complete and should intentionally never resolve
|
||||||
|
// even when the client stream is closed. We use just the lack of data to indicate this.
|
||||||
|
const row = id.toString(16) + ':\n';
|
||||||
|
const processedChunk = stringToChunk(row);
|
||||||
|
request.completedRegularChunks.push(processedChunk);
|
||||||
|
}
|
||||||
|
|
||||||
function emitDebugChunk(
|
function emitDebugChunk(
|
||||||
request: Request,
|
request: Request,
|
||||||
id: number,
|
id: number,
|
||||||
|
|
@ -3950,36 +4060,7 @@ function renderDebugModel(
|
||||||
// $FlowFixMe[method-unbinding]
|
// $FlowFixMe[method-unbinding]
|
||||||
if (typeof value.then === 'function') {
|
if (typeof value.then === 'function') {
|
||||||
const thenable: Thenable<any> = (value: any);
|
const thenable: Thenable<any> = (value: any);
|
||||||
switch (thenable.status) {
|
return serializeDebugThenable(request, counter, thenable);
|
||||||
case 'fulfilled': {
|
|
||||||
return serializePromiseID(
|
|
||||||
outlineDebugModel(request, counter, thenable.value),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
case 'rejected': {
|
|
||||||
const x = thenable.reason;
|
|
||||||
request.pendingChunks++;
|
|
||||||
const errorId = request.nextChunkId++;
|
|
||||||
if (
|
|
||||||
enablePostpone &&
|
|
||||||
typeof x === 'object' &&
|
|
||||||
x !== null &&
|
|
||||||
(x: any).$$typeof === REACT_POSTPONE_TYPE
|
|
||||||
) {
|
|
||||||
const postponeInstance: Postpone = (x: any);
|
|
||||||
// We don't log this postpone.
|
|
||||||
emitPostponeChunk(request, errorId, postponeInstance);
|
|
||||||
} else {
|
|
||||||
// We don't log these errors since they didn't actually throw into Flight.
|
|
||||||
const digest = '';
|
|
||||||
emitErrorChunk(request, errorId, digest, x);
|
|
||||||
}
|
|
||||||
return serializePromiseID(errorId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If it hasn't already resolved (and been instrumented) we just encode an infinite
|
|
||||||
// promise that will never resolve.
|
|
||||||
return serializeInfinitePromise();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isArray(value)) {
|
if (isArray(value)) {
|
||||||
|
|
@ -4206,16 +4287,17 @@ function serializeDebugModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function outlineDebugModel(
|
function emitOutlinedDebugModelChunk(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
id: number,
|
||||||
counter: {objectLimit: number},
|
counter: {objectLimit: number},
|
||||||
model: ReactClientValue,
|
model: ReactClientValue,
|
||||||
): number {
|
): void {
|
||||||
if (!__DEV__) {
|
if (!__DEV__) {
|
||||||
// These errors should never make it into a build so we don't need to encode them in codes.json
|
// These errors should never make it into a build so we don't need to encode them in codes.json
|
||||||
// eslint-disable-next-line react-internal/prod-error-codes
|
// eslint-disable-next-line react-internal/prod-error-codes
|
||||||
throw new Error(
|
throw new Error(
|
||||||
'outlineDebugModel should never be called in production mode. This is a bug in React.',
|
'emitOutlinedDebugModel should never be called in production mode. This is a bug in React.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -4246,7 +4328,6 @@ function outlineDebugModel(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = request.nextChunkId++;
|
|
||||||
const prevModelRoot = debugModelRoot;
|
const prevModelRoot = debugModelRoot;
|
||||||
debugModelRoot = model;
|
debugModelRoot = model;
|
||||||
if (typeof model === 'object' && model !== null) {
|
if (typeof model === 'object' && model !== null) {
|
||||||
|
|
@ -4266,10 +4347,27 @@ function outlineDebugModel(
|
||||||
debugModelRoot = prevModelRoot;
|
debugModelRoot = prevModelRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
request.pendingChunks++;
|
|
||||||
const row = id.toString(16) + ':' + json + '\n';
|
const row = id.toString(16) + ':' + json + '\n';
|
||||||
const processedChunk = stringToChunk(row);
|
const processedChunk = stringToChunk(row);
|
||||||
request.completedRegularChunks.push(processedChunk);
|
request.completedRegularChunks.push(processedChunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
function outlineDebugModel(
|
||||||
|
request: Request,
|
||||||
|
counter: {objectLimit: number},
|
||||||
|
model: ReactClientValue,
|
||||||
|
): number {
|
||||||
|
if (!__DEV__) {
|
||||||
|
// These errors should never make it into a build so we don't need to encode them in codes.json
|
||||||
|
// eslint-disable-next-line react-internal/prod-error-codes
|
||||||
|
throw new Error(
|
||||||
|
'outlineDebugModel should never be called in production mode. This is a bug in React.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = request.nextChunkId++;
|
||||||
|
request.pendingChunks++;
|
||||||
|
emitOutlinedDebugModelChunk(request, id, counter, model);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user