7cab51cc28
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>