Version: 2.0.0-rc.8 (@solidjs/web)
packages/web/src/server.ts ~1534:
let html = root(
d => {
setTimeout(d);
return resolveSSRSync(escape(code()));
},
{ id: renderId }
);
The root (and every memo/computation created under it) lives until the next macrotask. In any code path that renders more than once per task — benchmarks, batch pre-rendering, tests, Promise.all over many renderToString calls — nothing is released until the loop yields.
Measured
Rendering @yak/solid's base runtime (one memo per element) 1000 elements per call, tight loop, --expose-gc:
| renders |
heap growth |
| 500 |
+121 MB |
| 2500 |
+608 MB |
≈243 KB retained per render; the same loop with a setImmediate yield between renders stays flat. React's renderToString is flat in the tight loop. The practical effect: a synchronous renderToString microbenchmark understated that runtime by 2–6× on larger workloads purely from GC pressure (ryansolid/yak-bench, see the method notes), and the published Solid-vs-React numbers in next-yak#644 were taken with exactly such a loop.
Even on a real server this holds each request's whole reactive graph one macrotask longer than necessary.
Why it's deferred (to confirm)
Presumably so that cleanups (head-tag registrations, httpStatus/httpHeader scope declarations, asset tracking) don't run before serializeFragmentAssets / renderShellHead / assembleDocument read their state — the streaming path documents "the head is committed right before the render's final dispose".
Proposed fix
Capture d and call it synchronously at the end of renderToString, after assembleDocument — same ordering guarantee, no macrotask hold. If something genuinely needs the dispose to happen after the caller receives the string, queueMicrotask bounds the retention to the current task instead of the next turn of the event loop.
Notes
renderToStream has its own dispose plumbing (if (firstFlushed) dispose()); check whether the sync path can share it.
- A regression test: render N times in a loop, force GC, assert heap is bounded (or assert
getOwner()-visible disposal via an onCleanup counter that must equal N before the loop returns).
Version: 2.0.0-rc.8 (
@solidjs/web)packages/web/src/server.ts~1534:The root (and every memo/computation created under it) lives until the next macrotask. In any code path that renders more than once per task — benchmarks, batch pre-rendering, tests,
Promise.allover manyrenderToStringcalls — nothing is released until the loop yields.Measured
Rendering
@yak/solid's base runtime (one memo per element) 1000 elements per call, tight loop,--expose-gc:≈243 KB retained per render; the same loop with a
setImmediateyield between renders stays flat. React'srenderToStringis flat in the tight loop. The practical effect: a synchronousrenderToStringmicrobenchmark understated that runtime by 2–6× on larger workloads purely from GC pressure (ryansolid/yak-bench, see the method notes), and the published Solid-vs-React numbers in next-yak#644 were taken with exactly such a loop.Even on a real server this holds each request's whole reactive graph one macrotask longer than necessary.
Why it's deferred (to confirm)
Presumably so that cleanups (head-tag registrations,
httpStatus/httpHeaderscope declarations, asset tracking) don't run beforeserializeFragmentAssets/renderShellHead/assembleDocumentread their state — the streaming path documents "the head is committed right before the render's final dispose".Proposed fix
Capture
dand call it synchronously at the end ofrenderToString, afterassembleDocument— same ordering guarantee, no macrotask hold. If something genuinely needs the dispose to happen after the caller receives the string,queueMicrotaskbounds the retention to the current task instead of the next turn of the event loop.Notes
renderToStreamhas its own dispose plumbing (if (firstFlushed) dispose()); check whether the sync path can share it.getOwner()-visible disposal via anonCleanupcounter that must equal N before the loop returns).