diff --git a/docs/branch-review-ledger.md b/docs/branch-review-ledger.md index e2b180941f..200d700c95 100644 --- a/docs/branch-review-ledger.md +++ b/docs/branch-review-ledger.md @@ -765,3 +765,5 @@ Records before 2026-07-28 were written by hand and had drifted: 146 lines carrie | 2026-08-08 | claude/planning-build-intelligence-9ot0nm | 1ebc84bb288b516bb322c09cde2889e981d302a4 | AGENTS.md reasoning-effort calibration section (docs-only) | Authored and handed off as PR #1730; docs-only, pr-policy classifier returns clinicalRisk/operationalRisk/ragRanking false | prettier --check . (repo-wide, pass); docs:check-links (1665 refs resolve, pass); pr-policy classifyPullRequestFiles(AGENTS.md) | | 2026-08-08 | claude/planning-build-intelligence-9ot0nm | 2b0ad7d41d841c13515f10de7c41e449470dfa78 | pr-1730 review-and-fix | Deep review + Bugbot: no P0/P1; fixed 2 scoped P2 clarity risks (version-bump under-planning; live-state vs provider boundary). Residual: OPENAI_*_REASONING_EFFORT vocab overlap. Merge-tree clean; required CI was green pre-push. | prettier --check AGENTS.md; docs:check-links (1667); verify:pr-local (docs route pass); verify:cheap (524 files / 5607 tests pass); pr-policy classify clinical/operational/rag false; Bugbot no P0-P2 | | 2026-08-08 | dependabot/npm_and_yarn/js-yaml-4.3.1 | a79943df33e653d2a65d4db2f192ee77c22ab75a | PR #1668 unblock | late-synced main after CI green on f04a96c3; merge-tree clean (GitHub DIRTY was stale); js-yaml 4.3.1 + nanoid 3.3.18 preserved; no unresolved threads; CI re-run after push | pre-late-sync: PR required pass on f04a96c3; Production UI skipped; post-sync pending | +| 2026-08-08 | cursor/safety-plan-copy-timer-a650 | cf57b34a36b768e150cd776f7e19acfd984245f7 | PR #1717 unblock | fixed missing it() closer from Copilot autofix; merged origin/main after #1668; merge-tree clean; no unresolved threads | local: vitest patient-safety-plan.dom.test.tsx (8/8); format ok; pending hosted CI after push | +| 2026-08-08 | cursor/safety-plan-copy-timer-a650 | 3142eb9a93275ce2c2435523560b4ed6624d8f53 | PR #1717 unblock | fixed parse + no-explicit-any from Copilot autofix; merged origin/main after #1668; merge-tree clean; 0 threads | local: vitest 8/8; eslint file clean; format ok; pending hosted CI | diff --git a/src/components/patient-safety-plan.tsx b/src/components/patient-safety-plan.tsx index c6cfecf929..d033f3ba77 100644 --- a/src/components/patient-safety-plan.tsx +++ b/src/components/patient-safety-plan.tsx @@ -21,7 +21,7 @@ import { X, type LucideIcon, } from "lucide-react"; -import { useCallback, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { NavigationBackButton } from "@/components/navigation-back-button"; import { appModeHomeHref } from "@/lib/app-modes"; @@ -479,8 +479,24 @@ export function PatientSafetyPlan() { // Per-instance id counter — avoids a module-level mutable that would persist // across remounts; ids only need to be unique within this mounted plan. const uidRef = useRef(0); + const copiedResetRef = useRef | null>(null); + const mountedRef = useRef(true); const uid = useCallback((prefix: string) => `${prefix}-live-${uidRef.current++}`, []); + // Clear the copy-feedback timer on unmount so jsdom teardown / remount cannot + // fire setState after the environment tears down (`window is not defined`). + // Also flip mountedRef so a late clipboard await cannot schedule feedback. + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + if (copiedResetRef.current != null) { + clearTimeout(copiedResetRef.current); + copiedResetRef.current = null; + } + }; + }, []); + const addEntry = useCallback( (key: StepKey, primary: string, secondary?: string) => { setEntries((prev) => ({ ...prev, [key]: [...prev[key], { id: uid(key), primary, secondary }] })); @@ -555,8 +571,14 @@ export function PatientSafetyPlan() { const copyPlan = async () => { try { await navigator.clipboard.writeText(planText); + if (!mountedRef.current) return; setCopied(true); - window.setTimeout(() => setCopied(false), 1600); + if (copiedResetRef.current != null) clearTimeout(copiedResetRef.current); + copiedResetRef.current = setTimeout(() => { + copiedResetRef.current = null; + if (!mountedRef.current) return; + setCopied(false); + }, 1600); } catch { /* clipboard unavailable in some embeds — safe no-op */ } diff --git a/tests/patient-safety-plan.dom.test.tsx b/tests/patient-safety-plan.dom.test.tsx index 9d6e417615..8479a476da 100644 --- a/tests/patient-safety-plan.dom.test.tsx +++ b/tests/patient-safety-plan.dom.test.tsx @@ -1,6 +1,6 @@ /** @vitest-environment jsdom */ -import { render, screen, within } from "@testing-library/react"; +import { act, fireEvent, render, screen, within } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { describe, expect, it, vi } from "vitest"; @@ -131,4 +131,88 @@ describe("PatientSafetyPlan — incomplete-plan draft guard", () => { const copied = String(writeText.mock.calls[0]?.[0] ?? ""); expect(copied).toMatch(/\*\*\* DRAFT — INCOMPLETE SAFETY PLAN, NOT FOR PATIENT HANDOVER \*\*\*/); }); + + it("does not schedule copy feedback when clipboard settles after unmount", async () => { + const user = userEvent.setup(); + let resolveWrite!: () => void; + const writeText = vi.fn( + () => + new Promise((resolve) => { + resolveWrite = () => resolve(); + }), + ); + const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, "clipboard"); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + + try { + const { unmount } = render(); + await user.click(screen.getByRole("button", { name: /^Copy$/ })); + expect(writeText).toHaveBeenCalledTimes(1); + + const callsBeforeUnmount = setTimeoutSpy.mock.calls.length; + unmount(); + await act(async () => { + resolveWrite(); + }); + + const feedbackTimers = setTimeoutSpy.mock.calls.slice(callsBeforeUnmount).filter((call) => call[1] === 1600); + expect(feedbackTimers).toHaveLength(0); + } finally { + if (clipboardDescriptor) { + Object.defineProperty(navigator, "clipboard", clipboardDescriptor); + } else { + // `navigator.clipboard` is usually inherited; delete the shadowing test override. + delete (navigator as { clipboard?: Clipboard }).clipboard; + } + setTimeoutSpy.mockRestore(); + } + }); + + it("clears the scheduled copy-feedback timer on unmount", async () => { + vi.useFakeTimers(); + try { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + const clearTimeoutSpy = vi.spyOn(window, "clearTimeout"); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); + + const { unmount } = render(); + // fireEvent avoids userEvent's real-timer waits under fake timers. + fireEvent.click(screen.getByRole("button", { name: /^Copy$/ })); + + // Flush the resolved clipboard promise so the 1600ms reset is scheduled. + await act(async () => { + await Promise.resolve(); + }); + + expect(screen.getByRole("button", { name: /^Copied$/ })).toBeTruthy(); + const feedbackTimers = setTimeoutSpy.mock.calls.filter((call) => call[1] === 1600); + expect(feedbackTimers).toHaveLength(1); + const timerHandle = setTimeoutSpy.mock.results.find( + (result, index) => setTimeoutSpy.mock.calls[index]?.[1] === 1600, + )?.value; + + const clearsBeforeUnmount = clearTimeoutSpy.mock.calls.length; + unmount(); + expect(clearTimeoutSpy.mock.calls.slice(clearsBeforeUnmount).some((call) => call[0] === timerHandle)).toBe(true); + + // Advancing past the reset window must not throw after teardown + // (original jsdom `window is not defined` / setState-after-unmount failure). + expect(() => { + act(() => { + vi.advanceTimersByTime(2000); + }); + }).not.toThrow(); + } finally { + vi.useRealTimers(); + vi.restoreAllMocks(); + } + }); });