Skip to content

fix(client): avoid retaining onContentUpdated callbacks during ssr - #1722

Merged
Mister-Hope merged 1 commit into
vuepress:mainfrom
maoger:fix/client-ssr-content-updated
Sep 27, 2026
Merged

Mister-Hope merged 1 commit into
vuepress:mainfrom
maoger:fix/client-ssr-content-updated

Conversation

@maoger

@maoger maoger commented Sep 27, 2026

Copy link
Copy Markdown
Contributor

Before submitting the PR, please make sure you do the following

  • Read the Contributing Guidelines.
  • Provide a description in this PR that addresses what the PR is solving. If this PR is going to solve an existing issue, please reference the issue (e.g. close #123).

What is the purpose of this pull request?

  • Bug fix
  • New feature
  • Documentation update
  • Other

Description

Symptom

On large sites, vuepress build memory usage grows linearly with the number of pages during the "Rendering N pages" step, until the build runs out of memory.

Root cause

onContentUpdated adds the callback to the module-level contentUpdatedCallbacks Set and relies on onUnmounted to remove it. During build, all pages are rendered with the same server app (renderPageToString → renderToString), and components are never unmounted in SSR. So every callback registered while rendering stays in the Set until the process exits.

A callback closure usually captures component state (for example, vuepress-theme-hope's NavbarDropdown calls onContentUpdated(() => { open.value = false })), which keeps the component instance alive, and through parent / subTree the whole component and vnode tree of the rendered page.

Reproduction

  1. Use any component that calls onContentUpdated in setup, e.g. the e2e site's OnContentUpdated root component, or a theme like vuepress-theme-hope.
  2. Run vuepress build and inspect contentUpdatedCallbacks.size after rendering (or watch the heap during "Rendering N pages").

With a temporary log added to the built client, the e2e site (webpack bundler, 55 pages) ends the build with 55 retained callbacks before this change and 0 after it.

Measurements on a real site

1253 pages, vuepress@2.0.0-rc.31, vuepress-theme-hope@2.0.0-rc.109, Vite 8 (Rolldown), Node 24:

  • Before: during "Rendering 1253 pages" the JS heap grew linearly from ~1.3 GB to ~4.7 GB (~2.7 MB retained per page). In a build limited to 6 GB of memory (4 cores, no swap, simulated with a cgroup to match the target CI machine) it ran out of memory regardless of --max-old-space-size.
  • A heap snapshot taken after ~743 rendered pages contained 348,748 live component instances. For every sampled instance, the shortest non-weak retainer path went through the contentUpdatedCallbacks Set → callback closure → emit → component instance → parent / subTree.
  • After skipping the registration in SSR (applied in the site as an equivalent workaround: a Vite transform that makes onContentUpdated return early in the server bundle): the heap no longer grows while rendering (sawtooth around ~1 GB), the JS heap of the whole build peaks at ~1.4 GB, and the same 6 GB-limited build succeeds.

Fix

Return early from onContentUpdated when __VUEPRESS_SSR__ is true. The callbacks are only invoked from the onVnodeMounted / onVnodeUpdated / onVnodeBeforeUnmount hooks of <Content>, which never run in SSR. So registering them on the server has no effect other than retaining memory. Client-side behavior, including hydration, is unchanged.

Output

The generated HTML is unchanged. With the workaround above, 1409 of the 1410 HTML files of the real site are identical to the unpatched build after normalizing hashed asset names; the remaining one is a timeline page that only differs in the order of entries with the same date, which also differs between two unpatched builds. For the e2e site (webpack), the 55 HTML files are identical except for the hashed app.js file name; the content of app.js only differs in the order of entries in the generated route map, which is not affected by this change.

Verification

  • pnpm build, pnpm lint, pnpm check-types and pnpm test:unit pass.
  • With a temporary log of contentUpdatedCallbacks.size added to the built client, the e2e site (webpack bundler, 55 pages) ends the build with 55 retained callbacks before this change and 0 after it, and e2e/tests/composables/on-content-updated.spec.ts covers the unchanged client-side behavior.
  • No unit test is added, following the existing convention that the client package is covered by e2e tests; the retention across SSR renders is not observable from the e2e pages. Happy to add one if preferred.

Screenshots

N/A

`onContentUpdated` adds the callback to a module-level Set and relies on
`onUnmounted` to remove it. During `vuepress build`, every page is
rendered with the same server app, and components are never unmounted
in SSR, so every callback registered while rendering stays in the Set
until the process exits.

Each callback closure usually captures component state, which keeps the
component instance and, through `parent` / `subTree`, the whole
component and vnode tree of the rendered page alive. As a result, the
memory used by the build grows linearly with the number of pages, and
large sites may run out of memory while rendering.

Content updated callbacks are only invoked on client side, so skip the
registration in SSR. The rendered HTML is unchanged.
Copilot AI lite review requested due to automatic review settings September 27, 2026 04:14

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot review overview

🟢 Approval recommended

No unresolved issues were identified, and client-side behavior remains unchanged.

Review effort: Lite
Findings: None

What changed in this PR

Fixes SSR memory growth by preventing onContentUpdated callbacks from being retained during server-side rendering.

Changes:

  • Skips callback registration in SSR.
  • Preserves client-side callback behavior.
File Description
packages/​client/​src/​composables/​onContentUpdated.ts Avoids registering callbacks during SSR.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@Mister-Hope
Mister-Hope merged commit 7b284ba into vuepress:main Sep 27, 2026
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants