Files
react/packages/react-server-dom-webpack/src/client/ReactFlightDOMClientBrowser.js
T
Hendrik LiebauandGitHub 17bb2f70a0 [Flight] Add unstable_allowPartialStream option to Flight Client (#35731)
When using a partial prerender stream, i.e. a prerender that is
intentionally aborted before all I/O has resolved, consumers of
`createFromReadableStream` would need to keep the stream unclosed to
prevent React Flight from erroring on unresolved chunks. However, some
browsers (e.g. Chrome, Firefox) keep unclosed ReadableStreams with
pending reads as native GC roots, retaining the entire Flight response.

With this PR we're adding an `unstable_allowPartialStream` option, that
allows consumers to close the stream normally. The Flight Client's
`close()` function then transitions pending chunks to halted instead of
erroring them. Halted chunks keep Suspense fallbacks showing (i.e. they
never resolve), and their `.then()` is a no-op so no new listeners
accumulate. Inner stream chunks (ReadableStream/AsyncIterable) are
closed gracefully, and `getChunk()` returns halted chunks for new IDs
that are accessed after closing the response. Blocked chunks are left
alone because they may be waiting on client-side async operations like
module loading, or on forward references to chunks that appeared later
in the stream, both of which resolve independently of closing.
2026-02-09 19:19:32 +01:00

297 lines
7.9 KiB
JavaScript

/**
* Copyright (c) Meta Platforms, Inc. and affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
* @flow
*/
import type {Thenable} from 'shared/ReactTypes.js';
import type {
DebugChannel,
DebugChannelCallback,
FindSourceMapURLCallback,
Response as FlightResponse,
} from 'react-client/src/ReactFlightClient';
import type {ReactServerValue} from 'react-client/src/ReactFlightReplyClient';
import {
createResponse,
createStreamState,
getRoot,
reportGlobalError,
processBinaryChunk,
processStringChunk,
close,
injectIntoDevTools,
} from 'react-client/src/ReactFlightClient';
import {processReply} from 'react-client/src/ReactFlightReplyClient';
export {
createServerReference,
registerServerReference,
} from 'react-client/src/ReactFlightReplyClient';
import type {TemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
export {createTemporaryReferenceSet} from 'react-client/src/ReactFlightTemporaryReferences';
export type {TemporaryReferenceSet};
type CallServerCallback = <A, T>(string, args: A) => Promise<T>;
export type Options = {
callServer?: CallServerCallback,
debugChannel?: {writable?: WritableStream, readable?: ReadableStream, ...},
temporaryReferences?: TemporaryReferenceSet,
unstable_allowPartialStream?: boolean,
findSourceMapURL?: FindSourceMapURLCallback,
replayConsoleLogs?: boolean,
environmentName?: string,
startTime?: number,
endTime?: number,
};
function createDebugCallbackFromWritableStream(
debugWritable: WritableStream,
): DebugChannelCallback {
const textEncoder = new TextEncoder();
const writer = debugWritable.getWriter();
return message => {
if (message === '') {
writer.close();
} else {
// Note: It's important that this function doesn't close over the Response object or it can't be GC:ed.
// Therefore, we can't report errors from this write back to the Response object.
if (__DEV__) {
writer.write(textEncoder.encode(message + '\n')).catch(console.error);
}
}
};
}
function createResponseFromOptions(options: void | Options) {
const debugChannel: void | DebugChannel =
__DEV__ && options && options.debugChannel !== undefined
? {
hasReadable: options.debugChannel.readable !== undefined,
callback:
options.debugChannel.writable !== undefined
? createDebugCallbackFromWritableStream(
options.debugChannel.writable,
)
: null,
}
: undefined;
return createResponse(
null,
null,
null,
options && options.callServer ? options.callServer : undefined,
undefined, // encodeFormAction
undefined, // nonce
options && options.temporaryReferences
? options.temporaryReferences
: undefined,
options && options.unstable_allowPartialStream
? options.unstable_allowPartialStream
: false,
__DEV__ && options && options.findSourceMapURL
? options.findSourceMapURL
: undefined,
__DEV__ ? (options ? options.replayConsoleLogs !== false : true) : false, // defaults to true
__DEV__ && options && options.environmentName
? options.environmentName
: undefined,
__DEV__ && options && options.startTime != null
? options.startTime
: undefined,
__DEV__ && options && options.endTime != null ? options.endTime : undefined,
debugChannel,
);
}
function startReadingFromUniversalStream(
response: FlightResponse,
stream: ReadableStream,
onDone: () => void,
): void {
// This is the same as startReadingFromStream except this allows WebSocketStreams which
// return ArrayBuffer and string chunks instead of Uint8Array chunks. We could potentially
// always allow streams with variable chunk types.
const streamState = createStreamState(response, stream);
const reader = stream.getReader();
function progress({
done,
value,
}: {
done: boolean,
value: any,
...
}): void | Promise<void> {
if (done) {
return onDone();
}
if (value instanceof ArrayBuffer) {
// WebSockets can produce ArrayBuffer values in ReadableStreams.
processBinaryChunk(response, streamState, new Uint8Array(value));
} else if (typeof value === 'string') {
// WebSockets can produce string values in ReadableStreams.
processStringChunk(response, streamState, value);
} else {
processBinaryChunk(response, streamState, value);
}
return reader.read().then(progress).catch(error);
}
function error(e: any) {
reportGlobalError(response, e);
}
reader.read().then(progress).catch(error);
}
function startReadingFromStream(
response: FlightResponse,
stream: ReadableStream,
onDone: () => void,
debugValue: mixed,
): void {
const streamState = createStreamState(response, debugValue);
const reader = stream.getReader();
function progress({
done,
value,
}: {
done: boolean,
value: ?any,
...
}): void | Promise<void> {
if (done) {
return onDone();
}
const buffer: Uint8Array = (value: any);
processBinaryChunk(response, streamState, buffer);
return reader.read().then(progress).catch(error);
}
function error(e: any) {
reportGlobalError(response, e);
}
reader.read().then(progress).catch(error);
}
function createFromReadableStream<T>(
stream: ReadableStream,
options?: Options,
): Thenable<T> {
const response: FlightResponse = createResponseFromOptions(options);
if (
__DEV__ &&
options &&
options.debugChannel &&
options.debugChannel.readable
) {
let streamDoneCount = 0;
const handleDone = () => {
if (++streamDoneCount === 2) {
close(response);
}
};
startReadingFromUniversalStream(
response,
options.debugChannel.readable,
handleDone,
);
startReadingFromStream(response, stream, handleDone, stream);
} else {
startReadingFromStream(
response,
stream,
close.bind(null, response),
stream,
);
}
return getRoot(response);
}
function createFromFetch<T>(
promiseForResponse: Promise<Response>,
options?: Options,
): Thenable<T> {
const response: FlightResponse = createResponseFromOptions(options);
promiseForResponse.then(
function (r) {
if (
__DEV__ &&
options &&
options.debugChannel &&
options.debugChannel.readable
) {
let streamDoneCount = 0;
const handleDone = () => {
if (++streamDoneCount === 2) {
close(response);
}
};
startReadingFromUniversalStream(
response,
options.debugChannel.readable,
handleDone,
);
startReadingFromStream(response, (r.body: any), handleDone, r);
} else {
startReadingFromStream(
response,
(r.body: any),
close.bind(null, response),
r,
);
}
},
function (e) {
reportGlobalError(response, e);
},
);
return getRoot(response);
}
function encodeReply(
value: ReactServerValue,
options?: {temporaryReferences?: TemporaryReferenceSet, signal?: AbortSignal},
): Promise<
string | URLSearchParams | FormData,
> /* We don't use URLSearchParams yet but maybe */ {
return new Promise((resolve, reject) => {
const abort = processReply(
value,
'',
options && options.temporaryReferences
? options.temporaryReferences
: undefined,
resolve,
reject,
);
if (options && options.signal) {
const signal = options.signal;
if (signal.aborted) {
abort((signal: any).reason);
} else {
const listener = () => {
abort((signal: any).reason);
signal.removeEventListener('abort', listener);
};
signal.addEventListener('abort', listener);
}
}
});
}
export {createFromFetch, createFromReadableStream, encodeReply};
if (__DEV__) {
injectIntoDevTools();
}