diff --git a/web/src/components/app/contacts/ContactsTable.tsx b/web/src/components/app/contacts/ContactsTable.tsx
index 15190d4b..40d6ad7c 100644
--- a/web/src/components/app/contacts/ContactsTable.tsx
+++ b/web/src/components/app/contacts/ContactsTable.tsx
@@ -1625,7 +1625,10 @@ function LeadStatusPill({ lead }: { lead?: ContactCampaignProgress | null }) {
)}
{meta.label}
-
+ {/* The reason, when there is one, is worth more than the word it
+ covers — and React owning the attribute is what clears any word
+ the tooltip helper left here before the lead changed state. */}
+
{meta.label}
diff --git a/web/src/lib/helper/clippedTitle.test.ts b/web/src/lib/helper/clippedTitle.test.ts
index fb713232..825058ed 100644
--- a/web/src/lib/helper/clippedTitle.test.ts
+++ b/web/src/lib/helper/clippedTitle.test.ts
@@ -58,6 +58,19 @@ describe("clippedTitle", () => {
expect(el.getAttribute("title")).toBe("Could not send: mailbox rejected the recipient");
});
+ // A caller can start supplying a title after we have already titled the
+ // element, so ownership has to be judged on the value, not the element.
+ it("stands down when a caller titles an element it had titled", () => {
+ const el = span("Queued", 420, 160);
+ fire(clippedTitle.onMouseEnter, el);
+ expect(el.getAttribute("title")).toBe("Queued");
+ el.title = "Could not send: mailbox rejected the recipient";
+ fire(clippedTitle.onMouseEnter, el);
+ expect(el.getAttribute("title")).toBe("Could not send: mailbox rejected the recipient");
+ fire(clippedTitle.onMouseLeave, el);
+ expect(el.getAttribute("title")).toBe("Could not send: mailbox rejected the recipient");
+ });
+
// Nothing removes the attribute on re-render, so it must not outlive the
// hover that set it: a row whose text changes underneath a resting cursor
// would otherwise show the previous value.
diff --git a/web/src/lib/helper/clippedTitle.ts b/web/src/lib/helper/clippedTitle.ts
index 213ad14a..9fa11d96 100644
--- a/web/src/lib/helper/clippedTitle.ts
+++ b/web/src/lib/helper/clippedTitle.ts
@@ -7,19 +7,22 @@
import type { MouseEvent } from "react";
-// Only ever remove a title this module put there. An element with its own
-// `title` prop keeps it: React rewrites that attribute only when the prop value
-// changes, so taking it away once would lose it for good.
-const ours = new WeakSet();
+// The exact string this module last wrote on an element. Anything else in the
+// attribute belongs to the caller and is left alone: React rewrites a `title`
+// prop only when its value changes, so clobbering it once would lose it for
+// good. Matching on the value rather than the element matters because a caller
+// can start supplying a title after we have already titled it.
+const ours = new WeakMap();
function titleWhenClipped(e: MouseEvent) {
const el = e.currentTarget;
- if (el.hasAttribute("title") && !ours.has(el)) return;
+ const current = el.getAttribute("title");
+ if (current !== null && current !== ours.get(el)) return;
const text = el.textContent ?? "";
if (text && el.scrollWidth > el.clientWidth) {
el.title = text;
- ours.add(el);
- } else if (ours.has(el)) {
+ ours.set(el, text);
+ } else if (current !== null) {
el.removeAttribute("title");
ours.delete(el);
}
@@ -27,7 +30,7 @@ function titleWhenClipped(e: MouseEvent) {
function clearTitle(e: MouseEvent) {
const el = e.currentTarget;
- if (!ours.has(el)) return;
+ if (el.getAttribute("title") !== ours.get(el)) return;
el.removeAttribute("title");
ours.delete(el);
}