Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
41e7f90
fix(safety-plan): clear copy-feedback timer on unmount
cursoragent Aug 7, 2026
d2c8895
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
579513b
fix(safety-plan): ignore clipboard completion after unmount
cursoragent Aug 8, 2026
69977b0
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
d08a9d3
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
04cb18e
test(safety-plan): prove copy-feedback timer clears on unmount
cursoragent Aug 8, 2026
20aae03
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
cursoragent Aug 8, 2026
e178ee6
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
5a5fd14
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
6b67c69
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
842326f
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
0c8f49a
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
7a42434
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
a5bb21a
Potential fix for pull request finding
BigSimmo Aug 8, 2026
d9833d1
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
f2de7bf
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
527df10
Merge branch 'main' into cursor/safety-plan-copy-timer-a650
BigSimmo Aug 8, 2026
b16bccc
merge(main): sync #1717 before merge
cursoragent Aug 8, 2026
90de154
merge(main): sync #1717 after #1668 for unblock
cursoragent Aug 8, 2026
095c315
fix(safety-plan): restore closing brace after Copilot autofix
cursoragent Aug 8, 2026
36e1ea4
fix(safety-plan): drop explicit any in clipboard cleanup
cursoragent Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/branch-review-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
26 changes: 24 additions & 2 deletions src/components/patient-safety-plan.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<ReturnType<typeof setTimeout> | 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 }] }));
Expand Down Expand Up @@ -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(() => {
Comment thread
cursor[bot] marked this conversation as resolved.
copiedResetRef.current = null;
if (!mountedRef.current) return;
setCopied(false);
}, 1600);
} catch {
/* clipboard unavailable in some embeds — safe no-op */
}
Expand Down
86 changes: 85 additions & 1 deletion tests/patient-safety-plan.dom.test.tsx
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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<void>((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(<PatientSafetyPlan />);
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(<PatientSafetyPlan />);
// 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();
}
});
});
Loading