@samitouri / QOS-React / commits / 9b5b4d51e5

[Flight] Add 'pending_weak' to Flight thenable protocol (#37154)

Added behind a new experimental flag, `enableFlightWeakThenables`. Adds a new thenable status to the Flight protocol: `'pending_weak'`. Unlike a regular pending thenable, a weak thenable does not block the stream from closing. If it settles while the stream is still open, its value is emitted like a normal pending thenable. Otherwise its reference is left unfulfilled and on the client it stays forever pending, without erroring, even when the connection closes. It's up to the client to handle the unresolved promise in an appropriate way. The motivating use case is being able to encode metadata about a Flight stream into the response itself. For example, a framework might want to track whether a page varies by search params. It could represent this in the response as a `Promise<boolean>` that resolves to `true` as soon as the component being rendered in the stream accesses search params. If the thenable never resolves by the time the stream closes, then the client knows that no search params were ever accessed. In the future we could add a higher-level API for encoding this kind of information. For now, we intentionally start with the low-level primitive so frameworks can experiment in userspace without adding significantly to React's surface area. Internally, Flight already uses its own private thenable statuses, like `'resolved_model'`, and the protocol is designed to treat any status besides `'fulfilled'` and `'rejected'` as equivalent to `'pending'`, so `'pending_weak'` slots into the existing machinery. On the wire, a weak reference is encoded as `$w<id>`, next to `$@<id>` for regular promises, so the client knows its row may intentionally never arrive. On the client, a weak reference behaves like any other pending promise until the response closes; then, instead of erroring, it is left forever pending.

Andrew Clark committed Jul 31, 2026 at 13:22 UTC 9b5b4d51e5be870d396a9568701259e5eb053668
11 files changed +680 -79
packages/react-client/src/ReactFlightClient.js
+121 -30
@@ -46,6 +46,7 @@ import {
46 enableProfilerTimer,
47 enableComponentPerformanceTrack,
48 enableAsyncDebugInfo,
49 + enableFlightWeakThenables,
50 } from 'shared/ReactFeatureFlags';
51
52 import {
@@ -150,12 +151,21 @@ const ROW_CHUNK_BY_LENGTH = 4;
151 type RowParserState = 0 | 1 | 2 | 3 | 4;
152
153 const PENDING = 'pending';
154 +// A weak Promise reference. Behaves like PENDING except that when the stream
155 +// closes it transitions to HALTED instead of erroring, because the server
156 +// may intentionally never emit it. Only used when enableFlightWeakThenables
157 +// is on.
158 +const PENDING_WEAK = 'pending_weak';
159 const BLOCKED = 'blocked';
160 const RESOLVED_MODEL = 'resolved_model';
161 const RESOLVED_MODULE = 'resolved_module';
162 const INITIALIZED = 'fulfilled';
163 const ERRORED = 'rejected';
158 -const HALTED = 'halted'; // DEV-only. Means it never resolves even if connection closes.
164 +// Means it never resolves, even when the connection closes. The shared
165 +// terminal state of a weak chunk that didn't settle before close, of any
166 +// pending chunk at close when partial streams are allowed, and of DEV-only
167 +// debug halts.
168 +const HALTED = 'halted';
169
170 const __PROTO__ = '__proto__';
171
@@ -171,6 +181,15 @@ type PendingChunk<T> = {
181 _debugInfo: ReactDebugInfo, // DEV-only
182 then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
183 };
184 +type PendingWeakChunk<T> = {
185 + status: 'pending_weak',
186 + value: null | Array<InitializationReference | (T => mixed)>,
187 + reason: null | Array<InitializationReference | (mixed => mixed)>,
188 + _children: Array<SomeChunk<any>> | ProfilingResult, // Profiling-only
189 + _debugChunk: null | SomeChunk<ReactDebugInfoEntry>, // DEV-only
190 + _debugInfo: ReactDebugInfo, // DEV-only
191 + then(resolve: (T) => mixed, reject?: (mixed) => mixed): void,
192 +};
193 type BlockedChunk<T> = {
194 status: 'blocked',
195 value: null | Array<InitializationReference | (T => mixed)>,
@@ -238,6 +257,7 @@ type HaltedChunk<T> = {
257 };
258 type SomeChunk<T> =
259 | PendingChunk<T>
260 + | PendingWeakChunk<T>
261 | BlockedChunk<T>
262 | ResolvedModelChunk<T>
263 | ResolvedModuleChunk<T>
@@ -306,6 +326,7 @@ function reactPromiseThen<T>(
326 }
327 break;
328 case PENDING:
329 + case PENDING_WEAK:
330 case BLOCKED:
331 if (typeof resolve === 'function') {
332 if (chunk.value === null) {
@@ -449,6 +470,7 @@ function readChunk<T>(chunk: SomeChunk<T>): T {
470 case INITIALIZED:
471 return chunk.value;
472 case PENDING:
473 + case PENDING_WEAK:
474 case BLOCKED:
475 case HALTED:
476 // eslint-disable-next-line no-throw-literal
@@ -479,6 +501,13 @@ function createPendingChunk<T>(response: Response): PendingChunk<T> {
501 return new ReactPromise(PENDING, null, null);
502 }
503
504 +function createPendingWeakChunk<T>(response: Response): PendingWeakChunk<T> {
505 + // Unlike a regular pending chunk, a weak chunk may never settle, so it
506 + // doesn't retain a strong reference to the Response while it waits.
507 + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
508 + return new ReactPromise(PENDING_WEAK, null, null);
509 +}
510 +
511 function releasePendingChunk(response: Response, chunk: SomeChunk<any>): void {
512 if (__DEV__ && chunk.status === PENDING) {
513 if (--response._pendingChunks === 0) {
@@ -497,6 +526,22 @@ function releasePendingChunk(response: Response, chunk: SomeChunk<any>): void {
526 }
527 }
528
529 +function createHaltedChunk<T>(response: Response): HaltedChunk<T> {
530 + // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
531 + return new ReactPromise(HALTED, null, null);
532 +}
533 +
534 +// Transition a chunk to HALTED: it will never resolve, even when the
535 +// connection closes. Clears any listeners to release their closures. Future
536 +// .then() calls on HALTED chunks are no-ops.
537 +function haltChunk<T>(response: Response, chunk: SomeChunk<T>): void {
538 + releasePendingChunk(response, chunk);
539 + const haltedChunk: HaltedChunk<T> = chunk as any;
540 + haltedChunk.status = HALTED;
541 + haltedChunk.value = null;
542 + haltedChunk.reason = null;
543 +}
544 +
545 function createBlockedChunk<T>(response: Response): BlockedChunk<T> {
546 // $FlowFixMe[invalid-constructor] Flow doesn't support functions as constructors
547 return new ReactPromise(BLOCKED, null, null);
@@ -755,7 +800,11 @@ function triggerErrorOnChunk<T>(
800 chunk: SomeChunk<T>,
801 error: mixed,
802 ): void {
758 - if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
803 + if (
804 + chunk.status !== PENDING &&
805 + chunk.status !== PENDING_WEAK &&
806 + chunk.status !== BLOCKED
807 + ) {
808 // If we get more data to an already resolved ID, we assume that it's
809 // a stream chunk since any other row shouldn't have more than one entry.
810 const streamChunk: InitializedStreamChunk<any> = chunk as any;
@@ -767,7 +816,7 @@ function triggerErrorOnChunk<T>(
816 releasePendingChunk(response, chunk);
817 const listeners = chunk.reason;
818
770 - if (__DEV__ && chunk.status === PENDING) {
819 + if (__DEV__ && (chunk.status === PENDING || chunk.status === PENDING_WEAK)) {
820 // Lazily initialize any debug info and block the initializing chunk on any unresolved entries.
821 if (chunk._debugChunk != null) {
822 const prevHandler = initializingHandler;
@@ -898,7 +947,7 @@ function resolveModelChunk<T>(
947 chunk: SomeChunk<T>,
948 value: UninitializedModel,
949 ): void {
901 - if (chunk.status !== PENDING) {
950 + if (chunk.status !== PENDING && chunk.status !== PENDING_WEAK) {
951 // If we get more data to an already resolved ID, we assume that it's
952 // a stream chunk since any other row shouldn't have more than one entry.
953 const streamChunk: InitializedStreamChunk<any> = chunk as any;
@@ -928,7 +977,11 @@ function resolveModuleChunk<T>(
977 chunk: SomeChunk<T>,
978 value: ClientReference<T>,
979 ): void {
931 - if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
980 + if (
981 + chunk.status !== PENDING &&
982 + chunk.status !== PENDING_WEAK &&
983 + chunk.status !== BLOCKED
984 + ) {
985 // We already resolved. We didn't expect to see this.
986 return;
987 }
@@ -980,7 +1033,7 @@ let isInitializingDebugInfo: boolean = false;
1033
1034 function initializeDebugChunk(
1035 response: Response,
983 - chunk: ResolvedModelChunk<any> | PendingChunk<any>,
1036 + chunk: ResolvedModelChunk<any> | PendingChunk<any> | PendingWeakChunk<any>,
1037 ): void {
1038 const debugChunk = chunk._debugChunk;
1039 if (debugChunk !== null) {
@@ -1010,7 +1063,8 @@ function initializeDebugChunk(
1063 break;
1064 }
1065 case BLOCKED:
1013 - case PENDING: {
1066 + case PENDING:
1067 + case PENDING_WEAK: {
1068 waitForReference(
1069 initializedChunk,
1070 debugInfo,
@@ -1032,7 +1086,8 @@ function initializeDebugChunk(
1086 break;
1087 }
1088 case BLOCKED:
1035 - case PENDING: {
1089 + case PENDING:
1090 + case PENDING_WEAK: {
1091 // Signal to the caller that we need to wait.
1092 waitForReference(
1093 debugChunk,
@@ -1168,6 +1223,10 @@ export function reportGlobalError(
1223 // because we won't be getting any new data to resolve it.
1224 if (chunk.status === PENDING) {
1225 triggerErrorOnChunk(response, chunk, error);
1226 + } else if (enableFlightWeakThenables && chunk.status === PENDING_WEAK) {
1227 + // A weak Promise reference may never be emitted by the server. It
1228 + // stays forever pending instead of erroring.
1229 + haltChunk(response, chunk);
1230 } else if (chunk.status === INITIALIZED && chunk.reason !== null) {
1231 chunk.reason.error(error);
1232 }
@@ -1502,11 +1561,7 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
1561 if (response._allowPartialStream) {
1562 // For partial streams, chunks accessed after close should be HALTED
1563 // (never resolve).
1505 - chunk = createPendingChunk(response);
1506 - const haltedChunk: HaltedChunk<any> = chunk as any;
1507 - haltedChunk.status = HALTED;
1508 - haltedChunk.value = null;
1509 - haltedChunk.reason = null;
1564 + chunk = createHaltedChunk(response);
1565 } else {
1566 // We have already errored the response and we're not going to get
1567 // anything more streaming in so this will immediately error.
@@ -1520,6 +1575,25 @@ function getChunk(response: Response, id: number): SomeChunk<any> {
1575 return chunk;
1576 }
1577
1578 +// Like getChunk, but for weak Promise references. The server may never emit
1579 +// the row for a weak reference, so an unresolved weak chunk halts (stays
1580 +// forever pending) instead of erroring when the stream closes.
1581 +function getWeakChunk(response: Response, id: number): SomeChunk<any> {
1582 + const chunks = response._chunks;
1583 + let chunk = chunks.get(id);
1584 + if (!chunk) {
1585 + if (response._closed) {
1586 + // The stream already closed without emitting this row, so it will
1587 + // never resolve.
1588 + chunk = createHaltedChunk(response);
1589 + } else {
1590 + chunk = createPendingWeakChunk(response);
1591 + }
1592 + chunks.set(id, chunk);
1593 + }
1594 + return chunk;
1595 +}
1596 +
1597 function fulfillReference(
1598 response: Response,
1599 reference: InitializationReference,
@@ -1574,7 +1648,8 @@ function fulfillReference(
1648 }
1649 // Fallthrough
1650 }
1577 - case PENDING: {
1651 + case PENDING:
1652 + case PENDING_WEAK: {
1653 // If we're not yet initialized we need to skip what we've already drilled
1654 // through and then wait for the next value to become available.
1655 path.splice(0, i - 1);
@@ -1778,7 +1853,7 @@ function rejectReference(
1853 }
1854
1855 function waitForReference<T>(
1781 - referencedChunk: PendingChunk<T> | BlockedChunk<T>,
1856 + referencedChunk: PendingChunk<T> | PendingWeakChunk<T> | BlockedChunk<T>,
1857 parentObject: Object,
1858 key: string,
1859 response: Response,
@@ -2124,7 +2199,8 @@ function getOutlinedModel<T>(
2199 break;
2200 }
2201 case BLOCKED:
2127 - case PENDING: {
2202 + case PENDING:
2203 + case PENDING_WEAK: {
2204 return waitForReference(
2205 referencedChunk,
2206 parentObject,
@@ -2233,6 +2309,7 @@ function getOutlinedModel<T>(
2309 }
2310 return chunkValue;
2311 case PENDING:
2312 + case PENDING_WEAK:
2313 case BLOCKED:
2314 return waitForReference(
2315 chunk,
@@ -2469,6 +2546,23 @@ function parseModelString(
2546 }
2547 return chunk;
2548 }
2549 + case 'w': {
2550 + if (enableFlightWeakThenables) {
2551 + // Weak Promise
2552 + const id = parseInt(value.slice(2), 16);
2553 + const chunk = getWeakChunk(response, id);
2554 + if (enableProfilerTimer && enableComponentPerformanceTrack) {
2555 + if (
2556 + initializingChunk !== null &&
2557 + isArray(initializingChunk._children)
2558 + ) {
2559 + initializingChunk._children.push(chunk);
2560 + }
2561 + }
2562 + return chunk;
2563 + }
2564 + return undefined;
2565 + }
2566 case 'S': {
2567 // Symbol
2568 return Symbol.for(value.slice(2));
@@ -3035,14 +3129,14 @@ function resolveDebugHalt(response: Response, id: number): void {
3129 chunks.set(id, (chunk = createPendingChunk(response)));
3130 } else {
3131 }
3038 - if (chunk.status !== PENDING && chunk.status !== BLOCKED) {
3132 + if (
3133 + chunk.status !== PENDING &&
3134 + chunk.status !== PENDING_WEAK &&
3135 + chunk.status !== BLOCKED
3136 + ) {
3137 return;
3138 }
3041 - releasePendingChunk(response, chunk);
3042 - const haltedChunk: HaltedChunk<any> = chunk as any;
3043 - haltedChunk.status = HALTED;
3044 - haltedChunk.value = null;
3045 - haltedChunk.reason = null;
3139 + haltChunk(response, chunk);
3140 }
3141
3142 function resolveModel(
@@ -5428,14 +5522,11 @@ export function close(weakResponse: WeakResponse): void {
5522 // For partial streams, we halt pending chunks instead of erroring them.
5523 response._closed = true;
5524 response._chunks.forEach(chunk => {
5431 - if (chunk.status === PENDING) {
5432 - // Clear listeners to release closures and transition to HALTED.
5433 - // Future .then() calls on HALTED chunks are no-ops.
5434 - releasePendingChunk(response, chunk);
5435 - const haltedChunk: HaltedChunk<any> = chunk as any;
5436 - haltedChunk.status = HALTED;
5437 - haltedChunk.value = null;
5438 - haltedChunk.reason = null;
5525 + if (
5526 + chunk.status === PENDING ||
5527 + (enableFlightWeakThenables && chunk.status === PENDING_WEAK)
5528 + ) {
5529 + haltChunk(response, chunk);
5530 } else if (chunk.status === INITIALIZED && chunk.reason !== null) {
5531 // Stream chunk - close gracefully instead of erroring.
5532 chunk.reason.close('"$undefined"');
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+350
@@ -2424,4 +2424,354 @@ describe('ReactFlightDOMEdge', () => {
2424 ).toString(),
2425 ).toBe('function () { [omitted code] }');
2426 });
2427 +
2428 + // A thenable with status 'pending_weak' doesn't keep the Flight stream
2429 + // open. If it settles before the stream closes for other reasons its value
2430 + // is emitted like a normal pending thenable; otherwise its reference is
2431 + // left unfulfilled and stays forever pending on the client.
2432 + //
2433 + // A framework-style tracker for whether a page accessed its search params
2434 + // during a render. The params object is instrumented so that the first
2435 + // access settles the usedSearchParams thenable. It settles synchronously
2436 + // at the access point, so an access is guaranteed to be encoded before
2437 + // the response closes.
2438 + function createSearchParams(values) {
2439 + const listeners = [];
2440 + const usedSearchParams = {
2441 + status: 'pending_weak',
2442 + value: undefined,
2443 + then(onFulfill) {
2444 + if (usedSearchParams.status === 'fulfilled') {
2445 + onFulfill(usedSearchParams.value);
2446 + } else {
2447 + listeners.push(onFulfill);
2448 + }
2449 + },
2450 + };
2451 + const searchParams = new Proxy(values, {
2452 + get(target, key) {
2453 + if (usedSearchParams.status === 'pending_weak') {
2454 + usedSearchParams.status = 'fulfilled';
2455 + usedSearchParams.value = true;
2456 + for (let i = 0; i < listeners.length; i++) {
2457 + listeners[i](true);
2458 + }
2459 + listeners.length = 0;
2460 + }
2461 + return target[key];
2462 + },
2463 + });
2464 + return {searchParams, usedSearchParams};
2465 + }
2466 +
2467 + it('emits the value of a weak-pending thenable that settles during the render', async () => {
2468 + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'});
2469 +
2470 + function Page() {
2471 + return <div>{'Results for ' + searchParams.q}</div>;
2472 + }
2473 +
2474 + let response;
2475 + await serverAct(() => {
2476 + const stream = ReactServerDOMServer.renderToReadableStream({
2477 + usedSearchParams,
2478 + root: <Page />,
2479 + });
2480 + // Start consuming immediately, like a server that pipes the response
2481 + // while it renders.
2482 + response = ReactServerDOMClient.createFromReadableStream(stream, {
2483 + serverConsumerManifest: {
2484 + moduleMap: null,
2485 + moduleLoading: null,
2486 + },
2487 + });
2488 + });
2489 +
2490 + const result = await response;
2491 + expect(await result.usedSearchParams).toBe(true);
2492 +
2493 + const ssrStream = await serverAct(() =>
2494 + ReactDOMServer.renderToReadableStream(result.root),
2495 + );
2496 + expect(await readResult(ssrStream)).toBe('<div>Results for react</div>');
2497 + });
2498 +
2499 + // @gate enableFlightWeakThenables
2500 + it('completes the response without waiting for a weak-pending thenable that never settles', async () => {
2501 + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'});
2502 +
2503 + function Page() {
2504 + return <div>Static content</div>;
2505 + }
2506 +
2507 + const stream = await serverAct(() =>
2508 + ReactServerDOMServer.renderToReadableStream({
2509 + usedSearchParams,
2510 + root: <Page />,
2511 + }),
2512 + );
2513 + const [stream1, stream2] = stream.tee();
2514 +
2515 + let content = null;
2516 + const readPromise = readResult(stream1).then(c => (content = c));
2517 + await serverAct(async () => {});
2518 + // The response completed even though the weak thenable never settled.
2519 + expect(content).not.toBe(null);
2520 + await readPromise;
2521 +
2522 + const result = await ReactServerDOMClient.createFromReadableStream(
2523 + stream2,
2524 + {
2525 + serverConsumerManifest: {
2526 + moduleMap: null,
2527 + moduleLoading: null,
2528 + },
2529 + },
2530 + );
2531 +
2532 + // Accessing the params after the response already completed doesn't do
2533 + // anything.
2534 + expect(searchParams.q).toBe('react');
2535 +
2536 + // The reference is left forever pending, without erroring.
2537 + const raced = await Promise.race([
2538 + result.usedSearchParams,
2539 + Promise.resolve('never accessed'),
2540 + ]);
2541 + expect(raced).toBe('never accessed');
2542 + });
2543 +
2544 + it('emits the value of a weak-pending thenable that settles while the response is still streaming', async () => {
2545 + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'});
2546 +
2547 + let resolveData;
2548 + const data = new Promise(res => (resolveData = res));
2549 + async function Results() {
2550 + const filter = await data;
2551 + return <div>{'Results for ' + searchParams[filter]}</div>;
2552 + }
2553 +
2554 + const stream = await serverAct(() =>
2555 + ReactServerDOMServer.renderToReadableStream({
2556 + usedSearchParams,
2557 + root: <Results />,
2558 + }),
2559 + );
2560 + const [stream1, stream2] = stream.tee();
2561 +
2562 + let content = null;
2563 + const readPromise = readResult(stream1).then(c => (content = c));
2564 +
2565 + // The response stays open while the data is loading — because of the
2566 + // async component, not because of the unresolved weak thenable.
2567 + await serverAct(async () => {});
2568 + expect(content).toBe(null);
2569 +
2570 + // The data resolves, the component accesses the search params, and the
2571 + // response completes.
2572 + await serverAct(() => resolveData('q'));
2573 + await serverAct(async () => {});
2574 + expect(content).not.toBe(null);
2575 + await readPromise;
2576 +
2577 + const result = await ReactServerDOMClient.createFromReadableStream(
2578 + stream2,
2579 + {
2580 + serverConsumerManifest: {
2581 + moduleMap: null,
2582 + moduleLoading: null,
2583 + },
2584 + },
2585 + );
2586 + expect(await result.usedSearchParams).toBe(true);
2587 +
2588 + const ssrStream = await serverAct(() =>
2589 + ReactDOMServer.renderToReadableStream(result.root),
2590 + );
2591 + expect(await readResult(ssrStream)).toBe('<div>Results for react</div>');
2592 + });
2593 +
2594 + // @gate !enableFlightWeakThenables
2595 + it('treats a weak-pending thenable like a normal pending thenable when the flag is off', async () => {
2596 + const {searchParams, usedSearchParams} = createSearchParams({q: 'react'});
2597 +
2598 + function Page() {
2599 + return <div>Static content</div>;
2600 + }
2601 +
2602 + const stream = await serverAct(() =>
2603 + ReactServerDOMServer.renderToReadableStream({
2604 + usedSearchParams,
2605 + root: <Page />,
2606 + }),
2607 + );
2608 + const [stream1, stream2] = stream.tee();
2609 +
2610 + let content = null;
2611 + const readPromise = readResult(stream1).then(c => (content = c));
2612 +
2613 + // Without the flag, the unknown thenable status is treated as an
2614 + // ordinary pending thenable, which keeps the response open.
2615 + await serverAct(async () => {});
2616 + expect(content).toBe(null);
2617 +
2618 + // Accessing the params settles the thenable and lets the response
2619 + // complete.
2620 + await serverAct(() => {
2621 + expect(searchParams.q).toBe('react');
2622 + });
2623 + await serverAct(async () => {});
2624 + expect(content).not.toBe(null);
2625 + await readPromise;
2626 +
2627 + const result = await ReactServerDOMClient.createFromReadableStream(
2628 + stream2,
2629 + {
2630 + serverConsumerManifest: {
2631 + moduleMap: null,
2632 + moduleLoading: null,
2633 + },
2634 + },
2635 + );
2636 + expect(await result.usedSearchParams).toBe(true);
2637 + });
2638 +
2639 + // @gate enableFlightWeakThenables
2640 + it('supports linked lists of weak-pending thenables', async () => {
2641 + // Weak thenables compose recursively: the value that a weak-pending
2642 + // thenable settles with can itself contain more weak-pending thenables.
2643 + // A linked list of them forms an async sequence that never blocks the
2644 + // response from completing. Modeled here as a framework tracking which
2645 + // params a page accessed during a dynamic render, encoded into the
2646 + // response itself as WeakThenable<{value: T, next: WeakThenable<...>}>.
2647 + function instrumentParams(params) {
2648 + function createWeakNode() {
2649 + const listeners = [];
2650 + const node = {
2651 + status: 'pending_weak',
2652 + value: undefined,
2653 + then(onFulfill) {
2654 + if (node.status === 'fulfilled') {
2655 + onFulfill(node.value);
2656 + } else {
2657 + listeners.push(onFulfill);
2658 + }
2659 + },
2660 + };
2661 + return {node, listeners};
2662 + }
2663 + let tail = createWeakNode();
2664 + const head = tail.node;
2665 + const accessed = new Set();
2666 + const instrumentedParams = new Proxy(params, {
2667 + get(target, name) {
2668 + if (
2669 + typeof name === 'string' &&
2670 + name in target &&
2671 + !accessed.has(name)
2672 + ) {
2673 + accessed.add(name);
2674 + const settledTail = tail;
2675 + tail = createWeakNode();
2676 + // Settle the tail of the list synchronously at the access point
2677 + // so it's guaranteed to be encoded before the response closes.
2678 + const result = {value: name, next: tail.node};
2679 + settledTail.node.status = 'fulfilled';
2680 + settledTail.node.value = result;
2681 + for (let i = 0; i < settledTail.listeners.length; i++) {
2682 + settledTail.listeners[i](result);
2683 + }
2684 + settledTail.listeners.length = 0;
2685 + }
2686 + return target[name];
2687 + },
2688 + });
2689 + return {params: instrumentedParams, accessedParams: head};
2690 + }
2691 +
2692 + const {params, accessedParams} = instrumentParams({
2693 + a: 'value-of-a',
2694 + b: 'value-of-b',
2695 + c: 'value-of-c',
2696 + });
2697 +
2698 + function Page() {
2699 + // The page reads param a during the render.
2700 + return 'Accessed: ' + params.a;
2701 + }
2702 +
2703 + let resolveNormal;
2704 + const pending = new Promise(res => {
2705 + resolveNormal = res;
2706 + });
2707 +
2708 + const stream = await serverAct(() =>
2709 + ReactServerDOMServer.renderToReadableStream({
2710 + accessedParams,
2711 + page: <Page />,
2712 + pending,
2713 + }),
2714 + );
2715 + const [stream1, stream2] = stream.tee();
2716 +
2717 + let content = null;
2718 + const readPromise = readResult(stream1).then(c => (content = c));
2719 +
2720 + // While the normal pending promise holds the stream open, param c is
2721 + // accessed, settling the next node of the list.
2722 + await serverAct(() => {
2723 + expect(params.c).toBe('value-of-c');
2724 + });
2725 + await serverAct(async () => {});
2726 + expect(content).toBe(null);
2727 +
2728 + // Param b is never accessed, so the tail of the list stays unsettled.
2729 + // It doesn't keep the response open: once the normal promise resolves,
2730 + // the response completes.
2731 + await serverAct(() => {
2732 + resolveNormal('done');
2733 + });
2734 + await serverAct(async () => {});
2735 + expect(content).not.toBe(null);
2736 + await readPromise;
2737 +
2738 + const result = await ReactServerDOMClient.createFromReadableStream(
2739 + stream2,
2740 + {
2741 + serverConsumerManifest: {
2742 + moduleMap: null,
2743 + moduleLoading: null,
2744 + },
2745 + },
2746 + );
2747 + expect(result.page).toBe('Accessed: value-of-a');
2748 +
2749 + // Wait until the full response has been processed.
2750 + await serverAct(async () => {});
2751 +
2752 + // Read the accessed params off the list, synchronously. A node that
2753 + // was never settled by the server stays forever pending, without
2754 + // erroring, which marks the end of the accessed params.
2755 + function readNode(node) {
2756 + // Attach a no-op listener to force Flight to synchronously unwrap a
2757 + // node that was received but not yet initialized.
2758 + node.then(() => {});
2759 + if (node.status !== 'fulfilled') {
2760 + return null;
2761 + }
2762 + return node.value;
2763 + }
2764 +
2765 + const accessed = [];
2766 + let node = result.accessedParams;
2767 + while (node !== null) {
2768 + const entry = readNode(node);
2769 + if (entry === null) {
2770 + break;
2771 + }
2772 + accessed.push(entry.value);
2773 + node = entry.next;
2774 + }
2775 + expect(accessed).toEqual(['a', 'c']);
2776 + });
2777 });
packages/react-server/src/ReactFlightServer.js
+191 -49
@@ -16,6 +16,7 @@ import {
16 enableProfilerTimer,
17 enableComponentPerformanceTrack,
18 enableAsyncDebugInfo,
19 + enableFlightWeakThenables,
20 } from 'shared/ReactFeatureFlags';
21
22 import {
@@ -1078,12 +1079,12 @@ function emitRequestedDebugThenable(
1079 );
1080 }
1081
1081 -function serializeThenable(
1082 +function createThenableTask(
1083 request: Request,
1084 task: Task,
1085 thenable: Thenable<any>,
1085 -): number {
1086 - const newTask = createTask(
1086 +): Task {
1087 + return createTask(
1088 request,
1089 thenable as any, // will be replaced by the value before we retry. used for debug info.
1090 task.keyPath, // the server component sequence continues through Promise-as-a-child.
@@ -1098,9 +1099,16 @@ function serializeThenable(
1099 __DEV__ ? task.debugStack : null,
1100 __DEV__ ? task.debugTask : null,
1101 );
1102 +}
1103
1104 +function serializeThenable(
1105 + request: Request,
1106 + task: Task,
1107 + thenable: Thenable<any>,
1108 +): number {
1109 switch (thenable.status) {
1110 case 'fulfilled': {
1111 + const newTask = createThenableTask(request, task, thenable);
1112 forwardDebugInfoFromThenable(request, newTask, thenable, null, null);
1113 // We have the resolved value, we can go ahead and schedule it for serialization.
1114 newTask.model = thenable.value;
@@ -1108,12 +1116,102 @@ function serializeThenable(
1116 return newTask.id;
1117 }
1118 case 'rejected': {
1119 + const newTask = createThenableTask(request, task, thenable);
1120 forwardDebugInfoFromThenable(request, newTask, thenable, null, null);
1121 const x = thenable.reason;
1122 erroredTask(request, newTask, x);
1123 return newTask.id;
1124 }
1125 + case 'pending_weak': {
1126 + if (enableFlightWeakThenables) {
1127 + // A weak-pending thenable doesn't block the stream from closing, so
1128 + // we don't create a task for it yet. We only reserve an id for its
1129 + // reference. If it settles while the stream is still open, we
1130 + // create the task at that point, the same as if we had serialized
1131 + // an already settled thenable.
1132 + //
1133 + // Delivery is driven by the thenable's notification. If the stream
1134 + // closes before the listeners are notified, the value is dropped
1135 + // and the reference is left unfulfilled. Since the stream may close
1136 + // synchronously when the last task completes, a thenable that
1137 + // notifies its listeners synchronously (unlike a native Promise,
1138 + // which notifies in a microtask) is guaranteed delivery of any
1139 + // value it settles with before the stream closes.
1140 + const id = request.nextChunkId++;
1141 + // The parent task is mutated as serialization continues, so we
1142 + // snapshot the context that the new task needs if it's created
1143 + // later.
1144 + const keyPath = task.keyPath;
1145 + const implicitSlot = task.implicitSlot;
1146 + const formatContext = task.formatContext;
1147 + const lastTimestamp =
1148 + enableProfilerTimer &&
1149 + (enableComponentPerformanceTrack || enableAsyncDebugInfo)
1150 + ? task.time
1151 + : 0;
1152 + const debugOwner = __DEV__ ? task.debugOwner : null;
1153 + const debugStack = __DEV__ ? task.debugStack : null;
1154 + const debugTask = __DEV__ ? task.debugTask : null;
1155 + let settled = false;
1156 + thenable.then(
1157 + (value: any) => {
1158 + if (settled || request.status > OPEN) {
1159 + // Too late. The stream already closed (or the request was
1160 + // aborted), so the reference stays unfulfilled.
1161 + return;
1162 + }
1163 + settled = true;
1164 + const newTask = createTaskWithID(
1165 + request,
1166 + id,
1167 + value,
1168 + keyPath,
1169 + implicitSlot,
1170 + formatContext,
1171 + request.abortableTasks,
1172 + lastTimestamp,
1173 + debugOwner,
1174 + debugStack,
1175 + debugTask,
1176 + );
1177 + forwardDebugInfoFromCurrentContext(request, newTask, thenable);
1178 + pingTask(request, newTask);
1179 + },
1180 + (reason: mixed) => {
1181 + if (settled || request.status > OPEN) {
1182 + return;
1183 + }
1184 + settled = true;
1185 + const newTask = createTaskWithID(
1186 + request,
1187 + id,
1188 + thenable as any, // never rendered. used for debug info.
1189 + keyPath,
1190 + implicitSlot,
1191 + formatContext,
1192 + request.abortableTasks,
1193 + lastTimestamp,
1194 + debugOwner,
1195 + debugStack,
1196 + debugTask,
1197 + );
1198 + if (
1199 + enableProfilerTimer &&
1200 + (enableComponentPerformanceTrack || enableAsyncDebugInfo)
1201 + ) {
1202 + // If this is async we need to time when this task finishes.
1203 + newTask.timed = true;
1204 + }
1205 + erroredTask(request, newTask, reason);
1206 + enqueueFlush(request);
1207 + },
1208 + );
1209 + return id;
1210 + }
1211 + // Fallthrough
1212 + }
1213 default: {
1214 + const newTask = createThenableTask(request, task, thenable);
1215 if (request.status === ABORTING) {
1216 // We can no longer accept any resolved values
1217 request.abortableTasks.delete(newTask);
@@ -1127,59 +1225,56 @@ function serializeThenable(
1225 }
1226 return newTask.id;
1227 }
1130 - if (typeof thenable.status === 'string') {
1228 + if (typeof thenable.status !== 'string') {
1229 // Only instrument the thenable if the status if not defined. If
1230 // it's defined, but an unknown value, assume it's been instrumented by
1231 // some custom userspace implementation. We treat it as "pending".
1134 - break;
1232 + const pendingThenable: PendingThenable<mixed> = thenable as any;
1233 + pendingThenable.status = 'pending';
1234 + pendingThenable.then(
1235 + fulfilledValue => {
1236 + if (thenable.status === 'pending') {
1237 + const fulfilledThenable: FulfilledThenable<mixed> =
1238 + thenable as any;
1239 + fulfilledThenable.status = 'fulfilled';
1240 + fulfilledThenable.value = fulfilledValue;
1241 + }
1242 + },
1243 + (error: mixed) => {
1244 + if (thenable.status === 'pending') {
1245 + const rejectedThenable: RejectedThenable<mixed> = thenable as any;
1246 + rejectedThenable.status = 'rejected';
1247 + rejectedThenable.reason = error;
1248 + }
1249 + },
1250 + );
1251 }
1136 - const pendingThenable: PendingThenable<mixed> = thenable as any;
1137 - pendingThenable.status = 'pending';
1138 - pendingThenable.then(
1139 - fulfilledValue => {
1140 - if (thenable.status === 'pending') {
1141 - const fulfilledThenable: FulfilledThenable<mixed> = thenable as any;
1142 - fulfilledThenable.status = 'fulfilled';
1143 - fulfilledThenable.value = fulfilledValue;
1144 - }
1252 + thenable.then(
1253 + value => {
1254 + forwardDebugInfoFromCurrentContext(request, newTask, thenable);
1255 + newTask.model = value;
1256 + pingTask(request, newTask);
1257 },
1146 - (error: mixed) => {
1147 - if (thenable.status === 'pending') {
1148 - const rejectedThenable: RejectedThenable<mixed> = thenable as any;
1149 - rejectedThenable.status = 'rejected';
1150 - rejectedThenable.reason = error;
1258 + reason => {
1259 + if (newTask.status === PENDING) {
1260 + if (
1261 + enableProfilerTimer &&
1262 + (enableComponentPerformanceTrack || enableAsyncDebugInfo)
1263 + ) {
1264 + // If this is async we need to time when this task finishes.
1265 + newTask.timed = true;
1266 + }
1267 + // We expect that the only status it might be otherwise is ABORTED.
1268 + // When we abort we emit chunks in each pending task slot and don't need
1269 + // to do so again here.
1270 + erroredTask(request, newTask, reason);
1271 + enqueueFlush(request);
1272 }
1273 },
1274 );
1154 - break;
1275 + return newTask.id;
1276 }
1277 }
1157 -
1158 - thenable.then(
1159 - value => {
1160 - forwardDebugInfoFromCurrentContext(request, newTask, thenable);
1161 - newTask.model = value;
1162 - pingTask(request, newTask);
1163 - },
1164 - reason => {
1165 - if (newTask.status === PENDING) {
1166 - if (
1167 - enableProfilerTimer &&
1168 - (enableComponentPerformanceTrack || enableAsyncDebugInfo)
1169 - ) {
1170 - // If this is async we need to time when this task finishes.
1171 - newTask.timed = true;
1172 - }
1173 - // We expect that the only status it might be otherwise is ABORTED.
1174 - // When we abort we emit chunks in each pending task slot and don't need
1175 - // to do so again here.
1176 - erroredTask(request, newTask, reason);
1177 - enqueueFlush(request);
1178 - }
1179 - },
1180 - );
1181 -
1182 - return newTask.id;
1278 }
1279
1280 function serializeReadableStream(
@@ -2760,9 +2855,36 @@ function createTask(
2855 debugOwner: null | ReactComponentInfo, // DEV-only
2856 debugStack: null | Error, // DEV-only
2857 debugTask: null | ConsoleTask, // DEV-only
2858 +): Task {
2859 + return createTaskWithID(
2860 + request,
2861 + request.nextChunkId++,
2862 + model,
2863 + keyPath,
2864 + implicitSlot,
2865 + formatContext,
2866 + abortSet,
2867 + lastTimestamp,
2868 + debugOwner,
2869 + debugStack,
2870 + debugTask,
2871 + );
2872 +}
2873 +
2874 +function createTaskWithID(
2875 + request: Request,
2876 + id: number,
2877 + model: ReactClientValue,
2878 + keyPath: ReactKey,
2879 + implicitSlot: boolean,
2880 + formatContext: FormatContext,
2881 + abortSet: Set<Task>,
2882 + lastTimestamp: number, // Profiling-only
2883 + debugOwner: null | ReactComponentInfo, // DEV-only
2884 + debugStack: null | Error, // DEV-only
2885 + debugTask: null | ConsoleTask, // DEV-only
2886 ): Task {
2887 request.pendingChunks++;
2765 - const id = request.nextChunkId++;
2888 if (typeof model === 'object' && model !== null) {
2889 // If we're about to write this into a new task we can assign it an ID early so that
2890 // any other references can refer to the value we're about to write.
@@ -2942,6 +3064,10 @@ function serializePromiseID(id: number): string {
3064 return '$@' + id.toString(16);
3065 }
3066
3067 +function serializeWeakPromiseID(id: number): string {
3068 + return '$w' + id.toString(16);
3069 +}
3070 +
3071 function serializeServerReferenceID(id: number): string {
3072 return '$h' + id.toString(16);
3073 }
@@ -3828,13 +3954,20 @@ function renderModelDestructive(
3954 const existingReference = writtenObjects.get(value);
3955 // $FlowFixMe[method-unbinding]
3956 if (typeof value.then === 'function') {
3957 + // A weak-pending thenable may never emit, so its reference is marked
3958 + // on the wire ($w instead of $@). That way the client knows to leave
3959 + // it forever pending, instead of erroring it, if the stream closes
3960 + // first.
3961 if (existingReference !== undefined) {
3962 if (task.keyPath !== null || task.implicitSlot) {
3963 // If we're in some kind of context we can't reuse the result of this render or
3964 // previous renders of this element. We only reuse Promises if they're not wrapped
3965 // by another Server Component.
3966 const promiseId = serializeThenable(request, task, value as any);
3837 - return serializePromiseID(promiseId);
3967 + return enableFlightWeakThenables &&
3968 + (value as any).status === 'pending_weak'
3969 + ? serializeWeakPromiseID(promiseId)
3970 + : serializePromiseID(promiseId);
3971 } else if (modelRoot === value) {
3972 // This is the ID we're currently emitting so we need to write it
3973 // once but if we discover it again, we refer to it by id.
@@ -3847,7 +3980,10 @@ function renderModelDestructive(
3980 // We assume that any object with a .then property is a "Thenable" type,
3981 // or a Promise type. Either of which can be represented by a Promise.
3982 const promiseId = serializeThenable(request, task, value as any);
3850 - const promiseReference = serializePromiseID(promiseId);
3983 + const promiseReference =
3984 + enableFlightWeakThenables && (value as any).status === 'pending_weak'
3985 + ? serializeWeakPromiseID(promiseId)
3986 + : serializePromiseID(promiseId);
3987 writtenObjects.set(value, promiseReference);
3988 return promiseReference;
3989 }
@@ -6157,6 +6293,12 @@ function finishAbortedTask(
6293 request.completedErrorChunks.push(processedChunk);
6294 }
6295
6296 +// "Halting" a task means finishing it without emitting anything into its
6297 +// slot: the reference is intentionally left unfulfilled and never resolves
6298 +// on the client. This is how an aborted prerender leaves its pending work.
6299 +// It's also the same outcome as a weak-pending thenable that never settles
6300 +// (see serializeThenable) — halting is initiated by the request aborting,
6301 +// weakness by the value itself.
6302 function haltTask(task: Task, request: Request): void {
6303 if (task.status !== PENDING) {
6304 // If this is already completed/errored we don't abort it.
packages/shared/ReactFeatureFlags.js
+6
@@ -79,6 +79,12 @@ export const enableLegacyCache = __EXPERIMENTAL__;
79
80 export const enableAsyncIterableChildren = __EXPERIMENTAL__;
81
82 +// Support thenables with status 'pending_weak' in Flight. A weak-pending
83 +// thenable doesn't keep the stream open; if it resolves before the stream
84 +// closes for other reasons, its value is emitted, otherwise its reference is
85 +// left unfulfilled.
86 +export const enableFlightWeakThenables = __EXPERIMENTAL__;
87 +
88 export const enableTaint = __EXPERIMENTAL__;
89
90 export const enableViewTransition: boolean = true;
packages/shared/ReactTypes.js
+6
@@ -126,6 +126,11 @@ export interface PendingThenable<T> extends ThenableImpl<T> {
126 _debugInfo?: null | ReactDebugInfo;
127 }
128
129 +export interface WeakPendingThenable<T> extends ThenableImpl<T> {
130 + status: 'pending_weak';
131 + _debugInfo?: null | ReactDebugInfo;
132 +}
133 +
134 export interface FulfilledThenable<T> extends ThenableImpl<T> {
135 status: 'fulfilled';
136 value: T;
@@ -141,6 +146,7 @@ export interface RejectedThenable<T> extends ThenableImpl<T> {
146 export type Thenable<T> =
147 | UntrackedThenable<T>
148 | PendingThenable<T>
149 + | WeakPendingThenable<T>
150 | FulfilledThenable<T>
151 | RejectedThenable<T>;
152
packages/shared/forks/ReactFeatureFlags.native-fb.js
+1
@@ -41,6 +41,7 @@ export const disableSchedulerTimeoutInWorkLoop: boolean = false;
41 export const disableTextareaChildren: boolean = false;
42 export const enableAsyncDebugInfo: boolean = true;
43 export const enableAsyncIterableChildren: boolean = false;
44 +export const enableFlightWeakThenables: boolean = false;
45 export const enableCPUSuspense: boolean = true;
46 export const enableCreateEventHandleAPI: boolean = false;
47 export const enableBrowserAPI: boolean = true;
packages/shared/forks/ReactFeatureFlags.native-oss.js
+1
@@ -26,6 +26,7 @@ export const disableSchedulerTimeoutInWorkLoop: boolean = false;
26 export const disableTextareaChildren: boolean = false;
27 export const enableAsyncDebugInfo: boolean = true;
28 export const enableAsyncIterableChildren: boolean = false;
29 +export const enableFlightWeakThenables: boolean = false;
30 export const enableCPUSuspense: boolean = false;
31 export const enableCreateEventHandleAPI: boolean = false;
32 export const enableBrowserAPI: boolean = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.js
+1
@@ -20,6 +20,7 @@ export const enablePerformanceIssueReporting: boolean = false;
20 export const enableUpdaterTracking: boolean = false;
21 export const enableLegacyCache: boolean = __EXPERIMENTAL__;
22 export const enableAsyncIterableChildren: boolean = false;
23 +export const enableFlightWeakThenables: boolean = false;
24 export const enableTaint: boolean = true;
25 export const disableCommentsAsDOMContainers: boolean = true;
26 export const disableInputAttributeSyncing: boolean = false;
packages/shared/forks/ReactFeatureFlags.test-renderer.native-fb.js
+1
@@ -21,6 +21,7 @@ export const disableSchedulerTimeoutInWorkLoop = false;
21 export const disableTextareaChildren = false;
22 export const enableAsyncDebugInfo = true;
23 export const enableAsyncIterableChildren = false;
24 +export const enableFlightWeakThenables = false;
25 export const enableCPUSuspense = true;
26 export const enableCreateEventHandleAPI = false;
27 export const enableBrowserAPI = true;
packages/shared/forks/ReactFeatureFlags.test-renderer.www.js
+1
@@ -20,6 +20,7 @@ export const enablePerformanceIssueReporting: boolean = false;
20 export const enableUpdaterTracking: boolean = false;
21 export const enableLegacyCache: boolean = true;
22 export const enableAsyncIterableChildren: boolean = false;
23 +export const enableFlightWeakThenables: boolean = false;
24 export const enableTaint: boolean = true;
25 export const disableCommentsAsDOMContainers: boolean = true;
26 export const disableInputAttributeSyncing: boolean = false;
packages/shared/forks/ReactFeatureFlags.www.js
+1
@@ -72,6 +72,7 @@ export const disableLegacyContext = __EXPERIMENTAL__;
72 export const enableLegacyCache: boolean = true;
73
74 export const enableAsyncIterableChildren: boolean = false;
75 +export const enableFlightWeakThenables: boolean = false;
76
77 export const enableTaint: boolean = false;
78