Skip to content

Fix composable cleanup leaks in useReviewProposals, useGlobalSearch, usePaperReviewSelectors#1104

Merged
Chris0Jeky merged 7 commits into
mainfrom
fix/1094-composable-cleanup-leaks
May 29, 2026
Merged

Fix composable cleanup leaks in useReviewProposals, useGlobalSearch, usePaperReviewSelectors#1104
Chris0Jeky merged 7 commits into
mainfrom
fix/1094-composable-cleanup-leaks

Conversation

@Chris0Jeky
Copy link
Copy Markdown
Owner

Resolves #1094. (Re-opened from #1096, which GitHub auto-closed when its stacked base branch fix/1093 was deleted on merge of #1095. Rebased onto main; carries the same commits + both adversarial reviews.)

What

Adds onScopeDispose cleanup to three composables so timers, intervals, and in-flight AbortControllers are released when the owning scope tears down:

  • useReviewProposals — 60s clock interval (with idempotent startClock guard)
  • useGlobalSearch — debounce timer + AbortController, with finally writes guarded against post-disposal mutation
  • usePaperReviewSelectors — AbortController, with fetch-generation invalidation on dispose so aborted Promise.allSettled continuations don't write reactive state

Reviews

Two independent adversarial reviews were completed on #1096; all findings (3 HIGH, 3 MEDIUM, 1 LOW) addressed. See the consolidated review + fix-evidence comment below.

Verification

vue-tsc -b clean · eslint clean · 63/63 tests across the 3 specs (3 new disposal tests added).

Ensures the 60s expiry-detection clock is cleared when the composable's
effect scope is disposed, preventing leaked intervals.
Clears pending debounce timer and aborts in-flight search request when
the composable's effect scope is disposed.
Aborts in-flight deep review API requests when the composable's effect
scope is disposed, preventing orphaned network calls.
Verify useReviewProposals registers stopClock via onScopeDispose and
that calling the disposal callback clears the clock interval. Add
effectScope test for useGlobalSearch confirming debounce timer is
cleared on scope disposal.
Copilot AI review requested due to automatic review settings May 29, 2026 18:11
@Chris0Jeky
Copy link
Copy Markdown
Owner Author

Review record (carried from #1096)

This PR is the rebased continuation of #1096, which was auto-closed when its stacked base branch was deleted. Both independent adversarial reviews live there:

All findings addressed (verified post-rebase):

Finding Severity Fix
H1 — startClock no double-start guard (interval leak) HIGH early-return if clockInterval !== null
H2 — usePaperReviewSelectors reactive write after disposal (allSettled resolves post-abort) HIGH fetchGeneration++ in onScopeDispose so continuation short-circuits
H3 / M6 — useGlobalSearch finally writes loading/loadingMore post-disposal HIGH/MED isDisposed flag guards both finally blocks
M4 — no disposal test for usePaperReviewSelectors MED new abort-on-stop test asserting no write-back
M5 — useReviewProposals disposal test mock-only MED new startClock idempotency test
L7 — internal watchers not explicitly stopped LOW no change — scope-bound watchers auto-stop (documented Vue behavior)

Verification: vue-tsc -b clean · eslint clean on all 6 files · 63/63 tests across the 3 specs (3 new). Rebased onto current main; specs re-run green post-rebase.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request introduces lifecycle cleanup using onScopeDispose across several composables (useGlobalSearch, usePaperReviewSelectors, and useReviewProposals) to clear timers, abort in-flight requests, and prevent post-disposal state mutations, accompanied by comprehensive unit tests. The review feedback highlights that while these cleanups are beneficial, several asynchronous operations and their corresponding try/catch blocks in useReviewProposals and useGlobalSearch remain unguarded against post-disposal state writes and potential router navigation, and recommends adding isDisposed checks to fully secure these operations.

}
}

onScopeDispose(stopClock)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

While cleaning up the clock interval is a great improvement, useReviewProposals still lacks guards for its asynchronous operations (loadProposals, openProposalFromHash, and loadBoardOptions).\n\nIf the composable's owning scope is disposed while these requests are in-flight, they will still resolve and mutate reactive state (proposals.value, proposalsLoading.value, etc.). More critically, openProposalFromHash can trigger router navigation (safeReplace), which could unexpectedly redirect the user even after they have navigated away and the component has been unmounted.\n\nWe should introduce an isDisposed flag and use it to guard all post-await state writes and router actions.

  let isDisposed = false\n  onScopeDispose(() => {\n    isDisposed = true\n    stopClock()\n  })

hasMoreCards.value = false
} finally {
loading.value = false
if (!isDisposed) loading.value = false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

While guarding the finally block prevents loading.value from being updated after disposal, the reactive state writes inside the try and catch blocks are still unguarded.\n\nIf the network request resolves or rejects (with an error other than AbortError) after the scope is disposed, the callbacks will still execute and mutate boards.value, cards.value, error.value, etc.\n\nTo fully prevent post-disposal mutations, we should check isDisposed immediately after the await call and at the start of the catch block.

error.value = 'Failed to load more results.'
} finally {
loadingMore.value = false
if (!isDisposed) loadingMore.value = false
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

Similar to executeSearch, the try and catch blocks in loadMore are not guarded against post-disposal mutations. If the request resolves or rejects after the scope is disposed, reactive state like cards.value and error.value will still be mutated.\n\nPlease add if (isDisposed) return guards right after the await call and at the start of the catch block.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This PR addresses composable lifecycle cleanup leaks (timers, debounce timeouts, and AbortControllers) by registering disposal handlers via onScopeDispose, reducing the risk of memory/resource leaks when a component/effect scope is torn down.

Changes:

  • Add onScopeDispose(stopClock) and an idempotent guard to prevent leaked intervals in useReviewProposals.
  • Add onScopeDispose cleanup for debounce timers and in-flight requests in useGlobalSearch, plus new disposal-focused tests.
  • Add onScopeDispose abort + fetch-generation invalidation to prevent post-disposal reactive writes in usePaperReviewSelectors, plus a new disposal test.

Reviewed changes

Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
frontend/taskdeck-web/src/composables/useReviewProposals.ts Stops the 60s clock interval automatically on scope disposal and prevents double-start interval leaks.
frontend/taskdeck-web/src/composables/useGlobalSearch.ts Clears debounce timers and aborts in-flight searches on disposal; introduces disposal-guarding logic (needs adjustment for axios cancellation + state writes).
frontend/taskdeck-web/src/composables/usePaperReviewSelectors.ts Aborts in-flight deep review requests and invalidates fetch generation to prevent stale writes after disposal.
frontend/taskdeck-web/src/tests/composables/useReviewProposals.spec.ts Adds tests ensuring onScopeDispose registration and interval idempotency.
frontend/taskdeck-web/src/tests/composables/useGlobalSearch.spec.ts Adds effect-scope disposal tests for debounce cleanup and mid-search disposal behavior (needs to mirror axios cancellation).
frontend/taskdeck-web/src/tests/composables/usePaperReviewSelectors.spec.ts Adds effect-scope disposal test ensuring aborted in-flight results don’t write back.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@@ -1,4 +1,4 @@
import { ref, watch } from 'vue'
import { onScopeDispose, ref, watch } from 'vue'
Comment on lines 92 to +96
cards.value = []
totalCardCount.value = 0
hasMoreCards.value = false
} finally {
loading.value = false
if (!isDisposed) loading.value = false
Comment on lines 127 to 131
error.value = 'Failed to load more results.'
} finally {
loadingMore.value = false
if (!isDisposed) loadingMore.value = false
}
}
Comment on lines 1 to +2
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { nextTick } from 'vue'
import { effectScope, nextTick } from 'vue'
Comment on lines +316 to +324
// Mirror the real API: reject with AbortError once the signal aborts.
mockSearch.mockImplementation(
(_q: string, signal?: AbortSignal) =>
new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () =>
reject(new DOMException('Aborted', 'AbortError')),
)
}) as ReturnType<typeof searchApi.search>,
)
@Chris0Jeky Chris0Jeky merged commit 76b747a into main May 29, 2026
33 checks passed
@Chris0Jeky Chris0Jeky deleted the fix/1094-composable-cleanup-leaks branch May 29, 2026 18:26
@github-project-automation github-project-automation Bot moved this from Pending to Done in Taskdeck Execution May 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

FE: Missing cleanup and memory leak patterns in composables

2 participants