Skip to content

fix(vue-form): run the cleanup returned by FormApi.mount() on unmount - #2364

Open
ktx-kirtan wants to merge 2 commits into
TanStack:mainfrom
ktx-kirtan:fix/vue-form-mount-cleanup
Open

fix(vue-form): run the cleanup returned by FormApi.mount() on unmount#2364
ktx-kirtan wants to merge 2 commits into
TanStack:mainfrom
ktx-kirtan:fix/vue-form-mount-cleanup

Conversation

@ktx-kirtan

@ktx-kirtan ktx-kirtan commented Sep 1, 2026

Copy link
Copy Markdown

Problem

packages/vue-form/src/useForm.tsx does:

onMounted(formApi.mount)

FormApi.mount() returns a teardown function. Unlike React's useEffect, Vue ignores a value returned from onMounted, so that teardown is never invoked.

mount() registers three listeners on the shared formEventClient plus a store subscription:

// packages/form-core/src/FormApi.ts
this.mount = () => {
  const cleanupDevtoolBroadcast = this.store.subscribe(...)
  const cleanupFormStateListener = formEventClient.on('request-form-state', ...)
  const cleanupFormResetListener = formEventClient.on('request-form-reset', ...)
  const cleanupFormForceSubmitListener = formEventClient.on('request-form-force-submit', ...)
  const cleanup = () => { /* releases all four + emits 'form-unmounted' */ }
  ...
  return cleanup
}

Because formEventClient is a module-level singleton, those listeners live for the lifetime of the document. Every mounted form leaks three window listeners and a store subscription, and the listener closures capture the FormApi — and through this.options, the options object supplied by the component — so the component's scope is retained too. form-unmounted is also never emitted, so devtools never learns the form went away.

In an SPA that mounts a form on more than one route, this accumulates on every navigation.

Fix

Hold the teardown and call it from onUnmounted:

let cleanupMount: (() => void) | undefined

onMounted(() => {
  cleanupMount = formApi.mount()
})

onUnmounted(() => {
  cleanupMount?.()
  cleanupMount = undefined
})

react-form already gets this right — useIsomorphicLayoutEffect(formApi.mount, []) does honour the returned cleanup. This brings the Vue adapter in line with the same contract.

Tests

Added packages/vue-form/tests/useFormMountCleanup.test.tsx with two cases:

  • every window listener type registered during mount is removed on unmount
  • listeners do not accumulate across repeated mount/unmount cycles

Both fail on main and pass with this change.

How this was found

Profiling a Vue 3 SPA for a memory leak across route changes. Instrumenting addEventListener/removeEventListener and capturing construction stacks showed three window:form-devtools:* listeners accumulating per navigation, originating in FormEventClient.on. On the affected route this retained ~4,000 detached DOM nodes per navigation; applying this fix locally took that to 0.

Note the same discarded-cleanup pattern cannot be worked around by consumers: useForm captures formApi.mount by value when registering the hook, so wrapping mount on the returned instance afterwards has no effect. It has to be fixed here.

Summary by CodeRabbit

  • Bug Fixes

    • Improved form lifecycle cleanup by removing event listeners and subscriptions when form components are unmounted.
    • Prevented listener accumulation across repeated component mounts and unmounts.
  • Tests

    • Added coverage for cleanup after unmounting and repeated mount cycles.

`onMounted(formApi.mount)` discards the teardown function that
`FormApi.mount()` returns. Unlike React's `useEffect`, Vue ignores a value
returned from `onMounted`, so the three `formEventClient` listeners
(`request-form-state`, `request-form-reset`, `request-form-force-submit`)
and the devtools store subscription registered by `mount()` are never
released.

Every mounted form therefore leaks them for the lifetime of the document,
and because the listener closures capture the `FormApi` — and through it the
options object supplied by the component — the component's scope is retained
too. In a SPA that mounts a form on several routes this accumulates on every
navigation.

`react-form` already gets this right via
`useIsomorphicLayoutEffect(formApi.mount, [])`, which does honour the
returned cleanup; this brings the Vue adapter in line.
Covers the regression directly: every window listener the devtools event
client registers during FormApi.mount() must be removed when the component
unmounts, so repeatedly mounting a form does not accumulate listeners.
@coderabbitai

coderabbitai Bot commented Sep 1, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: 226978d2-31de-427c-a1d3-ed4b3559b371

📥 Commits

Reviewing files that changed from the base of the PR and between 57a855b and d45ad50.

📒 Files selected for processing (2)
  • packages/vue-form/src/useForm.tsx
  • packages/vue-form/tests/useFormMountCleanup.test.tsx

Included review availability: Your plan provides up to 8 included reviews per hour; 7 remain after this review.


📝 Walkthrough

Walkthrough

useForm now stores the teardown returned by formApi.mount() and runs it during Vue component unmount. New tests verify listener removal after unmount and across repeated mount cycles.

Changes

Vue form lifecycle cleanup

Layer / File(s) Summary
Mount teardown and cleanup validation
packages/vue-form/src/useForm.tsx, packages/vue-form/tests/useFormMountCleanup.test.tsx
useForm captures the teardown returned by formApi.mount() and invokes it from onUnmounted. Tests verify that mounted listeners are removed after unmount and do not accumulate across repeated mounts.

Estimated code review effort: 2 (Simple) | ~10 minutes

Merge Risk: ⚪ Minimal · up to d45ad

This change ensures Vue forms release their event listeners and subscriptions when unmounted, preventing cleanup-related memory leaks without altering normal form behavior. No actionable merge-blocking risk remains beyond normal checks and review.

🚥 Pre-merge checks | ✅ 4 | ❌ 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%. Docstring coverage is scoped to functions touched by this diff. Analyzed 1 functions across 2 files. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: invoking the cleanup returned by FormApi.mount() when the Vue component unmounts.
Description check ✅ Passed The description is detailed and directly addresses the problem, fix, motivation, test coverage, and observed impact. It does not use the template's exact Changes, Checklist, or Release Impact headings…
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description is detailed and directly addresses the problem, fix, motivation, test coverage, and observed impact. It does not use the template's exact Changes, Checklist, or Release Impact headings, and it does not explicitly confirm the checklist items or changeset status, but the required change information is otherwise substantially complete.

  • Fix all pre-merge checks with AI
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

ktx-kirtan added a commit to openobserve/openobserve that referenced this pull request Sep 1, 2026
patch-package binds a patch to one exact version, so upgrading a patched
package silently stops the patch matching. On the default settings that is
only a warning and the install still exits 0 — the memory-leak fixes in
web/patches would come back off without anyone noticing.

--error-on-warn makes any drift a non-zero exit, including the case where the
patch still applies cleanly to a newer version. Verified by renaming a patch
to a version that does not match: the install now exits 1 instead of 0.

The packages stay on caret ranges and upgrade normally. When one moves, the
install fails in the PR's CI checks naming the package, and the patch is
regenerated or dropped from there. Note the npm-update workflow itself only
runs `npm install --package-lock-only`, which skips scripts, so the red check
lands on the PR it opens rather than in the workflow log.

web/patches/README.md documents why each patch exists, what to do when one
stops applying — for @tanstack/vue-form the right answer may be deleting it,
since the fix is upstream in TanStack/form#2364 — and how to confirm a
regenerated patch still fixes the leak rather than merely applying.
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.

1 participant