facebook / react · Issue No. 37178
When streaming with renderToPipeableStream, an outer Suspense boundary never completes if an inner boundary's suspending fallback is abandoned after the shell has flushed: the inner boundary's content resolves and is written to the stream, but no $RC is ever emitted for the outer boundary, onAllReady never fires, and no error is reported anywhere. The visible result is the outer fallback showing forever while the real content sits unreferenced in the stream.
This looks like the post-shell-flush sibling of #34694 ("suspended fallback segment directly inside another Suspense is abandoned"): that fix's test resolves before flushing, and this variant — where the content resolves ~150ms after the shell flushed — still reproduces on current builds, including canary.
Reproduced identically on:
19.2.619.2.8 (latest stable)19.3.0-canary-cbb046ab-20260731// npm i react@latest react-dom@latest && node repro.js
const React = require('react');
const { renderToPipeableStream } = require('react-dom/server');
const { Writable } = require('stream');
const h = React.createElement;
let openContentGate;
const contentGate = new Promise((r) => { openContentGate = r; });
let contentReady = false;
function SlowContent() {
// Suspends until the gate opens ~150ms in — i.e. AFTER the shell flushed.
if (!contentReady) throw contentGate.then(() => { contentReady = true; });
return h('main', null, 'REAL_CONTENT_' + 'x'.repeat(20000));
}
function SuspendingFallback() {
// A fallback that suspends and never resolves. Once SlowContent resolves,
// this fallback is abandoned and the visible children should render.
throw new Promise(() => {});
}
const app = h('div', null,
h(React.Suspense, { fallback: h('p', null, 'outer-fallback') },
h(React.Suspense, { fallback: h(SuspendingFallback) },
h(SlowContent)
)
)
);
let out = '';
const events = [];
const dest = new Writable({
write(chunk, enc, cb) { out += chunk.toString('utf8'); cb(); },
});
const { pipe } = renderToPipeableStream(app, {
// Small threshold to force the outlined-boundary path the same way a large
// real-world page (hundreds of KB) does with the default.
progressiveChunkSize: 1024,
onShellReady() { events.push('onShellReady'); pipe(dest); },
onAllReady() { events.push('onAllReady'); },
onError(e) { events.push('onError: ' + (e && e.message)); },
});
setTimeout(openContentGate, 150);
setTimeout(() => {
const rc = [...out.matchAll(/\$RC\("([^"]+)","([^"]+)"/g)].map(m => `${m[1]}<-${m[2]}`);
const rs = [...out.matchAll(/\$RS\("([^"]+)","([^"]+)"/g)].map(m => `${m[1]}->${m[2]}`);
console.log('events:', events);
console.log('$RC calls:', rc);
console.log('$RS calls:', rs);
console.log('content present in stream:', out.includes('REAL_CONTENT_'));
}, 5000);
events: [ 'onShellReady' ]
pending boundary templates: [ 'B:0', 'B:1' ]
hidden segments: [ 'S:0', 'S:1' ]
$RC calls: [ 'B:1<-S:1' ]
$RS calls: []
content present in stream: true
outer completed = false | onAllReady fired = false
The inner boundary B:1 completes (its $RC targets a segment inside the still-hidden S:0), but the outer boundary B:0 never receives a completion instruction, and the request never reaches onAllReady — the render hangs with no error. The abandoned fallback task appears to keep the outer boundary's pending count from draining.
Once the inner boundary's content resolves, its never-resolving fallback is moot; the outer boundary should complete ($RC("B:0", ...) emitted, onAllReady fired) and the content should be revealable — matching the expectation in #34694's test that "visible children render correctly without errors" when a suspended fallback is abandoned, just with the content resolving after the shell flush instead of before it.
Hit in production through Next.js App Router: a large streamed route (big enough to enter the outlined-boundary path with the default progressiveChunkSize) behind a loading.tsx boundary intermittently serves its loading skeleton forever — content bytes present in the document as unreferenced hidden segments, stream closed cleanly by the framework layer, zero client or server errors, no recovery. It only manifests under cold conditions (cold serverless instance / cold lazy chunks), which makes it look like an app-level data bug until the wire format is inspected.
Relay reads this issue against the repository's contribution signals: the files it is likely to touch, how the maintainers triage work this size, and what the first contribution would exercise.
The full analysis for this issue is still being assembled. Until then, the description above and the thread on GitHub are the most reliable context.