renderToStreamWithSuspense function
Progressively streams HTML with out-of-order resolution for asynchronous Suspense boundaries.
Flushes the initial document shell immediately as its first stream chunk, emitting
fallback placeholder elements (<div id="bloom-suspense-N">...</div>) for every SuspenseNode
located anywhere in the tree (including deeply nested children).
As each boundary's async resource future resolves, this function streams an inline <script>
snippet that replaces the placeholder <div> with the resolved HTML content in the client DOM
via outerHTML. Boundaries stream in whatever order they resolve, rather than source order.
If an async resource fails and SuspenseNode.errorBuilder is provided, the error subtree is rendered and streamed; if no error builder is configured, the initial fallback element remains.
final stream = renderToStreamWithSuspense(
Div(
children: [
const H1(text: 'Live Dashboard'),
Suspense<UserProfile>(
resource: fetchUserProfile,
fallback: const Div(text: 'Loading profile...'),
builder: (profile) => Div(text: 'Hello, ${profile.name}'),
),
],
),
);
await for (final chunk in stream) {
httpResponse.write(chunk);
}
Implementation
Stream<String> renderToStreamWithSuspense(BloomNode node) {
final shellBuf = StringBuffer();
final controller = StreamController<String>();
var outstanding = 0;
var shellDone = false;
var counter = 0;
void maybeClose() {
if (shellDone && outstanding == 0 && !controller.isClosed) {
controller.close();
}
}
void onSuspense(SuspenseNode<dynamic> boundary, StringBuffer buf) {
final id = 'bloom-suspense-${counter++}';
buf.write('<div id="$id">');
_render(boundary.fallback, buf, onSuspense);
buf.write('</div>');
// Routed through a `dynamic`-typed reference so `resource`/`builder`
// are invoked via fully dynamic dispatch. Reading them through any
// statically-typed `SuspenseNode<...>` view (even `<dynamic>`) trips
// Dart's generic covariance check — the real object is
// `SuspenseNode<T>` for some concrete `T`, and a `BloomNode
// Function(T)` is genuinely not a `BloomNode Function(dynamic)` by
// Dart's static function-subtyping rules. Dynamic dispatch skips
// that check and calls correctly regardless of `T`.
final dynamic dynBoundary = boundary;
outstanding++;
() async {
try {
final data = await dynBoundary.resource();
final resolved = dynBoundary.builder(data) as BloomNode;
final innerBuf = StringBuffer();
// Nested boundaries discovered here (from resolved async content)
// register themselves via the same [onSuspense] hook, incrementing
// [outstanding] before this task's own decrement below — so the
// stream never closes early while a nested boundary is pending.
_render(resolved, innerBuf, onSuspense);
final safeJson = jsonEncode(innerBuf.toString())
.replaceAll('</script', '<\\/script');
if (!controller.isClosed) {
controller.add(
'<script>(function(){var e=document.getElementById("$id");'
'if(e){e.outerHTML=$safeJson;}})();</script>',
);
}
} catch (err, stack) {
if (boundary.errorBuilder != null) {
try {
final errorNode = boundary.errorBuilder!(err, stack);
final innerBuf = StringBuffer();
_render(errorNode, innerBuf, onSuspense);
final safeJson = jsonEncode(innerBuf.toString())
.replaceAll('</script', '<\\/script');
if (!controller.isClosed) {
controller.add(
'<script>(function(){var e=document.getElementById("$id");'
'if(e){e.outerHTML=$safeJson;}})();</script>',
);
}
} catch (_) {}
}
// Resource rejected — if no errorBuilder was supplied (or if it threw),
// the fallback already flushed in the shell stands as the final content.
} finally {
outstanding--;
maybeClose();
}
}();
}
// The shell renders eagerly inside a fresh keyframe zone so concurrent
// streams cannot corrupt each other's `@keyframes` deduplication state.
// Async Suspense continuations scheduled above inherit this zone, keeping
// late-resolved patches scoped to their own stream. Only the chunk
// delivery in [tail] below is lazy.
late final String shell;
runZoned(() {
_render(node, shellBuf, onSuspense);
shellDone = true;
// Closes immediately if no boundary was encountered (outstanding stays
// 0). If boundaries resolved synchronously-fast enough to have already
// closed the controller by this point, buffered events are still
// delivered once `controller.stream` gets its listener below — never
// skip the subscription.
maybeClose();
shell = shellBuf.toString();
}, zoneValues: {_keyframesZoneKey: <String>{}});
final shellSnapshot = shell;
Stream<String> tail() async* {
yield shellSnapshot;
yield* controller.stream;
}
return tail();
}