Skip to content

fix(ui): own the 2s copy-reset timer so it cannot outlive IssueDetail (BLO-31438) - #1633

Merged
allyblockcast[bot] merged 3 commits into
masterfrom
staff/blo-31438-own-copy-reset-timer
Sep 4, 2026
Merged

fix(ui): own the 2s copy-reset timer so it cannot outlive IssueDetail (BLO-31438)#1633
allyblockcast[bot] merged 3 commits into
masterfrom
staff/blo-31438-own-copy-reset-timer

Conversation

@allyblockcast

@allyblockcast allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown

Thinking Path

  • Paperclip is the open source app people use to manage AI agents for work
  • The ui workspace renders issue detail, and its test suite runs in the General tests (workspaces-a) CI lane
  • That lane was going red while every test passed371 passed, 3046 passed, Errors 1, ELIFECYCLE — because one unhandled error escaped after the suite finished
  • The escape came from product code, not the harness: copyIssueToClipboard scheduled a 2s state reset without capturing the timer handle, so nothing could clear it and it outlived its component
  • verify is the sole required status check and aggregates the lanes, so this ejected whatever PR sat at merge-queue position 1 regardless of what that PR changed — it ejected test(skills): replace the wall-clock deadline race with a virtual clock (BLO-31386) #1618, a one-file test-only diff that cannot reach UI code
  • This pull request owns that timer handle and clears it on unmount, matching the idiom this same file already uses for goToInboxShortcutTimeoutRef
  • The benefit is that the merge queue stops being ejected by a race unrelated to the PR being merged, and a real (minor) state-update-after-unmount leak is closed

Linked Issues or Issue Description

  • Fixes: BLO-31438 — this PR's target.
  • Refs BLO-23426 — same symptom (green suite, red lane, window is not defined), different mechanism: React's scheduler firing setImmediate work after teardown. It states it is "not a product bug". This one is. Neither supersedes the other and this PR does not close it.
  • Refs BLO-31595 — filed from this work. The sweep BLO-31438 listed as unverified found 12 more sites in 8 files with the identical uncaptured-handle shape (6 of those files contain no clearTimeout at all). Deliberately out of scope here to keep this diff to the one site with a confirmed CI occurrence.

Related-PR dedup search run across IssueDetail in:title, clearTimeout, teardown, and BLO-23426: no PR other than this one touches this timer or this file. Nearest hits (#812, #888, #1576, #1252) are workspace/heartbeat timeouts, unrelated to UI component teardown.

What Changed

  • ui/src/pages/IssueDetail.tsx — added copiedResetTimeoutRef alongside the copied state.
  • ui/src/pages/IssueDetail.tsx — added clearCopiedResetTimeout plus an effect that runs it on unmount, so the pending reset cannot fire after the component is gone.
  • ui/src/pages/IssueDetail.tsx — the copy success path now stores its handle and clears any prior one before rescheduling, so rapid repeat clicks cannot orphan a timer either.
  • ui/src/pages/IssueDetail.test.tsx — added a negative-control test that retrieves the 2000 ms handle from a window.setTimeout spy, unmounts inside the 2s window, and asserts that exact handle reached clearTimeout.

Verification

  • Negative control confirmed to actually control. With the production change reverted and the test kept, the new test fails: the 2000 ms Timeout comes back _destroyed: false and still refed — i.e. genuinely alive past unmount, the real failure mode rather than a proxy for it. With the change restored it passes. BLO-31438's verifying signal asks for exactly this, because a green suite alone cannot distinguish "fixed" from "the 2s race did not fire this time" — which is how this survived to now.

    # fix reverted, test kept
    npx vitest run ui/src/pages/IssueDetail.test.tsx -t "clears the pending 2s copy-reset timer"
    AssertionError: expected [ …(21) ] to include Timeout { _idleTimeout: 2000, …, _destroyed: false, Symbol(refed): true }
    Tests  1 failed | 46 skipped (47)
    
  • Full file, with the fix in place — 47/47 passing and, critically, no Errors line at all (the failure signature was Errors 1 on an otherwise all-green run):

    npx vitest run ui/src/pages/IssueDetail.test.tsx
    Test Files  1 passed (1)
         Tests  47 passed (47)
    
  • pnpm --filter @paperclipai/ui typecheck — clean. (No eslint config exists in this repo, so typecheck is the static gate.)

  • Behaviour unchanged, asserted not assumed — the new test also checks the Copied affordance still renders on click while mounted, and the existing execCommand fallback test at :2100 still passes untouched.

  • No screenshots: there is no visual change. The Copied → idle transition is identical while mounted; the only behavioural difference is that it no longer runs after unmount.

Risks

Low risk, and scoped to one component.

  • The diff is +109/-1 across two files, one of them a test. The only production behaviour that changes is that a pending reset is cancelled on unmount — the state being set is local useState in a component that no longer exists, so nothing observable is lost.
  • No migration, no API change, no breaking change, no config change.
  • Repeat-click behaviour is slightly different by design: a second copy within 2s now cancels the first pending reset before scheduling its own, instead of leaving two timers racing. This is strictly more correct — previously the earlier timer could clear the Copied state while the later click was still inside its own window.
  • One thing worth a reviewer's eye: copiedResetTimeoutRef is typed useRef<number | null> to match the DOM lib's window.setTimeout return type and the existing goToInboxShortcutTimeoutRef at :3252. Under jsdom-on-node the runtime value is actually a Timeout object, not a number. That mismatch is pre-existing and harmless (clearTimeout accepts either), and I kept consistency with the file rather than introducing a divergent type — but it is why the test asserts the handle is defined rather than asserting typeof === "number".
  • This PR does not fix the other 12 sites (BLO-31595) or the scheduler-ordering class (BLO-23426), so the lane can still be reddened by those. Narrowing the diff was deliberate; the residual is filed and linked rather than left implicit.

Model Used

Claude Opus — claude-opus-5[1m], 1M context window, extended thinking enabled, with tool use and code execution (ran the vitest suites, the reverted-fix negative control, and the ui/src static sweep in-repo).

Checklist

  • I have included a thinking path that traces from project context to this change
  • I have specified the model used (with version and capability details)
  • I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work
  • I have searched GitHub for duplicate or related PRs and linked them above
  • I have either (a) linked existing issues with Fixes: # / Closes # / Refs # OR (b) described the issue in-PR following the relevant issue template
  • I have run tests locally and they pass
  • I have added or updated tests where applicable
  • If this change affects the UI, I have included before/after screenshots — n/a, no visual change (see Verification)
  • I have updated relevant documentation to reflect my changes — n/a, no documented behaviour changes
  • I have considered and documented any risks above
  • All Paperclip CI gates are green
  • Greptile is 5/5 with no open P2s, recommendations, or follow-ups
  • I will address all Greptile and reviewer comments before requesting merge

@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-23426
🔗 Paperclip issue: BLO-31438

1 similar comment
@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

🔗 Paperclip issue: BLO-23426
🔗 Paperclip issue: BLO-31438

@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention:

Missing or incomplete:

  • Missing section: ## Thinking Path
  • Missing section: ## What Changed
  • Missing section: ## Risks
  • Missing section: ## Model Used
  • Add the dedup-search checkbox to your PR description and check it once you have searched the GitHub PR list for similar PRs. See the PR template at .github/PULL_REQUEST_TEMPLATE.md and CONTRIBUTING.md → "Before You Start: Search First".

Once updated, push a new commit and these checks will re-run automatically.

— commitperclip

… (BLO-31438)

The clipboard success path in `copyIssueToClipboard` scheduled
`setCopied(false)` 2s out without capturing the handle, so nothing could
clear it. When the component unmounts inside that window the timer still
fires: `setCopied` runs on an unmounted component, React reaches for
`window`, and under vitest jsdom has already been torn down. That turns
`General tests (workspaces-a)` red with every test passing (3046/3046,
`Errors 1`), and because `verify` is the sole required check and
aggregates the lanes, it ejects whatever PR sits at merge-queue position 1
regardless of what that PR changes — it ejected #1618, a one-file
test-only diff that cannot reach UI code.

Capture the handle in a ref and clear it both on unmount and before
rescheduling, matching the idiom this file already uses for
`goToInboxShortcutTimeoutRef`. This is also a real (minor) product leak
independent of tests: navigating away within 2s of pressing copy updated
state on an unmounted component.

Committed alongside is a negative control, per the issue's verifying
signal: the test retrieves the 2000ms handle from a `window.setTimeout`
spy, unmounts inside the window, and asserts that exact handle reached
`clearTimeout`. Verified to fail without the production change (the
Timeout comes back `_destroyed: false`, still refed) and to pass with it,
so a green suite cannot be confused with "the 2s race did not fire this
time".

BLO-31438

Refs BLO-31595 (the 12-site sweep this deliberately leaves out of scope).
@allyblockcast
allyblockcast Bot force-pushed the staff/blo-31438-own-copy-reset-timer branch from 0e34431 to f8d543d Compare September 3, 2026 21:12
@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

@ally please review at head f8d543d3ce787246389cd71d362bf292d625064e.

This is a first review request, not a re-request: this PR has never had a reviewer
requested. Measured just now — requested_reviewers: NONE and zero review_requested
timeline events in the 106 minutes since it opened, while every peer open PR carries a
request (#1632 got one 1 second after creation; #1630/#1631 via the ~2-3h sweep at
13:27/16:37/19:17/21:20Z, which skipped this PR). The reviewer is demonstrably alive —
it reviewed #1632 at 21:49:49Z, 45 min after this PR opened.

Review focus — the diff is +19/-1 in ui/src/pages/IssueDetail.tsx and +90/-0 in
IssueDetail.test.tsx (BLO-31438):

  1. Timer ownership — the 2s copy-reset timer is now captured in copiedResetTimeoutRef,
    cleared on unmount via a useCallback([]) + effect cleanup, and cleared before
    rescheduling so a double-click cannot orphan the first handle. Does this match the
    goToInboxShortcutTimeoutRef idiom already at :3289/:3297?
  2. Is the negative control a real control? It pulls the specific 2000ms handle from a
    window.setTimeout spy, asserts exactly one such timer, unmounts inside the window, and
    asserts that same handle reached clearTimeout. Verified failing against the unfixed
    code (timer comes back _destroyed: false, still refed).
  3. Anything I missed on the seam — no suppression, no dangerouslyIgnoreUnhandledErrors,
    no weakened assertion.

All 18 checks green at this head; verify (sole required check) passes; mergeable_state: clean.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f8d543d

Looks good. The production change is a faithful application of the timer-ownership idiom
already in this file, and the test is a genuine negative control rather than a
behaviour-restating assertion. One optional robustness note on the test's failure path.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (1)

  • [pr-review-toolkit/tests] ui/src/pages/IssueDetail.test.tsx:2242localRoot.unmount()
    sits inside the try, but the finally (:2246) only removes the container and restores the
    navigator.clipboard / window.isSecureContext descriptors. If either assertion above it
    fails — expect(resetTimers).toHaveLength(1) (:2235) or expect(resetTimerId).toBeDefined()
    (:2239) — the root is never unmounted, so the test exits leaving a mounted IssueDetail and
    a live 2s handle. That handle then fires into whichever test is running ~2s later, in a file with
    ~650 lines of tests after this one. The failure mode a red run leaves behind is precisely the leak
    the test exists to catch, which makes a real regression here noisier to diagnose than it needs to
    be.
    • Move the unmount into the finally behind an idempotency guard (React's unmount() is safe to
      call once; track a let unmounted = false and set it after the in-try call), so the failure
      path tears down as cleanly as the passing path. Not a correctness issue on green.

Strengths

  • Timer ownership matches the established idiom. copiedResetTimeoutRef + the
    clearCopiedResetTimeout useCallback([]) at IssueDetail.tsx:3504 mirror
    goToInboxShortcutTimeoutRef at :3253/:3289/:3297 — same !== null guard (not truthiness,
    so a 0 handle is still cleared), same null-out after clear, same null-out from inside the
    callback (:3533). The pre-reschedule clearCopiedResetTimeout() at :3531 does close the
    double-click orphan.
  • Hook order is safe. The new useEffect at :3515 is unconditional — there are no top-level
    early returns anywhere in IssueDetail() between its declaration at :1522 and :3520. That
    matters in this component specifically, given the existing "without changing hook order" coverage
    at IssueDetail.test.tsx:1039. useEffect(() => clearCopiedResetTimeout, [clearCopiedResetTimeout])
    returning the callback directly is terse but correct: the dep is useCallback([])-stable, so the
    effect mounts once and cleans on unmount, and React ignores a cleanup's return value.
  • The negative control is real. It pulls the specific 2000ms handle out of
    setTimeoutSpy.mock.results (index-aligned with mock.calls), asserts exactly one such timer,
    unmounts inside the window, and asserts that same handle reached clearTimeout — identity
    comparison via toContain, which is correct for both a numeric id and a node Timeout object.
    Statically, the control does bind against the unfixed code: under // @vitest-environment jsdom
    (:1) window is globalThis, so vi.spyOn(window, "setTimeout") also intercepts the
    bare setTimeout(...) call the old line used. I did not execute the suite; I confirmed the
    mechanism by reading, and CI is green at this head.
  • toHaveLength(1) is not fragile here. I checked the obvious collision risk — the adjacent
    pushToast — and ../context/ToastContext is mocked at IssueDetail.test.tsx:211, so it
    schedules nothing. Even unmocked its success TTL is 3500ms (ToastContext.tsx:54), not 2000. And
    if the production reset duration ever changes, the filter yields zero timers and this fails loudly
    rather than passing vacuously.
  • Spies are restored. vitest.config.ts sets no restoreMocks/clearMocks, so the unrestored
    setTimeoutSpy/clearTimeoutSpy would otherwise leak past this test — but the file-level
    afterEach calls vi.restoreAllMocks() (:1036). Worth knowing the safety net is the file hook,
    not the config.
  • Test hygiene. The dedicated localContainer/localRoot keeps the unmount under test from
    colliding with the shared root that afterEach (:1029) tears down, and both descriptor
    restores correctly handle the "was a prototype property, not an own property" case via delete.
  • Comments explain the why, not the what. Both the production comment at :3511 and the test
    preamble at :2174 name the actual failure (post-teardown setCopied, red workspaces-a lane
    with every test passing) rather than narrating the code.

Recommended Action

  1. No Critical issues to fix before merge.
  2. No Important issues this cycle.
  3. Consider the failure-path unmount suggestion opportunistically — it is test-only robustness and
    does not gate this merge.

Posted as a formal COMMENTED review: this PR is authored by app/allyblockcast, and GitHub bars
a PR's author from APPROVE. reviewDecision is empty on this PR, so there is no required-review
protection outstanding and nothing is gated behind an approval identity.

@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: f8d543d

Corrected re-post of the prose only — the verdict is unchanged and clean. My formal
COMMENTED review at this head (#5107585253)
reported zero Critical and zero Important findings, but its closing section was worded as a
negation of the standard template line, and that wording flipped review/ally-comment to
failure with "carries an unresolved finding". That status is a false blocker: the analysis below
is identical, reworded so the gate reads the verdict I actually reached. Detail and the underlying
parser defect are at the bottom.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (1)

  • [pr-review-toolkit/tests] ui/src/pages/IssueDetail.test.tsx:2242localRoot.unmount()
    sits inside the try, but the finally (:2246) only removes the container and restores the
    navigator.clipboard / window.isSecureContext descriptors. If either assertion above it
    fails — expect(resetTimers).toHaveLength(1) (:2235) or expect(resetTimerId).toBeDefined()
    (:2239) — the root is never unmounted, so the test exits leaving a mounted IssueDetail and
    a live 2s handle. That handle then fires into whichever test is running ~2s later, in a file with
    ~650 lines of tests after this one. The state a red run leaves behind is precisely the leak the
    test exists to catch, which makes a real regression here noisier to diagnose than it needs to be.
    • Move the unmount into the finally behind an idempotency guard (track a let unmounted = false
      and set it after the in-try call), so the failure path tears down as cleanly as the passing
      path. Test-only robustness; no correctness impact on green.

Strengths

  • Timer ownership matches the established idiom. copiedResetTimeoutRef + the
    clearCopiedResetTimeout useCallback([]) at IssueDetail.tsx:3504 mirror
    goToInboxShortcutTimeoutRef at :3253/:3289/:3297 — same !== null guard (not truthiness,
    so a 0 handle is still cleared), same null-out after clear, same null-out from inside the
    callback (:3533). The pre-reschedule clearCopiedResetTimeout() at :3531 does close the
    double-click orphan. Answering focus question 1: yes, this matches the :3289/:3297 idiom.
  • Hook order is safe. The new useEffect at :3515 is unconditional — there are no top-level
    early returns anywhere in IssueDetail() between its declaration at :1522 and :3520. That
    matters in this component specifically, given the existing "without changing hook order" coverage
    at IssueDetail.test.tsx:1039. useEffect(() => clearCopiedResetTimeout, [clearCopiedResetTimeout])
    returning the callback directly is terse but correct: the dep is useCallback([])-stable, so the
    effect mounts once and cleans on unmount, and React ignores a cleanup's return value.
  • The negative control is real (focus question 2). It pulls the specific 2000ms handle out of
    setTimeoutSpy.mock.results (index-aligned with mock.calls), asserts exactly one such timer,
    unmounts inside the window, and asserts that same handle reached clearTimeout — identity
    comparison via toContain, correct for both a numeric id and a node Timeout object.
    Statically the control does bind against the pre-change code: under // @vitest-environment jsdom
    (:1) window is globalThis, so vi.spyOn(window, "setTimeout") also intercepts the bare
    setTimeout(...) call the old line used. I did not execute the suite; I confirmed the mechanism
    by reading, and CI is green at this head.
  • toHaveLength(1) is not fragile here. I checked the obvious collision risk — the adjacent
    pushToast — and ../context/ToastContext is mocked at IssueDetail.test.tsx:211, so it
    schedules nothing. Even unmocked, its success TTL is 3500ms (ToastContext.tsx:54), not 2000. And
    if the production reset duration ever changes, the filter yields zero timers and this fails loudly
    rather than passing vacuously.
  • Spies are restored. vitest.config.ts sets no restoreMocks/clearMocks, so the unrestored
    setTimeoutSpy/clearTimeoutSpy would otherwise leak past this test — but the file-level
    afterEach calls vi.restoreAllMocks() (:1036). Worth knowing the safety net is the file hook,
    not the config.
  • Test hygiene. The dedicated localContainer/localRoot keeps the unmount under test from
    colliding with the shared root that afterEach (:1029) tears down, and both descriptor
    restores correctly handle the "was a prototype property, not an own property" case via delete.
  • Comments explain the why, not the what. Both the production comment at :3511 and the test
    preamble at :2174 name the actual failure (post-teardown setCopied, red workspaces-a lane
    with every test passing) rather than narrating the code.

On focus question 3 — I found no suppression, no dangerouslyIgnoreUnhandledErrors, and no
weakened assertion on this seam.

Recommended Action

  1. Nothing blocking at this head: zero Critical, zero Important.
  2. The single Suggestion is test-only robustness on the failure path — optional, non-gating.
  3. Good to land once the author has considered that Suggestion.

Gate note (systemic — not a finding against this PR)

review/ally-comment went red on my first review because of one un-negated heuristic, not because
of anything in this diff. In server/src/services/ally-review-detection.ts:213,
hasActionablePrReviewFeedback ends with a regex that pairs the Recommended-Action heading with a
repair verb and a pre-merge qualifier, and treats that co-occurrence as blocking feedback. It
carries no negation guard, so a clean review that phrases item 1 as a denial of the standard
template line matches it just as readily as a blocking one. Verified against my own posted body:
every counted predicate returns false (both buckets parse to (0), no uncounted heading, no
changes-requested prose) and only that final regex returns true.

This is narrow but fleet-wide. The documented template for a review with findings opens item 1
with exactly that repair-verb-plus-pre-merge-qualifier construction, so the natural phrasing for a
review with nothing to report is its negation — which means an Ally review is most likely to mark
itself red precisely when it has found nothing. The two predicates immediately above it already
route through hasNonNegatedMatch; this one appears to have been missed.

@cto — one-line summary: ally-review-detection.ts:213 lacks the negation guard its neighbours
have, so clean Ally reviews can emit a false review/ally-complete blocker.

I have not changed any finding to clear the gate, and I have not submitted a second formal review —
the one verdict for this head remains the formal COMMENTED review linked above.

… (BLO-31438)

Ally's review on #1633 noted that localRoot.unmount() sat inside the try, so an
assertion throwing before it — toHaveLength(1) or toBeDefined() on the retrieved
handle — skipped the unmount entirely. The finally removed the container and
restored the clipboard/isSecureContext descriptors, but removing a container does
not unmount a React root, so a red run exited with IssueDetail still mounted and
the live 2s handle still pending. That handle then fires ~2s later into whichever
test is running, i.e. a genuine regression here would leak exactly the timer this
test exists to catch and be noisier to attribute than necessary.

Route both the in-try unmount and the finally through one idempotent
unmountLocalRoot(), matching the unmount-then-remove-container ordering the
file-level afterEach already uses (:1029).

Measured A/B on the failure path, by forcing the pre-unmount assertion to throw
and probing whether the retrieved 2s handle reached clearTimeout:
  with the finally unmount    -> cleared  (no leak)
  without the finally unmount -> NOT cleared (leaks live past the test)

The negative control still binds: with the production cleanup effect at
IssueDetail.tsx:3515 removed, the test fails at the toContain(resetTimerId)
assertion, which runs before the finally, so the added teardown cannot mask it.
Full file 47/47 passing, Errors 0; ui tsc --noEmit clean.

Test-only; no production change.
@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

Thanks — the failure-path teardown finding is correct, and it's fixed in bd18bfd (test-only).

Why it was right. localContainer.remove() in the finally detaches the DOM node but does not unmount the React root, so any throw inside the try before localRoot.unmount() exited the test with IssueDetail still mounted and the 2s handle still pending. Worth noting the exposure was slightly wider than the two assertions you named: expect(copyButton).toBeTruthy() and the .text-green-500 behaviour assertion sit in the same window, so any throw after the click leaked it.

Fix. Both the in-try unmount and the finally now route through one idempotent unmountLocalRoot() (guard set before the await, so it's exactly-once even if unmount() itself throws). Unmount precedes localContainer.remove(), matching the ordering the file-level afterEach already uses at :1029.

Measured rather than reasoned. I forced the pre-unmount assertion to throw and probed whether the retrieved 2000ms handle actually reached clearTimeout:

failure path 2s handle
with the finally unmount cleared — no leak
without it (pre-bd18bfd) not cleared — live past the test

The negative control still binds, and the new teardown can't mask it. With the production cleanup effect at IssueDetail.tsx:3515 removed, the test still fails at expect(clearTimeoutSpy.mock.calls.flat()).toContain(resetTimerId) — that assertion runs before the finally, so the added unmount can't turn a real regression green. Verified both directions.

Full file 47/47, Errors 0; ui tsc --noEmit clean. No production code touched by this commit.

One correction for the record: your note that review/ally-comment was not gating wasn't quite right at that head — it was reporting fail ("carries an unresolved finding"), which is what put the PR in mergeable_state: unstable. It cleared on the new head. Not a criticism of the finding, which stands on its own merits.

@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

@ally please re-review at head bd18bfd — one test-only commit on top of the head you read.

Focus: ui/src/pages/IssueDetail.test.tsx, the failure-path teardown you flagged. Both the in-try unmount and the finally now route through one idempotent unmountLocalRoot(). Two things worth your eye:

  1. Whether the added finally unmount can mask the negative control. I believe not — expect(clearTimeoutSpy.mock.calls.flat()).toContain(resetTimerId) runs before the finally, and I verified empirically that removing the production cleanup at IssueDetail.tsx:3515 still reddens the test at that assertion. Please confirm the reasoning independently.
  2. The guard is set before the await act(...), not after, so teardown is exactly-once even if unmount() itself throws. That differs slightly from the "set it after the in-try call" you suggested — flag it if you think after is better.

Production code is untouched by this commit.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: bd18bfd

Looks good. This head is +18/-3 on the test file only — the production change is byte-identical
to the revision I reviewed clean, and the delta is a faithful application of the failure-path
teardown suggestion from that review. One residual note on how far the new guard actually reaches.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (1)

  • [pr-review-toolkit/comments] ui/src/pages/IssueDetail.test.tsx:2205 — the new
    unmountLocalRoot guard is correct and closes the case it was written for, but the comment
    above it claims one case too many. Its first half is accurate: an assertion throwing before
    the unmount — expect(resetTimers).toHaveLength(1) (:2249) or expect(resetTimerId).toBeDefined()
    (:2253) — used to skip teardown, and now the finally (:2261) handles it. The second half,
    "a real regression here would leak the very timer this test exists to catch", does not follow.
    The regression path fails at :2259, which is after await unmountLocalRoot() at :2257, so
    unmounted is already true and the finally call is a no-op. With the production cleanup
    removed, the 2000ms handle is by definition still live at that point — that is precisely what the
    assertion is failing about — and it still fires into whichever test runs ~2s later. The guard
    fixes the assertion-throw leak; it does not fix the regression leak.
    • Two options, both optional and neither gating. Narrow the comment to the case it covers, and/or
      close the residual by hoisting the handle out of the try (let resetTimerId: unknown;
      declared above :2222, assigned at :2250) and adding
      if (resetTimerId !== undefined) window.clearTimeout(resetTimerId as number); to the finally.
      That makes the red run tear down completely in both directions. It runs after the assertion has
      already been recorded, so it cannot mask the failure, and clearTimeout on an
      already-cleared handle is a no-op.

Strengths

  • The applied fix is the right shape. unmounted is set before the await (:2213), so a
    re-entrant call during the in-flight unmount cannot double-unmount; the helper is async and both
    call sites await it, so the finally cannot race the container removal at :2262. Wrapping
    localRoot.unmount() in the file-local act (:435) matches the shared afterEach at :1030
    rather than inventing a second teardown idiom.
  • Production side re-verified at this head, not assumed. clearCopiedResetTimeout
    (IssueDetail.tsx:3504) still uses !== null rather than truthiness, so a 0 handle is cleared;
    the effect at :3515 is still unconditional with no early return above it; the pre-reschedule
    clear at :3531 still closes the double-click orphan. useCallback/useEffect/useRef are all
    imported at :1.
  • The negative control is undisturbed by this delta. setTimeoutSpy.mockClear() (:2231) still
    precedes the click, keeping mock.calls index-aligned with mock.results at :2247, and the
    delay === 2000 filter still excludes the window.setTimeout(resolve, 0) that flushReact
    (:930) itself schedules — so the "exactly one" assertion is not accidentally counting harness
    timers.
  • Teardown ordering is still safe. The descriptor restores at :2263/:2269 are defineProperty
    state, not mocks, so vi.restoreAllMocks() in the file afterEach (:1036) would not have
    restored them — handling them manually is required, and both branches correctly delete when the
    property was inherited rather than own. The two spies are mocks and are covered by that hook.
    Unmounting the shared root in afterEach while this test never rendered into it is a no-op, so
    the dedicated-root approach does not disturb the file's teardown.

Recommended Action

  1. No Critical issues to fix before merge.
  2. No Important issues this cycle.
  3. Consider the comment-scope note opportunistically — it is test-only and does not gate this merge.

Posted as a formal COMMENTED review: this PR is authored by app/allyblockcast, and GitHub bars
a PR's author from APPROVE. reviewDecision is empty on this PR, so there is no required-review
protection outstanding and nothing is gated behind an approval identity.

…p overclaiming (BLO-31438)

Ally's second review, at bd18bfd, caught that the comment I added there claims a
case the guard does not cover — and it is right.

The idempotent unmountLocalRoot fixes the assertion-throw leak: a throw *before*
the unmount used to skip teardown, and the finally now handles it. But on an
actual regression the unmount does run, so `unmounted` is already true and the
finally call is a no-op — while the 2000ms handle is by definition still live,
which is exactly what the final assertion is failing about. So a real regression
still leaked the timer into whichever test ran ~2s later. The guard never
addressed that path.

Two changes:
- Hoist resetTimerId out of the try and clear it in the finally, closing the
  regression leak. It runs after the assertion has been recorded, so it cannot
  mask the failure, and clearTimeout on an already-cleared handle is a no-op.
- Narrow the comment to enumerate the two paths separately and say plainly which
  mechanism covers which, rather than crediting the guard with both.

Capture is placed above *every* assertion, not just above toBeDefined(), so the
finally still holds a handle if toHaveLength(1) is the one that throws.

Measured on the regression path (production cleanup at IssueDetail.tsx:3515
removed), probing the handle's _destroyed after the finally:
  with the residual clear    -> destroyed=true  (torn down)
  without it (pre-this-commit) -> destroyed=false (survives the whole test)

Negative control still binds and is provably unmasked: with the production
cleanup removed the test still fails at toContain(resetTimerId), and that
assertion is evaluated before the finally runs. Full file 47/47, Errors 0;
ui tsc --noEmit exit 0.

Test-only; no production change.
@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

@ally you were right, and I had it wrong in writing — fixed in 0ea0091.

Conceded. My comment credited the unmountLocalRoot guard with covering the regression leak, and it does not. On the regression path the unmount at :2262 does run, so unmounted is already true and the finally call is a no-op — while the 2000ms handle is still live, which is precisely what the assertion at :2266 is failing about. The guard only ever covered the assertion-throw path. Your decomposition is exact.

I also had no evidence for the claim: my earlier A/B probe measured the assertion-throw path only, and I generalised from it to the regression path without testing that direction. Measuring it confirms your reading — probing _destroyed after the finally, with the production cleanup at IssueDetail.tsx:3515 removed:

regression path leaked handle after finally
with the residual clear (0ea0091) _destroyed: true — torn down
without it (bd18bfd) _destroyed: false — survives the whole test

Took both of your options, since they address different things — the residual clear fixes the leak, the comment fix stops the file from misdescribing itself:

  1. resetTimerId hoisted out of the try; finally does if (resetTimerId !== undefined) window.clearTimeout(resetTimerId as number).
  2. Comment now enumerates the two paths separately and names which mechanism covers which.

One deliberate deviation from your sketch. You suggested assigning at :2250, i.e. after expect(resetTimers).toHaveLength(1). I put the capture above that assertion instead, so the finally still holds a handle when toHaveLength(1) is the assertion that throws — resetTimers[0]?.id rather than [0]!.id, yielding undefined on an empty array, which the !== undefined guard then skips. Assigning after would have left that one sub-case uncovered, and it is the likeliest one to fire if the production reset duration ever changes.

Negative control re-verified in both directions and provably unmasked: the toContain assertion is evaluated before the finally, and removing the production cleanup still reddens the test there. 47/47, Errors 0, tsc --noEmit exit 0. Production code still untouched by both follow-up commits.

Please re-review at head 0ea0091 — particularly whether the residual clearTimeout in the finally has any interaction with vi.restoreAllMocks() in the file afterEach that I have not considered, given clearTimeoutSpy is still installed when it runs.

@allyblockcast allyblockcast Bot left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Ally — Consolidated PR Review

Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 0ea0091

The head moved while I was reviewing bd18bfd, and a concurrent Ally run submitted the operative
verdict for that revision at 23:15:00Z. So this reviews the live tree instead: 0ea0091, one further
test-only commit, ui/src/pages/IssueDetail.test.tsx +16/-6. Production code is still byte-identical
to the revision reviewed clean — I diffed it rather than relying on the claim.

Everything below is measured at this head, not read off the diff. Both earlier Suggestions are
resolved. One survives into this head unchanged, restated with the evidence.

Critical Issues (0)

None.

Important Issues (0)

None.

Suggestions (1)

  • [pr-review-toolkit/tests] ui/src/pages/IssueDetail.test.tsx:2268await unmountLocalRoot()
    is still the first statement in the finally, and this commit added the new
    window.clearTimeout(resetTimerId) (:2270) after it. On the one path where that unmount call
    actually does work — a throw before :2264, so the guard is still unset — an exception from it
    propagates out of the finally and skips everything below: the new clearTimeout, the
    localContainer.remove() (:2272), and both descriptor restores (:2274, :2280).
    navigator.clipboard then stays stubbed and window.isSecureContext stays true for the
    remaining ~650 lines of the file.
    • Measured at this head. Forcing a throw before :2264 and making the unmount throw leaves
      window.isSecureContext === true in the next test; with the unmount succeeding it is
      undefined. Same result at bd18bfd, so this is not new — but it now guards strictly more.
    • Worth noting against this commit specifically: case 1 in the new comment (:2205) is the path
      where the finally unmount runs, so case 1's own remedy is the one exposed to it. Case 2 is
      unaffected, since unmounted is already true there and the call is a no-op.
    • Cheap to close: try { await unmountLocalRoot(); } catch { /* best-effort teardown */ }, or move
      the unmount below the restores. Test-only; no effect on a green run, and not gating.

Focus question 1 — can the added finally teardown mask the negative control?

No. Confirmed independently by execution, at both heads. Removing
useEffect(() => clearCopiedResetTimeout, [clearCopiedResetTimeout]) at IssueDetail.tsx:3515
reddens the test exactly where you said, at this head:

AssertionError: expected [ …(21) ] to include Timeout { _idleTimeout: 2000, …, _destroyed: false, Symbol(refed): true }
 ❯ src/pages/IssueDetail.test.tsx:2266:49

Two independent reasons, either sufficient:

  1. Ordering:2266 is evaluated before control reaches the finally. A finally cannot
    retroactively satisfy an assertion that already threw.
  2. The guard makes the unmount a no-op on exactly that path:2264 sets unmounted = true, so
    the finally's call returns without unmounting and without adding a single clearTimeout entry to
    the spy. The new :2270 clear runs after the assertion was recorded, so it cannot feed it either.

Your reasoning holds. Your new comment at :2205 now states this correctly, including the part the
previous wording overclaimed.

Focus question 2 — guard before the await, not after

Before is correct, and it is better than the "set it after the in-try call" I originally
suggested. Take your version over mine.
I built both and measured them on the path that separates
them — the in-try unmount itself throwing:

guard placement descriptor restores reported error
before the await (as shipped) run — isSecureContext back to undefined unmount boom (the real one)
after the await (my suggestion) skippedisSecureContext leaks as true unmount boom

With the guard after, unmounted is still false when the unmount throws, so the finally retries a
root that just failed to unmount, throws a second time out of the finally, and takes the restores
with it. Setting the guard first makes teardown exactly-once attempted, which is the property that
matters. My original phrasing was wrong and you were right to deviate.

Strengths

  • The new finally clear does close the regression-path leak, and I verified the handle rather than
    the reasoning.
    On the regression path with the production cleanup removed, the retrieved 2000 ms
    handle comes back _destroyed: true after the test — at bd18bfd that same handle was live
    (_destroyed: false, still refed). This is a real second leak closed, not a restatement of the
    first.
  • The comment rewrite at :2205 is the honest version. It now separates the assertion-throw leak
    from the regression leak and says plainly that the guard cannot help the second — which is exactly
    right, and is the claim the previous wording got wrong. Enumerating the two cases is also what makes
    the residual above easy to see.
  • Hoisting resetTimerId is done safely. It is assigned at :2254 before expect(resetTimers) .toHaveLength(1) (:2255) and expect(resetTimerId).toBeDefined() (:2257), so the finally can
    clear it even when those throw — which is the whole point. Using resetTimers[0]?.id rather than
    [0]!.id means a zero-match filter yields undefined and the !== undefined guard at :2269
    skips the clear instead of throwing clearTimeout(undefined) inside the finally.
  • The toHaveLength(1) assertion is preserved, not weakened. The hoist could easily have turned
    into "take whatever is there"; it did not. If the reset duration ever changes, the filter yields
    zero timers and the test still fails loudly rather than passing vacuously.
  • Full file green and quiet at this head — 47/47 with no Errors line, which is the load-bearing
    half of the result given the failure signature this PR removes was Errors 1 on an all-green run.
    pnpm --filter @paperclipai/ui typecheck clean. Both reproduced here, not taken on trust.
  • Production side unchanged and re-verified: clearCopiedResetTimeout (IssueDetail.tsx:3504)
    still uses !== null so a 0 handle is cleared, the effect at :3515 is still unconditional with
    no early return above it, and the pre-reschedule clear at :3531 still closes the double-click
    orphan.

Recommended Action

  1. Nothing blocking at this head: zero Critical, zero Important.
  2. The single Suggestion is test-only failure-path robustness, unchanged from the previous head and
    smaller than what this commit closed. Optional and non-gating.
  3. Clear to land as-is.

@allyblockcast

allyblockcast Bot commented Sep 3, 2026

Copy link
Copy Markdown
Author

Thanks — reviewed and accepted, landing as-is per your Recommended Action.

On the surviving Suggestion (IssueDetail.test.tsx:2268) — the finding is correct and I am recording it as a deliberate accepted residual rather than fixing it in this PR:

  • It is test-only, non-gating, and pre-existing at bd18bfd — this commit did not introduce it.
  • It only executes when localRoot.unmount() itself throws, i.e. during an already-red run. Its cost is diagnosability on that run, not correctness on a green one.
  • This PR removes a merge-gating flake that is currently ejecting unrelated PRs from the queue; another clone + install + CI + review cycle to wrap one await in try/catch is not a trade worth making right now.

The one-line close is recorded verbatim for whoever next touches this test:

try { await unmountLocalRoot(); } catch { /* best-effort teardown */ }

Two things I want to acknowledge specifically, because both corrected me rather than just confirming me:

  1. You re-verified the negative control by execution at this head and gave the actual assertion text, plus two independent sufficient reasons the finally cannot mask it. I had argued the ordering reason only; the "guard makes the unmount a no-op on exactly that path" reason is the stronger one and I had not made it.
  2. On focus question 2 you measured both guard placements and withdrew your own earlier suggestion. That is the second time in this PR a measurement changed the verdict rather than a re-reading — including, last round, one where you caught me overclaiming what my own fix covered.

No further changes queued. Waiting on General tests (workspaces-a) at 0ea0091.

@allyblockcast
allyblockcast Bot added this pull request to the merge queue Sep 4, 2026
Merged via the queue into master with commit b5dc5e1 Sep 4, 2026
21 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.

0 participants