fix(runtime): always unmount the tree in renderToString so a paint throw can't leak listeners (#186)
renderToString mounts the Vue tree, then lays out and paints, then unmounts. The app.unmount() sat inside the try AFTER paint, so when layout/paint threw (e.g. a <Transform> whose transformer throws during the paint phase) control jumped to the outer finally, which only freed yoga — Vue never tore down, so onScopeDispose never ran. Any composable that registered an external listener then leaked it: useWindowSize attaches a `resize` listener to the shared process.stdout (the no-op AppContext's stdout) and only removes it via onScopeDispose, so each failed renderToString leaked one listener, accumulating toward Node's MaxListenersExceededWarning. Fix: track that mount succeeded and, in the outer finally, run app.unmount() when `mounted && !teardownSucceeded` (best-effort, in try/catch, before the yoga free). The happy path is unaffected (it already unmounted; teardownSucceeded short-circuits the fallback). The error-path unmount frees child yoga nodes and runs onScopeDispose cleanups; freeRecursive then frees the root. The original paint error still propagates (the fallback teardown can't mask it). useWindowSize is intentionally unchanged — the unmount-in-finally is the general fix and also covers any other external listener a tree registers. Test (sequential — asserts on the process-global process.stdout resize listener count): three renderToString calls whose paint throws leak zero `resize` listeners after the fix (3 before). Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -144,10 +144,12 @@ function renderToStringInternal(
|
||||
};
|
||||
|
||||
let teardownSucceeded = false;
|
||||
let mounted = false;
|
||||
|
||||
try {
|
||||
// Synchronously render the Vue tree into the root.
|
||||
app.mount(root);
|
||||
mounted = true;
|
||||
|
||||
const restoreLayoutGuards = calculateLayoutWithContentGuards(
|
||||
root,
|
||||
@@ -198,12 +200,28 @@ function renderToStringInternal(
|
||||
|
||||
return normalizedStaticOutput || output;
|
||||
} finally {
|
||||
// If layout/paint threw, the happy-path app.unmount() above was skipped. Unmount
|
||||
// here so the tree's onScopeDispose cleanups ALWAYS run — otherwise a composable
|
||||
// that registered an external listener (e.g. useWindowSize's `resize` listener on
|
||||
// the shared process.stdout) leaks one per failed call, accumulating toward Node's
|
||||
// MaxListenersExceededWarning. Guard with `mounted` (never unmount a tree that
|
||||
// never mounted) and `teardownSucceeded` (the happy path already unmounted — no
|
||||
// double-unmount). app.unmount() also frees the CHILD yoga nodes via the node-ops
|
||||
// remove handler, leaving only the root for freeRecursive below.
|
||||
if (mounted && !teardownSucceeded) {
|
||||
try {
|
||||
app.unmount();
|
||||
} catch {
|
||||
// Best-effort teardown: a throw here must not mask the original error.
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure native yoga memory is freed even if rendering or teardown threw.
|
||||
// Yoga nodes are WASM-backed and not garbage collected.
|
||||
// Yoga nodes are WASM-backed and not garbage collected. In the happy path
|
||||
// detachYoga(root) already freed the root; here (error path) freeRecursive
|
||||
// cleans up the root and any child nodes the unmount above couldn't free.
|
||||
if (!teardownSucceeded) {
|
||||
try {
|
||||
// If unmount failed, some child nodes may not have been freed.
|
||||
// Use freeRecursive to clean up the entire tree as best-effort.
|
||||
root.yoga.freeRecursive();
|
||||
} catch {
|
||||
// Best-effort: node may already be partially freed
|
||||
|
||||
Reference in New Issue
Block a user