@samitouri / QOS-React-1 / commits / 894bc73cb4

[Flight] Patch Promise cycles and toString on Server Functions (#35345)

Server Functions can be stringified (sometimes implicitly) when passed as data. This adds an override to hide the source code in that case - just in case someone puts sensitive information in there. Note that this still preserves the `name` field but this is also available on the export but in practice is likely minified anyway. There's nothing else on these referenes we'd consider unsafe unless you explicitly expose expandos which are part of the `"use server"` export. This adds a safety check to ensure you don't encode cyclic Promises. This isn't a parser bug per se. Promises do have a safety mechanism that avoids them infinite looping. However, since we use custom Thenables, what can happen is that every time a native Promise awaits it, another Promise wrapper is created around the Thenable which foils the ECMAScript Promise cycle detection which can lead to an infinite loop. This also ensures that embedded `ReadableStream` and `AsyncIterable` streams are properly closed if the source stream closes early both on the Server and Client. This doesn't cause an infinite loop but just to make sure resource clean up can proceed properly. We're also adding some more explicit clear errors for invalid payloads since we no longer need to obfuscate the original issue.

Sebastian Markbåge committed Dec 11, 2025 at 15:24 UTC 894bc73cb493487c48d57f4508e6278db58e673a
14 files changed +441 -240
packages/react-client/src/ReactFlightClient.js
+148 -123
@@ -894,6 +894,7 @@ function resolveModuleChunk<T>(
894 const resolvedChunk: ResolvedModuleChunk<T> = (chunk: any);
895 resolvedChunk.status = RESOLVED_MODULE;
896 resolvedChunk.value = value;
897 + resolvedChunk.reason = null;
898 if (__DEV__) {
899 const debugInfo = getModuleDebugInfo(value);
900 if (debugInfo !== null) {
@@ -1114,6 +1115,8 @@ export function reportGlobalError(
1115 // because we won't be getting any new data to resolve it.
1116 if (chunk.status === PENDING) {
1117 triggerErrorOnChunk(response, chunk, error);
1118 + } else if (chunk.status === INITIALIZED && chunk.reason !== null) {
1119 + chunk.reason.error(error);
1120 }
1121 });
1122 if (__DEV__) {
@@ -1462,15 +1465,95 @@ function fulfillReference(
1465 ): void {
1466 const {handler, parentObject, key, map, path} = reference;
1467
1465 - for (let i = 1; i < path.length; i++) {
1468 + try {
1469 + for (let i = 1; i < path.length; i++) {
1470 + while (
1471 + typeof value === 'object' &&
1472 + value !== null &&
1473 + value.$$typeof === REACT_LAZY_TYPE
1474 + ) {
1475 + // We never expect to see a Lazy node on this path because we encode those as
1476 + // separate models. This must mean that we have inserted an extra lazy node
1477 + // e.g. to replace a blocked element. We must instead look for it inside.
1478 + const referencedChunk: SomeChunk<any> = value._payload;
1479 + if (referencedChunk === handler.chunk) {
1480 + // This is a reference to the thing we're currently blocking. We can peak
1481 + // inside of it to get the value.
1482 + value = handler.value;
1483 + continue;
1484 + } else {
1485 + switch (referencedChunk.status) {
1486 + case RESOLVED_MODEL:
1487 + initializeModelChunk(referencedChunk);
1488 + break;
1489 + case RESOLVED_MODULE:
1490 + initializeModuleChunk(referencedChunk);
1491 + break;
1492 + }
1493 + switch (referencedChunk.status) {
1494 + case INITIALIZED: {
1495 + value = referencedChunk.value;
1496 + continue;
1497 + }
1498 + case BLOCKED: {
1499 + // It is possible that we're blocked on our own chunk if it's a cycle.
1500 + // Before adding the listener to the inner chunk, let's check if it would
1501 + // result in a cycle.
1502 + const cyclicHandler = resolveBlockedCycle(
1503 + referencedChunk,
1504 + reference,
1505 + );
1506 + if (cyclicHandler !== null) {
1507 + // This reference points back to this chunk. We can resolve the cycle by
1508 + // using the value from that handler.
1509 + value = cyclicHandler.value;
1510 + continue;
1511 + }
1512 + // Fallthrough
1513 + }
1514 + case PENDING: {
1515 + // If we're not yet initialized we need to skip what we've already drilled
1516 + // through and then wait for the next value to become available.
1517 + path.splice(0, i - 1);
1518 + // Add "listener" to our new chunk dependency.
1519 + if (referencedChunk.value === null) {
1520 + referencedChunk.value = [reference];
1521 + } else {
1522 + referencedChunk.value.push(reference);
1523 + }
1524 + if (referencedChunk.reason === null) {
1525 + referencedChunk.reason = [reference];
1526 + } else {
1527 + referencedChunk.reason.push(reference);
1528 + }
1529 + return;
1530 + }
1531 + case HALTED: {
1532 + // Do nothing. We couldn't fulfill.
1533 + // TODO: Mark downstreams as halted too.
1534 + return;
1535 + }
1536 + default: {
1537 + rejectReference(
1538 + response,
1539 + reference.handler,
1540 + referencedChunk.reason,
1541 + );
1542 + return;
1543 + }
1544 + }
1545 + }
1546 + }
1547 + value = value[path[i]];
1548 + }
1549 +
1550 while (
1551 typeof value === 'object' &&
1552 value !== null &&
1553 value.$$typeof === REACT_LAZY_TYPE
1554 ) {
1471 - // We never expect to see a Lazy node on this path because we encode those as
1472 - // separate models. This must mean that we have inserted an extra lazy node
1473 - // e.g. to replace a blocked element. We must instead look for it inside.
1555 + // If what we're referencing is a Lazy it must be because we inserted one as a virtual node
1556 + // while it was blocked by other data. If it's no longer blocked, we can unwrap it.
1557 const referencedChunk: SomeChunk<any> = value._payload;
1558 if (referencedChunk === handler.chunk) {
1559 // This is a reference to the thing we're currently blocking. We can peak
@@ -1491,132 +1574,57 @@ function fulfillReference(
1574 value = referencedChunk.value;
1575 continue;
1576 }
1494 - case BLOCKED: {
1495 - // It is possible that we're blocked on our own chunk if it's a cycle.
1496 - // Before adding the listener to the inner chunk, let's check if it would
1497 - // result in a cycle.
1498 - const cyclicHandler = resolveBlockedCycle(
1499 - referencedChunk,
1500 - reference,
1501 - );
1502 - if (cyclicHandler !== null) {
1503 - // This reference points back to this chunk. We can resolve the cycle by
1504 - // using the value from that handler.
1505 - value = cyclicHandler.value;
1506 - continue;
1507 - }
1508 - // Fallthrough
1509 - }
1510 - case PENDING: {
1511 - // If we're not yet initialized we need to skip what we've already drilled
1512 - // through and then wait for the next value to become available.
1513 - path.splice(0, i - 1);
1514 - // Add "listener" to our new chunk dependency.
1515 - if (referencedChunk.value === null) {
1516 - referencedChunk.value = [reference];
1517 - } else {
1518 - referencedChunk.value.push(reference);
1519 - }
1520 - if (referencedChunk.reason === null) {
1521 - referencedChunk.reason = [reference];
1522 - } else {
1523 - referencedChunk.reason.push(reference);
1524 - }
1525 - return;
1526 - }
1527 - case HALTED: {
1528 - // Do nothing. We couldn't fulfill.
1529 - // TODO: Mark downstreams as halted too.
1530 - return;
1531 - }
1532 - default: {
1533 - rejectReference(
1534 - response,
1535 - reference.handler,
1536 - referencedChunk.reason,
1537 - );
1538 - return;
1539 - }
1577 }
1578 }
1579 + break;
1580 }
1543 - value = value[path[i]];
1544 - }
1581
1546 - while (
1547 - typeof value === 'object' &&
1548 - value !== null &&
1549 - value.$$typeof === REACT_LAZY_TYPE
1550 - ) {
1551 - // If what we're referencing is a Lazy it must be because we inserted one as a virtual node
1552 - // while it was blocked by other data. If it's no longer blocked, we can unwrap it.
1553 - const referencedChunk: SomeChunk<any> = value._payload;
1554 - if (referencedChunk === handler.chunk) {
1555 - // This is a reference to the thing we're currently blocking. We can peak
1556 - // inside of it to get the value.
1557 - value = handler.value;
1558 - continue;
1559 - } else {
1560 - switch (referencedChunk.status) {
1561 - case RESOLVED_MODEL:
1562 - initializeModelChunk(referencedChunk);
1582 + const mappedValue = map(response, value, parentObject, key);
1583 + parentObject[key] = mappedValue;
1584 +
1585 + // If this is the root object for a model reference, where `handler.value`
1586 + // is a stale `null`, the resolved value can be used directly.
1587 + if (key === '' && handler.value === null) {
1588 + handler.value = mappedValue;
1589 + }
1590 +
1591 + // If the parent object is an unparsed React element tuple, we also need to
1592 + // update the props and owner of the parsed element object (i.e.
1593 + // handler.value).
1594 + if (
1595 + parentObject[0] === REACT_ELEMENT_TYPE &&
1596 + typeof handler.value === 'object' &&
1597 + handler.value !== null &&
1598 + handler.value.$$typeof === REACT_ELEMENT_TYPE
1599 + ) {
1600 + const element: any = handler.value;
1601 + switch (key) {
1602 + case '3':
1603 + transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1604 + element.props = mappedValue;
1605 + break;
1606 + case '4':
1607 + // This path doesn't call transferReferencedDebugInfo because this reference is to a debug chunk.
1608 + if (__DEV__) {
1609 + element._owner = mappedValue;
1610 + }
1611 break;
1564 - case RESOLVED_MODULE:
1565 - initializeModuleChunk(referencedChunk);
1612 + case '5':
1613 + // This path doesn't call transferReferencedDebugInfo because this reference is to a debug chunk.
1614 + if (__DEV__) {
1615 + element._debugStack = mappedValue;
1616 + }
1617 + break;
1618 + default:
1619 + transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1620 break;
1621 }
1568 - switch (referencedChunk.status) {
1569 - case INITIALIZED: {
1570 - value = referencedChunk.value;
1571 - continue;
1572 - }
1573 - }
1574 - }
1575 - break;
1576 - }
1577 -
1578 - const mappedValue = map(response, value, parentObject, key);
1579 - parentObject[key] = mappedValue;
1580 -
1581 - // If this is the root object for a model reference, where `handler.value`
1582 - // is a stale `null`, the resolved value can be used directly.
1583 - if (key === '' && handler.value === null) {
1584 - handler.value = mappedValue;
1585 - }
1586 -
1587 - // If the parent object is an unparsed React element tuple, we also need to
1588 - // update the props and owner of the parsed element object (i.e.
1589 - // handler.value).
1590 - if (
1591 - parentObject[0] === REACT_ELEMENT_TYPE &&
1592 - typeof handler.value === 'object' &&
1593 - handler.value !== null &&
1594 - handler.value.$$typeof === REACT_ELEMENT_TYPE
1595 - ) {
1596 - const element: any = handler.value;
1597 - switch (key) {
1598 - case '3':
1599 - transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1600 - element.props = mappedValue;
1601 - break;
1602 - case '4':
1603 - // This path doesn't call transferReferencedDebugInfo because this reference is to a debug chunk.
1604 - if (__DEV__) {
1605 - element._owner = mappedValue;
1606 - }
1607 - break;
1608 - case '5':
1609 - // This path doesn't call transferReferencedDebugInfo because this reference is to a debug chunk.
1610 - if (__DEV__) {
1611 - element._debugStack = mappedValue;
1612 - }
1613 - break;
1614 - default:
1615 - transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1616 - break;
1622 + } else if (__DEV__ && !reference.isDebug) {
1623 + transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1624 }
1618 - } else if (__DEV__ && !reference.isDebug) {
1619 - transferReferencedDebugInfo(handler.chunk, fulfilledChunk);
1625 + } catch (error) {
1626 + rejectReference(response, reference.handler, error);
1627 + return;
1628 }
1629
1630 handler.deps--;
@@ -1882,6 +1890,7 @@ function loadServerReference<A: Iterable<any>, T>(
1890 const initializedChunk: InitializedChunk<T> = (chunk: any);
1891 initializedChunk.status = INITIALIZED;
1892 initializedChunk.value = handler.value;
1893 + initializedChunk.reason = null;
1894 if (resolveListeners !== null) {
1895 wakeChunk(response, resolveListeners, handler.value, initializedChunk);
1896 } else {
@@ -2359,7 +2368,7 @@ function parseModelString(
2368 // Symbol
2369 return Symbol.for(value.slice(2));
2370 }
2362 - case 'F': {
2371 + case 'h': {
2372 // Server Reference
2373 const ref = value.slice(2);
2374 return getOutlinedModel(
@@ -3138,6 +3147,7 @@ function startReadableStream<T>(
3147 streamState: StreamState,
3148 ): void {
3149 let controller: ReadableStreamController = (null: any);
3150 + let closed = false;
3151 const stream = new ReadableStream({
3152 type: type,
3153 start(c) {
@@ -3195,6 +3205,10 @@ function startReadableStream<T>(
3205 }
3206 },
3207 close(json: UninitializedModel): void {
3208 + if (closed) {
3209 + return;
3210 + }
3211 + closed = true;
3212 if (previousBlockedChunk === null) {
3213 controller.close();
3214 } else {
@@ -3205,6 +3219,10 @@ function startReadableStream<T>(
3219 }
3220 },
3221 error(error: mixed): void {
3222 + if (closed) {
3223 + return;
3224 + }
3225 + closed = true;
3226 if (previousBlockedChunk === null) {
3227 // $FlowFixMe[incompatible-call]
3228 controller.error(error);
@@ -3265,6 +3283,7 @@ function startAsyncIterable<T>(
3283 (chunk: any);
3284 initializedChunk.status = INITIALIZED;
3285 initializedChunk.value = {done: false, value: value};
3286 + initializedChunk.reason = null;
3287 if (resolveListeners !== null) {
3288 wakeChunkIfInitialized(
3289 response,
@@ -3294,6 +3313,9 @@ function startAsyncIterable<T>(
3313 nextWriteIndex++;
3314 },
3315 close(value: UninitializedModel): void {
3316 + if (closed) {
3317 + return;
3318 + }
3319 closed = true;
3320 if (nextWriteIndex === buffer.length) {
3321 buffer[nextWriteIndex] = createResolvedIteratorResultChunk(
@@ -3321,6 +3343,9 @@ function startAsyncIterable<T>(
3343 }
3344 },
3345 error(error: Error): void {
3346 + if (closed) {
3347 + return;
3348 + }
3349 closed = true;
3350 if (nextWriteIndex === buffer.length) {
3351 buffer[nextWriteIndex] =
packages/react-client/src/ReactFlightReplyClient.js
+25 -5
@@ -104,7 +104,7 @@ function serializePromiseID(id: number): string {
104 }
105
106 function serializeServerReferenceID(id: number): string {
107 - return '$F' + id.toString(16);
107 + return '$h' + id.toString(16);
108 }
109
110 function serializeTemporaryReferenceMarker(): string {
@@ -112,7 +112,6 @@ function serializeTemporaryReferenceMarker(): string {
112 }
113
114 function serializeFormDataReference(id: number): string {
115 - // Why K? F is "Function". D is "Date". What else?
115 return '$K' + id.toString(16);
116 }
117
@@ -474,8 +473,22 @@ export function processReply(
473 }
474 }
475
476 + const existingReference = writtenObjects.get(value);
477 +
478 // $FlowFixMe[method-unbinding]
479 if (typeof value.then === 'function') {
480 + if (existingReference !== undefined) {
481 + if (modelRoot === value) {
482 + // This is the ID we're currently emitting so we need to write it
483 + // once but if we discover it again, we refer to it by id.
484 + modelRoot = null;
485 + } else {
486 + // We've already emitted this as an outlined object, so we can
487 + // just refer to that by its existing ID.
488 + return existingReference;
489 + }
490 + }
491 +
492 // We assume that any object with a .then property is a "Thenable" type,
493 // or a Promise type. Either of which can be represented by a Promise.
494 if (formData === null) {
@@ -484,11 +497,19 @@ export function processReply(
497 }
498 pendingParts++;
499 const promiseId = nextPartId++;
500 + const promiseReference = serializePromiseID(promiseId);
501 + writtenObjects.set(value, promiseReference);
502 const thenable: Thenable<any> = (value: any);
503 thenable.then(
504 partValue => {
505 try {
491 - const partJSON = serializeModel(partValue, promiseId);
506 + const previousReference = writtenObjects.get(partValue);
507 + let partJSON;
508 + if (previousReference !== undefined) {
509 + partJSON = JSON.stringify(previousReference);
510 + } else {
511 + partJSON = serializeModel(partValue, promiseId);
512 + }
513 // $FlowFixMe[incompatible-type] We know it's not null because we assigned it above.
514 const data: FormData = formData;
515 data.append(formFieldPrefix + promiseId, partJSON);
@@ -504,10 +525,9 @@ export function processReply(
525 // that throws on the server instead.
526 reject,
527 );
507 - return serializePromiseID(promiseId);
528 + return promiseReference;
529 }
530
510 - const existingReference = writtenObjects.get(value);
531 if (existingReference !== undefined) {
532 if (modelRoot === value) {
533 // This is the ID we're currently emitting so we need to write it
packages/react-server-dom-esm/src/ReactFlightESMReferences.js
+8
@@ -88,6 +88,12 @@ function bind(this: ServerReference<any>): any {
88 return newFn;
89 }
90
91 +const serverReferenceToString = {
92 + value: () => 'function () { [omitted code] }',
93 + configurable: true,
94 + writable: true,
95 +};
96 +
97 export function registerServerReference<T: Function>(
98 reference: T,
99 id: string,
@@ -111,12 +117,14 @@ export function registerServerReference<T: Function>(
117 configurable: true,
118 },
119 bind: {value: bind, configurable: true},
120 + toString: serverReferenceToString,
121 }
122 : {
123 $$typeof,
124 $$id,
125 $$bound,
126 bind: {value: bind, configurable: true},
127 + toString: serverReferenceToString,
128 }) as PropertyDescriptorMap,
129 );
130 }
packages/react-server-dom-parcel/src/ReactFlightParcelReferences.js
+8
@@ -95,6 +95,12 @@ function bind(this: ServerReference<any>): any {
95 return newFn;
96 }
97
98 +const serverReferenceToString = {
99 + value: () => 'function () { [omitted code] }',
100 + configurable: true,
101 + writable: true,
102 +};
103 +
104 export function registerServerReference<T>(
105 reference: ServerReference<T>,
106 id: string,
@@ -118,12 +124,14 @@ export function registerServerReference<T>(
124 configurable: true,
125 },
126 bind: {value: bind, configurable: true},
127 + toString: serverReferenceToString,
128 }
129 : {
130 $$typeof,
131 $$id,
132 $$bound,
133 bind: {value: bind, configurable: true},
134 + toString: serverReferenceToString,
135 }) as PropertyDescriptorMap,
136 );
137 }
packages/react-server-dom-turbopack/src/ReactFlightTurbopackReferences.js
+8
@@ -102,6 +102,12 @@ function bind(this: ServerReference<any>): any {
102 return newFn;
103 }
104
105 +const serverReferenceToString = {
106 + value: () => 'function () { [omitted code] }',
107 + configurable: true,
108 + writable: true,
109 +};
110 +
111 export function registerServerReference<T: Function>(
112 reference: T,
113 id: string,
@@ -125,12 +131,14 @@ export function registerServerReference<T: Function>(
131 configurable: true,
132 },
133 bind: {value: bind, configurable: true},
134 + toString: serverReferenceToString,
135 }
136 : {
137 $$typeof,
138 $$id,
139 $$bound,
140 bind: {value: bind, configurable: true},
141 + toString: serverReferenceToString,
142 }) as PropertyDescriptorMap,
143 );
144 }
packages/react-server-dom-unbundled/src/ReactFlightUnbundledReferences.js
+8
@@ -102,6 +102,12 @@ function bind(this: ServerReference<any>): any {
102 return newFn;
103 }
104
105 +const serverReferenceToString = {
106 + value: () => 'function () { [omitted code] }',
107 + configurable: true,
108 + writable: true,
109 +};
110 +
111 export function registerServerReference<T: Function>(
112 reference: T,
113 id: string,
@@ -125,12 +131,14 @@ export function registerServerReference<T: Function>(
131 configurable: true,
132 },
133 bind: {value: bind, configurable: true},
134 + toString: serverReferenceToString,
135 } as PropertyDescriptorMap)
136 : ({
137 $$typeof,
138 $$id,
139 $$bound,
140 bind: {value: bind, configurable: true},
141 + toString: serverReferenceToString,
142 } as PropertyDescriptorMap),
143 );
144 }
packages/react-server-dom-webpack/src/ReactFlightWebpackReferences.js
+8
@@ -102,6 +102,12 @@ function bind(this: ServerReference<any>): any {
102 return newFn;
103 }
104
105 +const serverReferenceToString = {
106 + value: () => 'function () { [omitted code] }',
107 + configurable: true,
108 + writable: true,
109 +};
110 +
111 export function registerServerReference<T: Function>(
112 reference: T,
113 id: string,
@@ -125,12 +131,14 @@ export function registerServerReference<T: Function>(
131 configurable: true,
132 },
133 bind: {value: bind, configurable: true},
134 + toString: serverReferenceToString,
135 } as PropertyDescriptorMap)
136 : ({
137 $$typeof,
138 $$id,
139 $$bound,
140 bind: {value: bind, configurable: true},
141 + toString: serverReferenceToString,
142 } as PropertyDescriptorMap),
143 );
144 }
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+18 -1
@@ -156,6 +156,23 @@ describe('ReactFlightDOM', () => {
156 };
157 }
158
159 + function createUnclosingStream(
160 + stream: ReadableStream<Uint8Array>,
161 + ): ReadableStream<Uint8Array> {
162 + const reader = stream.getReader();
163 +
164 + const s = new ReadableStream({
165 + async pull(controller) {
166 + const {done, value} = await reader.read();
167 + if (!done) {
168 + controller.enqueue(value);
169 + }
170 + },
171 + });
172 +
173 + return s;
174 + }
175 +
176 const theInfinitePromise = new Promise(() => {});
177 function InfiniteSuspend() {
178 throw theInfinitePromise;
@@ -2970,7 +2987,7 @@ describe('ReactFlightDOM', () => {
2987 const {prelude} = await pendingResult;
2988
2989 const result = await ReactServerDOMClient.createFromReadableStream(
2973 - Readable.toWeb(prelude),
2990 + createUnclosingStream(Readable.toWeb(prelude)),
2991 );
2992
2993 const iterator = result.multiShotIterable[Symbol.asyncIterator]();
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMEdge-test.js
+31 -1
@@ -228,7 +228,7 @@ describe('ReactFlightDOMEdge', () => {
228
229 async function createBufferedUnclosingStream(
230 stream: ReadableStream<Uint8Array>,
231 - ): ReadableStream<Uint8Array> {
231 + ): Promise<ReadableStream<Uint8Array>> {
232 const chunks: Array<Uint8Array> = [];
233 const reader = stream.getReader();
234 while (true) {
@@ -2309,4 +2309,34 @@ describe('ReactFlightDOMEdge', () => {
2309 const result = await response;
2310 expect(result).toEqual({obj: obj, node: 'hi'});
2311 });
2312 +
2313 + it('does not leak the server reference code', async () => {
2314 + function foo() {
2315 + return 'foo';
2316 + }
2317 +
2318 + const bar = () => {
2319 + return 'bar';
2320 + };
2321 +
2322 + const anonymous = (
2323 + () => () =>
2324 + 'anonymous'
2325 + )();
2326 +
2327 + expect(
2328 + ReactServerDOMServer.registerServerReference(foo, 'foo-id').toString(),
2329 + ).toBe('function () { [omitted code] }');
2330 +
2331 + expect(
2332 + ReactServerDOMServer.registerServerReference(bar, 'bar-id').toString(),
2333 + ).toBe('function () { [omitted code] }');
2334 +
2335 + expect(
2336 + ReactServerDOMServer.registerServerReference(
2337 + anonymous,
2338 + 'anonymous-id',
2339 + ).toString(),
2340 + ).toBe('function () { [omitted code] }');
2341 + });
2342 });
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMNode-test.js
+36 -14
@@ -140,7 +140,7 @@ describe('ReactFlightDOMNode', () => {
140
141 async function createBufferedUnclosingStream(
142 stream: ReadableStream<Uint8Array>,
143 - ): ReadableStream<Uint8Array> {
143 + ): Promise<ReadableStream<Uint8Array>> {
144 const chunks: Array<Uint8Array> = [];
145 const reader = stream.getReader();
146 while (true) {
@@ -407,7 +407,7 @@ describe('ReactFlightDOMNode', () => {
407 );
408 });
409
410 - it('should cancels the underlying ReadableStream when we are cancelled', async () => {
410 + it('should cancel the underlying and transported ReadableStreams when we are cancelled', async () => {
411 let controller;
412 let cancelReason;
413 const s = new ReadableStream({
@@ -431,16 +431,30 @@ describe('ReactFlightDOMNode', () => {
431 ),
432 );
433
434 - const writable = new Stream.PassThrough(streamOptions);
435 - rscStream.pipe(writable);
434 + const readable = new Stream.PassThrough(streamOptions);
435 + rscStream.pipe(readable);
436 +
437 + const result = await ReactServerDOMClient.createFromNodeStream(readable, {
438 + moduleMap: {},
439 + moduleLoading: webpackModuleLoading,
440 + });
441 + const reader = result.getReader();
442
443 controller.enqueue('hi');
444
445 + await serverAct(async () => {
446 + // We should be able to read the part we already emitted before the abort
447 + expect(await reader.read()).toEqual({
448 + value: 'hi',
449 + done: false,
450 + });
451 + });
452 +
453 const reason = new Error('aborted');
440 - writable.destroy(reason);
454 + readable.destroy(reason);
455
456 await new Promise(resolve => {
443 - writable.on('error', () => {
457 + readable.on('error', () => {
458 resolve();
459 });
460 });
@@ -448,9 +462,17 @@ describe('ReactFlightDOMNode', () => {
462 expect(cancelReason.message).toBe(
463 'The destination stream errored while writing data.',
464 );
465 +
466 + let error = null;
467 + try {
468 + await reader.read();
469 + } catch (x) {
470 + error = x;
471 + }
472 + expect(error).toBe(reason);
473 });
474
453 - it('should cancels the underlying ReadableStream when we abort', async () => {
475 + it('should cancel the underlying and transported ReadableStreams when we abort', async () => {
476 const errors = [];
477 let controller;
478 let cancelReason;
@@ -1342,12 +1364,12 @@ describe('ReactFlightDOMNode', () => {
1364 '\n' +
1365 ' in Dynamic' +
1366 (gate(flags => flags.enableAsyncDebugInfo)
1345 - ? ' (file://ReactFlightDOMNode-test.js:1216:27)\n'
1367 + ? ' (file://ReactFlightDOMNode-test.js:1238:27)\n'
1368 : '\n') +
1369 ' in body\n' +
1370 ' in html\n' +
1349 - ' in App (file://ReactFlightDOMNode-test.js:1229:25)\n' +
1350 - ' in ClientRoot (ReactFlightDOMNode-test.js:1304:16)',
1371 + ' in App (file://ReactFlightDOMNode-test.js:1251:25)\n' +
1372 + ' in ClientRoot (ReactFlightDOMNode-test.js:1326:16)',
1373 );
1374 } else {
1375 expect(
@@ -1356,7 +1378,7 @@ describe('ReactFlightDOMNode', () => {
1378 '\n' +
1379 ' in body\n' +
1380 ' in html\n' +
1359 - ' in ClientRoot (ReactFlightDOMNode-test.js:1304:16)',
1381 + ' in ClientRoot (ReactFlightDOMNode-test.js:1326:16)',
1382 );
1383 }
1384
@@ -1366,8 +1388,8 @@ describe('ReactFlightDOMNode', () => {
1388 normalizeCodeLocInfo(ownerStack, {preserveLocation: true}),
1389 ).toBe(
1390 '\n' +
1369 - ' in Dynamic (file://ReactFlightDOMNode-test.js:1216:27)\n' +
1370 - ' in App (file://ReactFlightDOMNode-test.js:1229:25)',
1391 + ' in Dynamic (file://ReactFlightDOMNode-test.js:1238:27)\n' +
1392 + ' in App (file://ReactFlightDOMNode-test.js:1251:25)',
1393 );
1394 } else {
1395 expect(
@@ -1375,7 +1397,7 @@ describe('ReactFlightDOMNode', () => {
1397 ).toBe(
1398 '' +
1399 '\n' +
1378 - ' in App (file://ReactFlightDOMNode-test.js:1229:25)',
1400 + ' in App (file://ReactFlightDOMNode-test.js:1251:25)',
1401 );
1402 }
1403 } else {
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOMReplyEdge-test.js
+48 -1
@@ -135,7 +135,7 @@ describe('ReactFlightDOMReplyEdge', () => {
135 expect(await resultBlob.arrayBuffer()).toEqual(await blob.arrayBuffer());
136 });
137
138 - it('should supports ReadableStreams with typed arrays', async () => {
138 + it('should support ReadableStreams with typed arrays', async () => {
139 const buffer = new Uint8Array([
140 123, 4, 10, 5, 100, 255, 244, 45, 56, 67, 43, 124, 67, 89, 100, 20,
141 ]).buffer;
@@ -239,6 +239,53 @@ describe('ReactFlightDOMReplyEdge', () => {
239 expect(streamedBuffers.flatMap(t => Array.from(t))).toEqual(expectedBytes);
240 });
241
242 + it('should cancel the transported ReadableStream when we are cancelled', async () => {
243 + const s = new ReadableStream({
244 + start(controller) {
245 + controller.enqueue('hi');
246 + controller.close();
247 + },
248 + });
249 +
250 + const body = await ReactServerDOMClient.encodeReply(s);
251 +
252 + const iterable = {
253 + async *[Symbol.asyncIterator]() {
254 + // eslint-disable-next-line no-for-of-loops/no-for-of-loops
255 + for (const entry of body) {
256 + if (entry[1] === 'C') {
257 + // Return before finishing the stream.
258 + return;
259 + }
260 + yield entry;
261 + }
262 + },
263 + };
264 +
265 + const result = await ReactServerDOMServer.decodeReplyFromAsyncIterable(
266 + iterable,
267 + webpackServerMap,
268 + );
269 +
270 + const reader = result.getReader();
271 +
272 + // We should be able to read the part we already emitted before the abort
273 + expect(await reader.read()).toEqual({
274 + value: 'hi',
275 + done: false,
276 + });
277 +
278 + let error = null;
279 + try {
280 + await reader.read();
281 + } catch (x) {
282 + error = x;
283 + }
284 +
285 + expect(error).not.toBe(null);
286 + expect(error.message).toBe('Connection closed.');
287 + });
288 +
289 it('should abort when parsing an incomplete payload', async () => {
290 const infinitePromise = new Promise(() => {});
291 const controller = new AbortController();
packages/react-server/src/ReactFlightReplyServer.js
+89 -93
@@ -33,6 +33,7 @@ import {
33 import {ASYNC_ITERATOR} from 'shared/ReactSymbols';
34
35 import hasOwnProperty from 'shared/hasOwnProperty';
36 +import getPrototypeOf from 'shared/getPrototypeOf';
37
38 interface FlightStreamController {
39 enqueueModel(json: string): void;
@@ -128,6 +129,24 @@ ReactPromise.prototype.then = function <T>(
129 switch (chunk.status) {
130 case INITIALIZED:
131 if (typeof resolve === 'function') {
132 + let inspectedValue = chunk.value;
133 + // Recursively check if the value is itself a ReactPromise and if so if it points
134 + // back to itself. This helps catch recursive thenables early error.
135 + while (inspectedValue instanceof ReactPromise) {
136 + if (inspectedValue === chunk) {
137 + if (typeof reject === 'function') {
138 + reject(new Error('Cannot have cyclic thenables.'));
139 + }
140 + return;
141 + }
142 + if (inspectedValue.status === INITIALIZED) {
143 + inspectedValue = inspectedValue.value;
144 + } else {
145 + // If this is lazily resolved, pending or blocked, it'll eventually become
146 + // initialized and break the loop. Rejected also breaks it.
147 + break;
148 + }
149 + }
150 resolve(chunk.value);
151 }
152 break;
@@ -156,6 +175,9 @@ ReactPromise.prototype.then = function <T>(
175 }
176 };
177
178 +const ObjectPrototype = Object.prototype;
179 +const ArrayPrototype = Array.prototype;
180 +
181 export type Response = {
182 _bundlerConfig: ServerManifest,
183 _prefix: string,
@@ -506,6 +528,7 @@ function loadServerReference<A: Iterable<any>, T>(
528 const initializedChunk: InitializedChunk<T> = (chunk: any);
529 initializedChunk.status = INITIALIZED;
530 initializedChunk.value = handler.value;
531 + initializedChunk.reason = null;
532 if (resolveListeners !== null) {
533 wakeChunk(response, resolveListeners, handler.value);
534 }
@@ -674,6 +697,7 @@ function initializeModelChunk<T>(chunk: ResolvedModelChunk<T>): void {
697 const initializedChunk: InitializedChunk<T> = (chunk: any);
698 initializedChunk.status = INITIALIZED;
699 initializedChunk.value = value;
700 + initializedChunk.reason = null;
701 } catch (error) {
702 const erroredChunk: ErroredChunk<T> = (chunk: any);
703 erroredChunk.status = ERRORED;
@@ -694,6 +718,8 @@ export function reportGlobalError(response: Response, error: Error): void {
718 // because we won't be getting any new data to resolve it.
719 if (chunk.status === PENDING) {
720 triggerErrorOnChunk(response, chunk, error);
721 + } else if (chunk.status === INITIALIZED && chunk.reason !== null) {
722 + chunk.reason.error(error);
723 }
724 });
725 }
@@ -728,57 +754,34 @@ function fulfillReference(
754 ): void {
755 const {handler, parentObject, key, map, path} = reference;
756
731 - for (let i = 1; i < path.length; i++) {
732 - // The server doesn't have any lazy references but we unwrap Chunks here in the same way as the client.
733 - while (value instanceof ReactPromise) {
734 - const referencedChunk: SomeChunk<any> = value;
735 - switch (referencedChunk.status) {
736 - case RESOLVED_MODEL:
737 - initializeModelChunk(referencedChunk);
738 - break;
739 - }
740 - switch (referencedChunk.status) {
741 - case INITIALIZED: {
742 - value = referencedChunk.value;
743 - continue;
744 - }
745 - case BLOCKED:
746 - case PENDING: {
747 - // If we're not yet initialized we need to skip what we've already drilled
748 - // through and then wait for the next value to become available.
749 - path.splice(0, i - 1);
750 - // Add "listener" to our new chunk dependency.
751 - if (referencedChunk.value === null) {
752 - referencedChunk.value = [reference];
753 - } else {
754 - referencedChunk.value.push(reference);
755 - }
756 - if (referencedChunk.reason === null) {
757 - referencedChunk.reason = [reference];
758 - } else {
759 - referencedChunk.reason.push(reference);
760 - }
761 - return;
762 - }
763 - default: {
764 - rejectReference(response, reference.handler, referencedChunk.reason);
765 - return;
766 - }
757 + try {
758 + for (let i = 1; i < path.length; i++) {
759 + // The server doesn't have any lazy references so we don't expect to go through a Promise.
760 + const name = path[i];
761 + if (
762 + typeof value === 'object' &&
763 + value !== null &&
764 + (getPrototypeOf(value) === ObjectPrototype ||
765 + getPrototypeOf(value) === ArrayPrototype) &&
766 + hasOwnProperty.call(value, name)
767 + ) {
768 + value = value[name];
769 + } else {
770 + throw new Error('Invalid reference.');
771 }
772 }
769 - const name = path[i];
770 - if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
771 - value = value[name];
772 - }
773 - }
773
775 - const mappedValue = map(response, value, parentObject, key);
776 - parentObject[key] = mappedValue;
774 + const mappedValue = map(response, value, parentObject, key);
775 + parentObject[key] = mappedValue;
776
778 - // If this is the root object for a model reference, where `handler.value`
779 - // is a stale `null`, the resolved value can be used directly.
780 - if (key === '' && handler.value === null) {
781 - handler.value = mappedValue;
777 + // If this is the root object for a model reference, where `handler.value`
778 + // is a stale `null`, the resolved value can be used directly.
779 + if (key === '' && handler.value === null) {
780 + handler.value = mappedValue;
781 + }
782 + } catch (error) {
783 + rejectReference(response, reference.handler, error);
784 + return;
785 }
786
787 // There are no Elements or Debug Info to transfer here.
@@ -889,53 +892,17 @@ function getOutlinedModel<T>(
892 case INITIALIZED:
893 let value = chunk.value;
894 for (let i = 1; i < path.length; i++) {
892 - // The server doesn't have any lazy references but we unwrap Chunks here in the same way as the client.
893 - while (value instanceof ReactPromise) {
894 - const referencedChunk: SomeChunk<any> = value;
895 - switch (referencedChunk.status) {
896 - case RESOLVED_MODEL:
897 - initializeModelChunk(referencedChunk);
898 - break;
899 - }
900 - switch (referencedChunk.status) {
901 - case INITIALIZED: {
902 - value = referencedChunk.value;
903 - break;
904 - }
905 - case BLOCKED:
906 - case PENDING: {
907 - return waitForReference(
908 - referencedChunk,
909 - parentObject,
910 - key,
911 - response,
912 - map,
913 - path.slice(i - 1),
914 - );
915 - }
916 - default: {
917 - // This is an error. Instead of erroring directly, we're going to encode this on
918 - // an initialization handler so that we can catch it at the nearest Element.
919 - if (initializingHandler) {
920 - initializingHandler.errored = true;
921 - initializingHandler.value = null;
922 - initializingHandler.reason = referencedChunk.reason;
923 - } else {
924 - initializingHandler = {
925 - chunk: null,
926 - value: null,
927 - reason: referencedChunk.reason,
928 - deps: 0,
929 - errored: true,
930 - };
931 - }
932 - return (null: any);
933 - }
934 - }
935 - }
895 const name = path[i];
937 - if (typeof value === 'object' && hasOwnProperty.call(value, name)) {
896 + if (
897 + typeof value === 'object' &&
898 + value !== null &&
899 + (getPrototypeOf(value) === ObjectPrototype ||
900 + getPrototypeOf(value) === ArrayPrototype) &&
901 + hasOwnProperty.call(value, name)
902 + ) {
903 value = value[name];
904 + } else {
905 + throw new Error('Invalid reference.');
906 }
907 }
908 const chunkValue = map(response, value, parentObject, key);
@@ -1006,6 +973,11 @@ function parseTypedArray<T: $ArrayBufferView | ArrayBuffer>(
973 const id = parseInt(reference.slice(2), 16);
974 const prefix = response._prefix;
975 const key = prefix + id;
976 + const chunks = response._chunks;
977 + if (chunks.has(id)) {
978 + throw new Error('Already initialized typed array.');
979 + }
980 +
981 // We should have this backingEntry in the store already because we emitted
982 // it before referencing it. It should be a Blob.
983 // TODO: Use getOutlinedModel to allow us to emit the Blob later. We should be able to do that now.
@@ -1055,6 +1027,7 @@ function parseTypedArray<T: $ArrayBufferView | ArrayBuffer>(
1027 const initializedChunk: InitializedChunk<T> = (chunk: any);
1028 initializedChunk.status = INITIALIZED;
1029 initializedChunk.value = handler.value;
1030 + initializedChunk.reason = null;
1031 if (resolveListeners !== null) {
1032 wakeChunk(response, resolveListeners, handler.value);
1033 }
@@ -1116,8 +1089,13 @@ function parseReadableStream<T>(
1089 parentKey: string,
1090 ): ReadableStream {
1091 const id = parseInt(reference.slice(2), 16);
1092 + const chunks = response._chunks;
1093 + if (chunks.has(id)) {
1094 + throw new Error('Already initialized stream.');
1095 + }
1096
1097 let controller: ReadableStreamController = (null: any);
1098 + let closed = false;
1099 const stream = new ReadableStream({
1100 type: type,
1101 start(c) {
@@ -1166,6 +1144,10 @@ function parseReadableStream<T>(
1144 }
1145 },
1146 close(json: string): void {
1147 + if (closed) {
1148 + return;
1149 + }
1150 + closed = true;
1151 if (previousBlockedChunk === null) {
1152 controller.close();
1153 } else {
@@ -1176,6 +1158,10 @@ function parseReadableStream<T>(
1158 }
1159 },
1160 error(error: mixed): void {
1161 + if (closed) {
1162 + return;
1163 + }
1164 + closed = true;
1165 if (previousBlockedChunk === null) {
1166 // $FlowFixMe[incompatible-call]
1167 controller.error(error);
@@ -1218,6 +1204,10 @@ function parseAsyncIterable<T>(
1204 parentKey: string,
1205 ): $AsyncIterable<T, T, void> | $AsyncIterator<T, T, void> {
1206 const id = parseInt(reference.slice(2), 16);
1207 + const chunks = response._chunks;
1208 + if (chunks.has(id)) {
1209 + throw new Error('Already initialized stream.');
1210 + }
1211
1212 const buffer: Array<SomeChunk<IteratorResult<T, T>>> = [];
1213 let closed = false;
@@ -1241,6 +1231,9 @@ function parseAsyncIterable<T>(
1231 nextWriteIndex++;
1232 },
1233 close(value: string): void {
1234 + if (closed) {
1235 + return;
1236 + }
1237 closed = true;
1238 if (nextWriteIndex === buffer.length) {
1239 buffer[nextWriteIndex] = createResolvedIteratorResultChunk(
@@ -1268,6 +1261,9 @@ function parseAsyncIterable<T>(
1261 }
1262 },
1263 error(error: Error): void {
1264 + if (closed) {
1265 + return;
1266 + }
1267 closed = true;
1268 if (nextWriteIndex === buffer.length) {
1269 buffer[nextWriteIndex] =
@@ -1329,7 +1325,7 @@ function parseModelString(
1325 const chunk = getChunk(response, id);
1326 return chunk;
1327 }
1332 - case 'F': {
1328 + case 'h': {
1329 // Server Reference
1330 const ref = value.slice(2);
1331 return getOutlinedModel(response, ref, obj, key, loadServerReference);
packages/react-server/src/ReactFlightServer.js
+1 -1
@@ -2797,7 +2797,7 @@ function serializePromiseID(id: number): string {
2797 }
2798
2799 function serializeServerReferenceID(id: number): string {
2800 - return '$F' + id.toString(16);
2800 + return '$h' + id.toString(16);
2801 }
2802
2803 function serializeSymbolReference(name: string): string {
scripts/error-codes/codes.json
+5 -1
@@ -551,5 +551,9 @@
551 "563": "This render completed successfully. All cacheSignals are now aborted to allow clean up of any unused resources.",
552 "564": "Unknown command. The debugChannel was not wired up properly.",
553 "565": "resolveDebugMessage/closeDebugChannel should not be called for a Request that wasn't kept alive. This is a bug in React.",
554 - "566": "FragmentInstance.scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead."
554 + "566": "FragmentInstance.scrollIntoView() does not support scrollIntoViewOptions. Use the alignToTop boolean instead.",
555 + "567": "Already initialized stream.",
556 + "568": "Already initialized typed array.",
557 + "569": "Cannot have cyclic thenables.",
558 + "570": "Invalid reference."
559 }