[Flight] don't emit chunks for rejected thenables after abort (#31169)
When aborting we emit chunks for each pending task. However there was a bug where a thenable could also reject before we could flush and we end up with an extra chunk throwing off the pendingChunks bookeeping. When a task is retried we skip it if is is not in PENDING status because we understand it was completed some other way. We need to replciate this for the reject pathway on serialized thenables since aborting if effectively completing all pending tasks and not something we need to continue to do once the thenable rejects later.
Josh Story committed
Oct 10, 2024 at 06:47 UTC
38af456a494acb34931c71e31efbccdb53e11174
2 files changed
+70
-15
packages/react-server-dom-webpack/src/__tests__/ReactFlightDOM-test.js
+50
@@ -3084,4 +3084,54 @@ describe('ReactFlightDOM', () => {
3084
</div>,
3085
);
3086
});
3087
+
3088
+ it('rejecting a thenable after an abort before flush should not lead to a frozen readable', async () => {
3089
+ const ClientComponent = clientExports(function (props: {
3090
+ promise: Promise<void>,
3091
+ }) {
3092
+ return 'hello world';
3093
+ });
3094
+
3095
+ let reject;
3096
+ const promise = new Promise((_, re) => {
3097
+ reject = re;
3098
+ });
3099
+
3100
+ function App() {
3101
+ return (
3102
+ <div>
3103
+ <Suspense fallback="loading...">
3104
+ <ClientComponent promise={promise} />
3105
+ </Suspense>
3106
+ </div>
3107
+ );
3108
+ }
3109
+
3110
+ const errors = [];
3111
+ const {writable, readable} = getTestStream();
3112
+ const {pipe, abort} = await serverAct(() =>
3113
+ ReactServerDOMServer.renderToPipeableStream(<App />, webpackMap, {
3114
+ onError(x) {
3115
+ errors.push(x);
3116
+ },
3117
+ }),
3118
+ );
3119
+ await serverAct(() => {
3120
+ abort('STOP');
3121
+ reject('STOP');
3122
+ });
3123
+ pipe(writable);
3124
+
3125
+ const reader = readable.getReader();
3126
+ while (true) {
3127
+ const {done} = await reader.read();
3128
+ if (done) {
3129
+ break;
3130
+ }
3131
+ }
3132
+
3133
+ expect(errors).toEqual(['STOP']);
3134
+
3135
+ // We expect it to get to the end here rather than hang on the reader.
3136
+ });
3137
});
packages/react-server/src/ReactFlightServer.js
+20
-15
@@ -696,22 +696,27 @@ function serializeThenable(
696
pingTask(request, newTask);
697
},
698
reason => {
699
- if (
700
- enablePostpone &&
701
- typeof reason === 'object' &&
702
- reason !== null &&
703
- (reason: any).$$typeof === REACT_POSTPONE_TYPE
704
- ) {
705
- const postponeInstance: Postpone = (reason: any);
706
- logPostpone(request, postponeInstance.message, newTask);
707
- emitPostponeChunk(request, newTask.id, postponeInstance);
708
- } else {
709
- const digest = logRecoverableError(request, reason, newTask);
710
- emitErrorChunk(request, newTask.id, digest, reason);
699
+ if (newTask.status === PENDING) {
700
+ // We expect that the only status it might be otherwise is ABORTED.
701
+ // When we abort we emit chunks in each pending task slot and don't need
702
+ // to do so again here.
703
+ if (
704
+ enablePostpone &&
705
+ typeof reason === 'object' &&
706
+ reason !== null &&
707
+ (reason: any).$$typeof === REACT_POSTPONE_TYPE
708
+ ) {
709
+ const postponeInstance: Postpone = (reason: any);
710
+ logPostpone(request, postponeInstance.message, newTask);
711
+ emitPostponeChunk(request, newTask.id, postponeInstance);
712
+ } else {
713
+ const digest = logRecoverableError(request, reason, newTask);
714
+ emitErrorChunk(request, newTask.id, digest, reason);
715
+ }
716
+ newTask.status = ERRORED;
717
+ request.abortableTasks.delete(newTask);
718
+ enqueueFlush(request);
719
}
712
- newTask.status = ERRORED;
713
- request.abortableTasks.delete(newTask);
714
- enqueueFlush(request);
720
},
721
);
722