fix: skip tooltip content when value is neither truncated nor has a custom tooltip

`KeyValue` was always rendering `ResponsiveTooltipContent` and hiding it with a `hidden` class, so the value text was present in the DOM twice whenever the tooltip wasn't needed. The component now conditionally renders the content at all, which removes the duplication and lets tests drop their `@domainstack/ui/tooltip` mocks.

`useTruncation` is rewritten to use a callback ref instead of `useRef` + `useEffect`, so the resize/mutation observers follow the element even when the caller swaps it out (e.g. `ProviderCell` re-wrapping the span inside a tooltip trigger once truncation is detected). Tests for both `KeyValue` and `useTruncation` are added.
This commit is contained in:
jake committed 2026-10-04 13:59:57 -04:00
1 parent 94a13b252b
commit a411ea908b
9 files changed
+86 -116

No files matched your search

@@ -43,26 +43,6 @@ vi.mock("@/components/icons/provider-logo", () => ({
),
}));
vi.mock("@domainstack/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip">{children}</div>
),
TooltipTrigger: ({
children,
render: renderProp,
}: {
children?: React.ReactNode;
render?: React.ReactNode;
}) => (
<button type="button" data-slot="tooltip-trigger">
{renderProp ?? children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip-content">{children}</div>
),
}));
vi.mock("@domainstack/ui/responsive-tooltip", () => ({
ResponsiveTooltip: ({ children }: { children: React.ReactNode }) => (
<div data-slot="responsive-tooltip">{children}</div>
@@ -8,26 +8,6 @@ vi.mock("@/components/icons/favicon", () => ({
Favicon: ({ domain }: { domain: string }) => <div>icon:{domain}</div>,
}));
vi.mock("@domainstack/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip">{children}</div>
),
TooltipTrigger: ({
children,
render: renderProp,
}: {
children?: React.ReactNode;
render?: React.ReactNode;
}) => (
<button type="button" data-slot="tooltip-trigger">
{renderProp ?? children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip-content">{children}</div>
),
}));
describe("DnsRecordList", () => {
it("renders MX with TTL badges (sorting handled server-side)", async () => {
const records = [
@@ -1,29 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import { page } from "vitest/browser";
import { render } from "@/mocks/react";
import { HeadersSection } from "./headers-section";
// Keep TooltipContent empty in unit tests to avoid text duplication issues.
vi.mock("@domainstack/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip">{children}</div>
),
TooltipTrigger: ({
children,
render: renderProp,
}: {
children?: React.ReactNode;
render?: React.ReactNode;
}) => (
<button type="button" data-slot="tooltip-trigger">
{renderProp ?? children}
</button>
),
TooltipContent: (_: { children: React.ReactNode }) => null,
}));
describe("HeadersSection", () => {
it("highlights important headers and renders values", async () => {
const data = {
@@ -38,12 +19,7 @@ describe("HeadersSection", () => {
await expect
.element(page.getByText("strict-transport-security", { exact: true }))
.toBeInTheDocument();
expect(
page
.getByText("max-age=63072000", { exact: true })
.elements()
.some((n) => n.tagName.toLowerCase() === "span"),
).toBe(true);
await expect.element(page.getByText("max-age=63072000", { exact: true })).toBeInTheDocument();
});
it("sorts headers with important ones first, then alphabetically", async () => {
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { page } from "vitest/browser";
import { render } from "@/mocks/react";
import { KeyValue } from "./key-value";
describe("KeyValue", () => {
it("does not repeat an untruncated value in a tooltip when hovered", async () => {
await render(<KeyValue label="DNS" value="Cloudflare" />);
const value = page.getByText("Cloudflare", { exact: true });
await value.hover();
await expect.element(value).toHaveAttribute("data-popup-open");
expect(page.getByText("Cloudflare", { exact: true }).length).toBe(1);
});
it("shows valueTooltip on hover", async () => {
await render(<KeyValue label="DNS" value="Cloudflare" valueTooltip="Anycast DNS" />);
await page.getByText("Cloudflare", { exact: true }).hover();
await expect.element(page.getByText("Anycast DNS", { exact: true })).toBeVisible();
});
});
+5 -9
View File
@@ -67,15 +67,11 @@ export function KeyValue({
</span>
}
/>
<ResponsiveTooltipContent
className={cn(
isTruncated || valueTooltip != null
? "max-w-[80vw] break-words whitespace-pre-wrap md:max-w-[40rem]"
: "hidden",
)}
>
{valueTooltip ?? value}
</ResponsiveTooltipContent>
{isTruncated || valueTooltip != null ? (
<ResponsiveTooltipContent className="max-w-[80vw] break-words whitespace-pre-wrap md:max-w-[40rem]">
{valueTooltip ?? value}
</ResponsiveTooltipContent>
) : null}
</ResponsiveTooltip>
{suffix ? (
@@ -9,20 +9,6 @@ vi.mock("@/components/icons/favicon", () => ({
Favicon: ({ domain }: { domain: string }) => <div data-slot="favicon" data-domain={domain} />,
}));
vi.mock("@domainstack/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip">{children}</div>
),
TooltipTrigger: ({ children }: { children: React.ReactNode }) => (
<button type="button" data-slot="tooltip-trigger">
{children}
</button>
),
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div data-slot="tooltip-content">{children}</div>
),
}));
describe("RegistrationSection", () => {
it("renders registrar and dates", async () => {
await render(
@@ -41,8 +27,7 @@ describe("RegistrationSection", () => {
}
/>,
);
// Provider name appears in multiple places (value + tooltip)
expect(page.getByText("Namecheap", { exact: true }).length).toBeGreaterThan(0);
await expect.element(page.getByText("Namecheap", { exact: true })).toBeInTheDocument();
});
it("shows unavailable notice when status is unknown", async () => {
@@ -85,7 +70,7 @@ describe("RegistrationSection", () => {
it("shows location only instead of 'Unknown' when no name is published", async () => {
await renderWith({ contacts: [{ type: "registrant", country: "United States of America" }] });
await expect.element(page.getByText("United States of America").first()).toBeInTheDocument();
await expect.element(page.getByText("United States of America")).toBeInTheDocument();
expect(page.getByText(/Unknown —/).length).toBe(0);
});
@@ -93,15 +78,15 @@ describe("RegistrationSection", () => {
await renderWith({
contacts: [{ type: "registrant", organization: "Acme Corp", state: "CA", country: "US" }],
});
await expect.element(page.getByText("Acme Corp").first()).toBeInTheDocument();
await expect.element(page.getByText(/CA, United States/).first()).toBeInTheDocument();
await expect.element(page.getByText("Acme Corp")).toBeInTheDocument();
await expect.element(page.getByText(/CA, United States/)).toBeInTheDocument();
});
it("shows Not published, not Hidden, when only the country was redacted", async () => {
await renderWith({
contacts: [{ type: "registrant", redacted: true, redactedFields: ["country"] }],
});
await expect.element(page.getByText("Not published").first()).toBeInTheDocument();
await expect.element(page.getByText("Not published")).toBeInTheDocument();
});
it("shows Hidden when privacy is enabled and the name is a placeholder", async () => {
@@ -109,7 +94,7 @@ describe("RegistrationSection", () => {
privacyEnabled: true,
contacts: [{ type: "registrant", redacted: true, redactedFields: ["name"] }],
});
await expect.element(page.getByText("Hidden").first()).toBeInTheDocument();
await expect.element(page.getByText("Hidden")).toBeInTheDocument();
});
it("still shows a visible name when privacy is enabled by an email-only redaction", async () => {
@@ -117,12 +102,12 @@ describe("RegistrationSection", () => {
privacyEnabled: true,
contacts: [{ type: "registrant", name: "Jane Doe", redacted: true }],
});
await expect.element(page.getByText("Jane Doe").first()).toBeInTheDocument();
await expect.element(page.getByText("Jane Doe")).toBeInTheDocument();
});
it("shows Not published when the contact is empty", async () => {
await renderWith({ contacts: [{ type: "registrant" }] });
await expect.element(page.getByText("Not published").first()).toBeInTheDocument();
await expect.element(page.getByText("Not published")).toBeInTheDocument();
});
});
});
+34
View File
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { page } from "vitest/browser";
import { render } from "@/mocks/react";
import { useTruncation } from "./use-truncation";
// Wraps the measured span once truncated, the way ProviderCell does, so React
// mounts a new element behind the same ref.
function Harness() {
const { valueRef, isTruncated } = useTruncation();
const value = (
<span ref={valueRef} className="block truncate">
a fairly long provider name
</span>
);
return (
<div style={{ width: 40 }} data-truncated={String(isTruncated)} data-testid="harness">
{isTruncated ? <div>{value}</div> : value}
</div>
);
}
describe("useTruncation", () => {
it("keeps tracking the value after the caller swaps its element", async () => {
await render(<Harness />);
const harness = page.getByTestId("harness");
await expect.element(harness).toHaveAttribute("data-truncated", "true");
// Resize outside React so only the hook's observers can notice.
harness.element().setAttribute("style", "width: 600px");
await expect.element(harness).toHaveAttribute("data-truncated", "false");
});
});
+14 -13
View File
@@ -1,20 +1,21 @@
import { startTransition, useCallback, useEffect, useRef, useState } from "react";
import { startTransition, useCallback, useRef, useState } from "react";
export function useTruncation() {
const valueRef = useRef<HTMLSpanElement | null>(null);
const [isTruncated, setIsTruncated] = useState(false);
const cleanupRef = useRef<(() => void) | null>(null);
const recalcTruncation = useCallback(() => {
const element = valueRef.current;
// A callback ref, so observers follow the element even when the caller swaps
// it (e.g. ProviderCell wrapping the value in a tooltip trigger once truncated).
const valueRef = useCallback((element: HTMLSpanElement | null) => {
cleanupRef.current?.();
cleanupRef.current = null;
if (!element) return;
startTransition(() => {
setIsTruncated(element.scrollWidth > element.clientWidth);
});
}, []);
useEffect(() => {
const element = valueRef.current;
if (!element) return;
const recalcTruncation = () => {
startTransition(() => {
setIsTruncated(element.scrollWidth > element.clientWidth);
});
};
// Defer measurement to the next frame so ResizeObserver callbacks do not
// mutate layout in the same delivery loop (Firefox reports that as an error).
@@ -44,13 +45,13 @@ export function useTruncation() {
window.addEventListener("resize", scheduleRecalc);
return () => {
cleanupRef.current = () => {
cancelAnimationFrame(raf);
window.removeEventListener("resize", scheduleRecalc);
if (resizeObserver) resizeObserver.disconnect();
if (mutationObserver) mutationObserver.disconnect();
};
}, [recalcTruncation]);
}, []);
return {
valueRef,
+1 -5
View File
@@ -202,11 +202,7 @@ export async function hasRecentNotification(
type: NotificationType,
since?: Date,
): Promise<boolean> {
let cutoff = since;
if (!cutoff) {
cutoff = new Date();
cutoff.setDate(cutoff.getDate() - 30);
}
const cutoff = since ?? new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
const rows = await db
.select({ id: notifications.id })