Skip to content

fix(server-renderer): cleanup component effect scopes after SSR render#14548

Merged
edison1105 merged 11 commits intovuejs:mainfrom
SaeedNezafat:saeed-nezafat/fix/ssr-scope-cleanup
Mar 25, 2026
Merged

fix(server-renderer): cleanup component effect scopes after SSR render#14548
edison1105 merged 11 commits intovuejs:mainfrom
SaeedNezafat:saeed-nezafat/fix/ssr-scope-cleanup

Conversation

@SaeedNezafat
Copy link
Copy Markdown
Contributor

@SaeedNezafat SaeedNezafat commented Mar 9, 2026

Problem

SSR component instance scopes are created during render, but unlike client rendering they are not explicitly stopped because SSR does not run the normal unmount path.

This can retain scope-bound effects and cleanup callbacks beyond the request lifetime.

Related to nuxt/nuxt#33644, which reports the issue as an upstream Vue bug.

Solution

Track SSR component instance scopes on the request-local SSR context and stop them after render finalization.

This keeps cleanup:

  • request-scoped
  • renderer-local
  • cross-runtime
  • free of Node-specific APIs

Tests

Added tests for:

  • per-render scope cleanup
  • concurrent render isolation
  • detached scope ownership preservation

Related

A workaround based on SSR scope cleanup was previously suggested in that issue:
nuxt/nuxt#33644 (comment)

Another user reported that the workaround resolved the issue in their Nuxt setup:
nuxt/nuxt#33644 (comment)

This PR attempts to address the underlying problem directly in Vue's server renderer.

If needed, I can rebase or adapt this against any existing upstream work linked from nuxt/nuxt#33644.

Summary by CodeRabbit

  • Tests

    • Added tests validating component effect scope lifecycle: cleanup after render, isolation across concurrent renders, and that detached scopes created during SSR are not auto-stopped.
    • Updated SSR watch tests to expect no lingering watcher handles after render.
  • Bug Fixes

    • Server-side rendering now reliably stops watchers and per-component effect scopes.
    • Centralized cleanup to ensure it runs exactly once on both success and error paths for streamed and string SSR.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 9, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: ec0a80d6-3a16-4493-8290-fcd7615f0f28

📥 Commits

Reviewing files that changed from the base of the PR and between 990abe6 and 6dd4c81.

📒 Files selected for processing (1)
  • packages/server-renderer/src/renderToStream.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/server-renderer/src/renderToStream.ts

📝 Walkthrough

Walkthrough

Server renderer now records per-component effect scopes in the SSR context and ensures those scopes (and watcher handles) are cleaned up after render (both stream and string). Tests for effectScope lifecycle, concurrent render isolation, and detached-scope behavior were added (duplicated blocks present).

Changes

Cohort / File(s) Summary
Tests (render)
packages/server-renderer/__tests__/render.spec.ts
Imported effectScope, onScopeDispose and added three tests: scope cleanup after render, concurrent render isolation, detached-scope non-auto-stop. The new tests appear duplicated in the file.
Scope tracking & API
packages/server-renderer/src/render.ts
Added __instanceScopes?: { stop: () => void }[] to SSRContext and exported cleanupContext(context: SSRContext). Component rendering now pushes instance scopes into context.__instanceScopes.
Stream rendering
packages/server-renderer/src/renderToStream.ts
Centralized cleanup via finalize() that calls cleanupContext(context) once; render launched with a Promise microtask; ensures cleanup before stream end and preserves original render errors on stream destroy.
String rendering
packages/server-renderer/src/renderToString.ts
Wrapped render flow in try/finally and moved cleanup to finally by calling cleanupContext(context); removed inline watcher unwatch loop.
Tests (watchers)
packages/server-renderer/__tests__/ssrWatch.spec.ts
Updated assertions: expected ctx.__watcherHandles counts changed from 1 to 0 in several tests to reflect watcher registration changes.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Renderer
  participant SSRContext
  participant Component
  participant TeleportResolver
  participant Cleaner

  Client->>Renderer: request render (stream/string)
  Renderer->>Component: render component VNode
  Component->>SSRContext: push instance scope into __instanceScopes
  Renderer->>TeleportResolver: resolve teleports / unroll buffer
  TeleportResolver-->>Renderer: teleports resolved
  Renderer->>Cleaner: finalize / finally -> cleanupContext(context)
  Cleaner->>SSRContext: call watcher unwatch callbacks (if any)
  Cleaner->>SSRContext: for each __instanceScopes -> call stop()
  Cleaner->>SSRContext: clear __instanceScopes
  Cleaner-->>Renderer: cleanup complete
  Renderer-->>Client: return stream/string (then close)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Poem

🐇 I hopped through scopes both near and far,

stacked each one inside the SSR jar,
on finish I stopped them, neat and clear,
teleports settled, buffers cheer,
a tidy render—rabbit’s happy star ✨

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: fixing cleanup of component effect scopes after SSR rendering, which is the core objective of the pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
packages/server-renderer/__tests__/render.spec.ts (1)

1007-1083: Add one failing-render regression for the new cleanup path.

These cases only cover successful renders. Since the renderer change is specifically about finally-based teardown, I'd add a component that registers onScopeDispose and then throws during setup() or render, and assert that cleanup still fires.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server-renderer/__tests__/render.spec.ts` around lines 1007 - 1083,
Add a failing-render regression test that ensures onScopeDispose handlers still
run when setup() or the render function throws: create an app via createApp
where setup registers onScopeDispose (pushing to an array or setting a flag)
then deliberately throws (or returns a render that throws), call render(app)
expecting it to reject, and assert the cleanup ran (array contains entry or flag
true). Place tests alongside the existing ones (use the same deferred pattern
for concurrency if needed) so they exercise the finally-based teardown path and
reference render, createApp, setup, onScopeDispose, and effectScope to locate
the relevant test files.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server-renderer/src/renderToStream.ts`:
- Around line 76-97: The current Promise chain calls renderComponentVNode(vnode)
before Promise.resolve(), so synchronous throws escape the chain; change the
call to invoke renderComponentVNode inside a thenable (e.g.,
Promise.resolve().then(() => renderComponentVNode(vnode))) so any sync
exceptions are routed to the chain's .catch and .finally; ensure the rest of the
chain still calls unrollBuffer(buffer, stream), resolveTeleports(context),
stream.push(null), and that .catch uses stream.destroy(error) and .finally
invokes cleanup.

---

Nitpick comments:
In `@packages/server-renderer/__tests__/render.spec.ts`:
- Around line 1007-1083: Add a failing-render regression test that ensures
onScopeDispose handlers still run when setup() or the render function throws:
create an app via createApp where setup registers onScopeDispose (pushing to an
array or setting a flag) then deliberately throws (or returns a render that
throws), call render(app) expecting it to reject, and assert the cleanup ran
(array contains entry or flag true). Place tests alongside the existing ones
(use the same deferred pattern for concurrency if needed) so they exercise the
finally-based teardown path and reference render, createApp, setup,
onScopeDispose, and effectScope to locate the relevant test files.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 61f8e0a7-eaab-4a37-b08e-9aefb19bd856

📥 Commits

Reviewing files that changed from the base of the PR and between fdd863f and 12e9b0a.

📒 Files selected for processing (4)
  • packages/server-renderer/__tests__/render.spec.ts
  • packages/server-renderer/src/render.ts
  • packages/server-renderer/src/renderToStream.ts
  • packages/server-renderer/src/renderToString.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server-renderer/src/renderToStream.ts`:
- Around line 77-81: The loop calling each unwatch from context.__watcherHandles
drains the watchers but leaves the array intact which retains references; after
iterating and calling each unwatch() (context.__watcherHandles), clear the
handles to release request-local references — e.g. set context.__watcherHandles
to an empty array or delete the property once all unwatch() calls complete so
the stopped watcher state is no longer reachable.
- Around line 90-98: The current Promise chain puts cleanup in .finally(), which
can throw and bypass the .catch() that calls stream.destroy(error); instead,
declare a guarded flag (e.g., let cleaned = false) before the chain and remove
.finally(cleanup). Insert a guarded cleanup step into the main chain (e.g.,
.then(() => { if (!cleaned) { cleaned = true; cleanup(); } })) and also call the
same guarded cleanup from the .catch(error => { if (!cleaned) { cleaned = true;
cleanup(); } stream.destroy(error); throw error; }) so that any cleanup errors
are part of the same chain and all errors flow through the .catch that calls
stream.destroy; reference the existing symbols renderComponentVNode,
unrollBuffer, resolveTeleports, cleanup, stream.push(null), and stream.destroy
when making these changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 176181c7-af66-4930-a0fd-a82eac97f8f3

📥 Commits

Reviewing files that changed from the base of the PR and between 12e9b0a and dcfe76d.

📒 Files selected for processing (1)
  • packages/server-renderer/src/renderToStream.ts

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Mar 13, 2026

Open in StackBlitz

@vue/compiler-core

pnpm add https://pkg.pr.new/vuejs/core/@vue/compiler-core@14548
npm i https://pkg.pr.new/vuejs/core/@vue/compiler-core@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/compiler-core@14548.tgz

@vue/compiler-dom

pnpm add https://pkg.pr.new/vuejs/core/@vue/compiler-dom@14548
npm i https://pkg.pr.new/vuejs/core/@vue/compiler-dom@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/compiler-dom@14548.tgz

@vue/compiler-sfc

pnpm add https://pkg.pr.new/vuejs/core/@vue/compiler-sfc@14548
npm i https://pkg.pr.new/vuejs/core/@vue/compiler-sfc@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/compiler-sfc@14548.tgz

@vue/compiler-ssr

pnpm add https://pkg.pr.new/vuejs/core/@vue/compiler-ssr@14548
npm i https://pkg.pr.new/vuejs/core/@vue/compiler-ssr@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/compiler-ssr@14548.tgz

@vue/reactivity

pnpm add https://pkg.pr.new/vuejs/core/@vue/reactivity@14548
npm i https://pkg.pr.new/vuejs/core/@vue/reactivity@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/reactivity@14548.tgz

@vue/runtime-core

pnpm add https://pkg.pr.new/vuejs/core/@vue/runtime-core@14548
npm i https://pkg.pr.new/vuejs/core/@vue/runtime-core@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/runtime-core@14548.tgz

@vue/runtime-dom

pnpm add https://pkg.pr.new/vuejs/core/@vue/runtime-dom@14548
npm i https://pkg.pr.new/vuejs/core/@vue/runtime-dom@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/runtime-dom@14548.tgz

@vue/server-renderer

pnpm add https://pkg.pr.new/vuejs/core/@vue/server-renderer@14548
npm i https://pkg.pr.new/vuejs/core/@vue/server-renderer@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/server-renderer@14548.tgz

@vue/shared

pnpm add https://pkg.pr.new/vuejs/core/@vue/shared@14548
npm i https://pkg.pr.new/vuejs/core/@vue/shared@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/shared@14548.tgz

vue

pnpm add https://pkg.pr.new/vuejs/core/vue@14548
npm i https://pkg.pr.new/vuejs/core/vue@14548
yarn add https://pkg.pr.new/vuejs/core/vue@14548.tgz

@vue/compat

pnpm add https://pkg.pr.new/vuejs/core/@vue/compat@14548
npm i https://pkg.pr.new/vuejs/core/@vue/compat@14548
yarn add https://pkg.pr.new/vuejs/core/@vue/compat@14548.tgz

commit: 6dd4c81

@github-actions
Copy link
Copy Markdown

Size Report

Bundles

File Size Gzip Brotli
runtime-dom.global.prod.js 105 kB 39.7 kB 35.7 kB
vue.global.prod.js 163 kB 59.7 kB 53.1 kB

Usages

Name Size Gzip Brotli
createApp (CAPI only) 48.2 kB 18.7 kB 17.2 kB
createApp 56.3 kB 21.8 kB 19.9 kB
createSSRApp 60.6 kB 23.5 kB 21.5 kB
defineCustomElement 62.5 kB 23.7 kB 21.6 kB
overall 70.7 kB 27.1 kB 24.7 kB

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server-renderer/src/render.ts`:
- Around line 65-77: The cleanup loop in cleanupContext currently stops early if
a single unwatch() or scope.stop() throws, leaving other resources uncleaned;
update cleanupContext so each call to functions on context.__watcherHandles
(unwatch) and context.__instanceScopes (scope.stop) is wrapped in its own
try/catch to ensure remaining handles/scopes are processed even if one
throws—optionally capture/log the exception (but do not rethrow) and continue;
keep the existing length resets (context.__watcherHandles.length = 0 and
context.__instanceScopes.length = 0) after iteration to ensure arrays are
cleared.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 42c7b372-0f26-4180-87b5-36a2b70ac23b

📥 Commits

Reviewing files that changed from the base of the PR and between 56b738c and 861607c.

📒 Files selected for processing (5)
  • packages/server-renderer/__tests__/render.spec.ts
  • packages/server-renderer/__tests__/ssrWatch.spec.ts
  • packages/server-renderer/src/render.ts
  • packages/server-renderer/src/renderToStream.ts
  • packages/server-renderer/src/renderToString.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/server-renderer/tests/render.spec.ts

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
packages/server-renderer/src/renderToStream.ts (1)

96-103: ⚠️ Potential issue | 🟡 Minor

Original render error is lost when cleanup also throws.

If finalize() throws after a render failure, the original error is replaced with cleanupError, making the root cause harder to debug. Consider using finally to preserve the original error:

💡 Suggested fix
     .catch(error => {
       try {
         finalize()
-      } catch (cleanupError) {
-        error = cleanupError
+      } finally {
+        stream.destroy(error)
       }
-      stream.destroy(error)
     })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/server-renderer/src/renderToStream.ts` around lines 96 - 103, The
current catch block replaces the original render error when finalize() throws;
preserve the original error by capturing it into a separate variable (e.g.,
originalError = error) before calling finalize(), then call stream.destroy with
the originalError if present, falling back to cleanupError only if originalError
is falsy; alternatively, move finalize() into a finally block so
stream.destroy(error) is always invoked with the original render error. Update
the code around finalize() and stream.destroy() to stop assigning cleanupError
to the original error variable.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/server-renderer/src/renderToStream.ts`:
- Around line 96-103: The current catch block replaces the original render error
when finalize() throws; preserve the original error by capturing it into a
separate variable (e.g., originalError = error) before calling finalize(), then
call stream.destroy with the originalError if present, falling back to
cleanupError only if originalError is falsy; alternatively, move finalize() into
a finally block so stream.destroy(error) is always invoked with the original
render error. Update the code around finalize() and stream.destroy() to stop
assigning cleanupError to the original error variable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 217b17ba-ab17-4092-aef5-7ae8c4ee6d83

📥 Commits

Reviewing files that changed from the base of the PR and between 861607c and 5711a49.

📒 Files selected for processing (1)
  • packages/server-renderer/src/renderToStream.ts

@edison1105 edison1105 added scope: ssr ready to merge The PR is ready to be merged. labels Mar 13, 2026
@edison1105
Copy link
Copy Markdown
Member

/ecosystem-ci run

@vue-bot
Copy link
Copy Markdown
Contributor

vue-bot commented Mar 13, 2026

📝 Ran ecosystem CI: Open

suite result latest scheduled
nuxt success success
radix-vue success success
vant success success
pinia success success
language-tools failure failure
vueuse success success
primevue success success
vue-simple-compiler success success
router success success
vite-plugin-vue success success
quasar success success
vitepress success success
vue-macros success success
vue-i18n failure success
vuetify success success
test-utils success success

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/server-renderer/src/renderToStream.ts`:
- Around line 96-103: The catch block currently calls stream.destroy twice when
finalize() throws (once with cleanupError and again with the original error),
which can mask the root cause; modify the error handling in the .catch handler
around finalize() so you only call stream.destroy once—prefer passing the most
relevant error (e.g., cleanupError if finalize() throws, otherwise the original
error) or combine them (attach the original error as a property/cause) before a
single stream.destroy call; adjust the closure that references finalize() and
stream.destroy to implement this single-destroy strategy.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a211adb2-d1be-44f6-913d-b78276125655

📥 Commits

Reviewing files that changed from the base of the PR and between 861607c and 990abe6.

📒 Files selected for processing (2)
  • packages/server-renderer/src/render.ts
  • packages/server-renderer/src/renderToStream.ts

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
@edison1105
Copy link
Copy Markdown
Member

edison1105 commented Apr 3, 2026

@SaeedNezafat
I think this fix introduces more problems than it solves.

The current tests in this PR mainly validate behavior caused by calling scope.stop() during SSR cleanup, but they do not clearly demonstrate an actual SSR memory leak. In particular, this changes observable SSR semantics by triggering cleanup behavior that users do not expect, such as onScopeDispose() being invoked on the server.

Also, nuxt/nuxt#33644 should already have been addressed by #14445.

If there is still a real memory leak on the Vue side after that fix, please provide a minimal reproduction that demonstrates it clearly. Ideally this should show either:

  • retained subscriptions/effects across SSR requests, or
  • measurable heap growth that does not recover after requests complete.

Without such a reproduction, I think we should revert #14548 and revisit this with a narrower fix.

@SaeedNezafat
Copy link
Copy Markdown
Contributor Author

@edison1105

Following up on this issue, I took some time to re-investigate it more thoroughly across different Vue/Nuxt versions, using stronger instrumentation.

In earlier versions, I observed problematic SSR behavior in a real Nuxt application: reactive resources appeared to accumulate across requests. Applying a custom patch (based on AsyncLocalStorage + explicit EffectScope.stop()) noticeably improved the situation in practice, which initially suggested a retention issue.

Looking back, that behavior makes sense in context:

  • the workaround effectively enforced request isolation (via AsyncLocalStorage),
  • and made cleanup deterministic at the end of each render,
  • which helped mitigate issues related to async context/ownership and cross-request accumulation.

However, after re-testing on newer versions (including upstream async-context fixes), and upgrading the probe to use GC-based liveness tracking (WeakRef + FinalizationRegistry + forced GC), the picture is now clearer:

  • onScopeDispose() still does not fire during SSR in this scenario,
  • but request-scoped reactive closures are mostly reclaimed after GC (~98–99% finalized),
  • and heap usage returns close to baseline after forced GC.

At this point, I no longer have strong evidence of a persistent SSR heap leak in the current setup.

The earlier signal was largely influenced by callback-based tracking (onScopeDispose()), which does not run in SSR, and by behavior that appears to have been partially addressed by upstream fixes (e.g. async context handling).

My previous patch did improve behavior in practice, but it effectively introduced an explicit teardown phase and triggered onScopeDispose() during SSR, which conflicts with Vue’s current SSR semantics.


Interpretation

The most accurate interpretation seems to be:

  • there were real issues in earlier versions (especially around async context and request isolation),
  • the workaround was practically useful in that context,
  • but in the current state, what remains is primarily a lifecycle/semantics difference (SSR not triggering disposal callbacks), rather than a confirmed heap retention issue.

Open question

Given this, I’d be interested to clarify:

  • Is the absence of onScopeDispose() during SSR considered an intentional design choice?
  • Should this behavior be documented more explicitly?
  • Or is there any consideration for a request-scoped cleanup phase that remains internal (i.e. without triggering user-facing disposal callbacks)?

Additional observation

From experimenting with explicit scope cleanup, it seems that introducing an internal, request-scoped cleanup phase could potentially provide some practical benefits in certain scenarios:

  • more deterministic release of request-scoped reactive resources
  • reduced temporary accumulation and memory spikes under high SSR load
  • clearer ownership boundaries between requests
  • easier reasoning and debugging of SSR resource lifecycles

Of course, this would need to be carefully designed to avoid changing user-observable behavior (e.g. not triggering onScopeDispose()), but I thought it might be worth mentioning as an observation from real-world testing.

Thanks again for the earlier feedback — it helped narrow down the problem much more precisely.

@edison1105
Copy link
Copy Markdown
Member

@SaeedNezafat
Thanks for taking the time to revisit this more thoroughly. This update is very helpful.

Based on what you shared here, my understanding is that there is no longer strong evidence of a persistent SSR heap leak in the current setup. If that is the case, then this seems closer to an SSR lifecycle / semantics difference than to a confirmed memory leak.

On the open questions you raised, my current view is:

  1. onScopeDispose() not running during SSR seems more aligned with the current design semantics than with a simple missing bug fix.
    Vue’s SSR model does not currently treat render completion as a user-visible disposal / unmount phase, and most lifecycle hooks also do not run during SSR, with onServerPrefetch() being one of the few explicit exceptions.
    From that perspective, not triggering onScopeDispose() at the end of SSR render is consistent with the existing model.

  2. This behavior is worth documenting more explicitly.
    Especially since onScopeDispose() is a generic cleanup API, it is easy for users to assume that it would run when SSR render finishes, but that is not actually the case.

  3. As for an internal request-scoped cleanup phase, I think that is a valid direction to discuss separately, but it should be treated as a new and narrower design question.
    If such a mechanism is introduced in the future, it should only handle internal resource disposal without changing user-observable behavior, for example by not triggering onScopeDispose() or similar disposal callbacks.

Given the current conclusion, I still think the fix in #14548 is too broad, because using scope.stop() at the end of SSR render introduces additional user-observable behavior changes.

If there is still a real Vue-side SSR leak after #14445 and the async-context fixes, I think the better path would be to provide a minimal reproduction first and then design a narrower fix around that. Otherwise, I would lean toward reverting #14548 and discussing any potential internal request-scoped cleanup mechanism separately.

@bgarcias-cp
Copy link
Copy Markdown

Hello @edison1105

I have a minimal reproduction of a memory leak in vue 3.5.32
I've already created an issue, can you take a look?
#14706

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

Labels

ready to merge The PR is ready to be merged. scope: ssr

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants