Skip to content

renderToString disposes its root via setTimeout; graphs are retained across synchronous renders #3385

Description

@ryansolid

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).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions