Files
buzz/desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs
Taylor Ho de8a2741c7 feat(desktop-messages): show compact Buzz link metadata (#6252)
**Category:** improvement
**User Impact:** Buzz links in messages now resolve into compact,
descriptive chips with predictable navigation, while tooltips use the
neutral secondary surface consistently across light and dark themes.

**Problem:** Raw Buzz-native links and opaque identifiers make message
context difficult to scan, while oversized previews disrupt the
conversation. Missing, delayed, or deleted metadata also needs a stable
presentation that does not mislead users or break navigation.

**Solution:** Keep message and issue chips metadata-independent: message
chips render only the channel name, while issue chips render only the
repository name—never fetched content/title or an event hash. Fetched
context remains available in each hover tooltip. Other native links
continue to resolve into concise inline labels, with muted secondary
fallbacks, distinct deleted states, and existing navigation paths
preserved. This also makes two explicit shared-tooltip behavior changes:

1. **Color:** shared and rich tooltips move from the primary color pair
to the neutral secondary semantic pair, including dedicated huddle-token
handling and matching rich-descendant foregrounds.
2. **Hover behavior:** tooltip roots set Radix
`disableHoverableContent`, and tooltip content is pointer-transparent,
so moving off the trigger dismisses the tooltip instead of letting the
pointer enter and keep the popover open.

Together these preserve readable contrast, authoritative project
context, and predictable hover dismissal.

<details>
<summary>File changes</summary>

**desktop/src/features/communities/useCommunityInit.ts**
Primes channel metadata needed to resolve native links promptly.

**desktop/src/features/messages/lib/messageLinkMetadata.test.mjs**
Covers message preview extraction, fallbacks, and deleted-message
semantics.

**desktop/src/features/messages/lib/messageLinkMetadata.ts**
Normalizes fetched message metadata into compact labels and status-aware
presentation.

**desktop/src/features/messages/ui/SentFromThreadLine.tsx**
Provides thread context to linked-message metadata resolution.

**desktop/src/shared/lib/useResolvedLinkPreviews.ts**
Exposes resolved preview state for native entity chips.

**desktop/src/shared/styles/globals/markdown.css**
Adds compact chip layout and semantic disabled-state styling.

**desktop/src/shared/styles/globals/theme.css**
Defines the semantic colors used by unavailable and deleted chips.

**desktop/src/shared/ui/markdown.test.mjs**
Exercises native-link rendering and provider-backed entity metadata
behavior.

**desktop/src/shared/ui/markdown.tsx**
Routes native markdown links through metadata-aware chip components
without changing navigation contracts.

**desktop/src/shared/ui/markdown/ChannelDeepLink.test.mjs**
Covers resolved, unresolved, private, archived, and forum channel
presentations.

**desktop/src/shared/ui/markdown/ChannelDeepLink.tsx**
Renders channel names and concise channel metadata inline with stable
fallbacks.

**desktop/src/shared/ui/markdown/MessageLinkPill.tsx**
Renders loading, ready, unavailable, DM, forum, and deleted message
states compactly.

**desktop/src/shared/ui/markdown/entityLinks.tsx**
Enriches repository, issue, and pull-request chips and uses
containing-project context in tooltips.

**desktop/src/shared/ui/markdown/types.ts**
Extends markdown runtime metadata required by native chip rendering.

**desktop/src/shared/ui/markdown/useMessageLinkMetadata.ts**
Fetches and classifies linked-message metadata while preserving
navigable fallbacks.

**desktop/src/testing/e2eBridge.ts**
Adds deterministic metadata controls for runtime visual and navigation
tests.

**desktop/tailwind.config.js**
Makes semantic disabled colors available to chip styles.

**desktop/tests/e2e/entity-link-recipient-cards.spec.ts**
Verifies entity labels, project-aware tooltips, delayed metadata, and
navigation behavior.

**desktop/tests/e2e/navigation.spec.ts**
Verifies channel and message chip states, metadata resolution, deletion
handling, and navigation.

**desktop/src/shared/ui/tooltip.tsx**
Migrates the shared tooltip surface from the primary color pair to the
semantic secondary pair. It also sets Radix `disableHoverableContent`
and makes tooltip content pointer-transparent so leaving the trigger
dismisses the tooltip predictably.

**desktop/src/features/agents/ui/RestartDiffBadge.tsx**
Aligns restart-diff tooltip values and supporting copy with the
secondary tooltip foreground.

**desktop/src/features/channels/ui/AddChannelBotTeamsSection.tsx**
Aligns rich team tooltip descriptions, persona chips, avatars, and names
with secondary tooltip semantics.

**desktop/src/features/projects/ui/ProjectAuthorIdentity.tsx**
Uses the matching secondary tooltip foreground for project author
metadata.

**desktop/src/features/projects/ui/ProjectCards.tsx**
Aligns repository-unavailable tooltip descriptions with the secondary
tooltip foreground.

**desktop/src/shared/styles/globals/utilities.css**
Makes huddle tooltips consume their dedicated palette directly without
misleading primary or secondary aliases.

**desktop/src/shared/styles/globals/tooltipSemantics.test.mjs**
Pins direct huddle-token consumption and rejects semantic alias drift.

**desktop/tests/e2e/tooltip-semantics.spec.ts**
Verifies simple and rich tooltip surfaces and descendant tokens in Buzz
light and Catppuccin Mocha dark themes.

**desktop/playwright.config.ts**
Registers tooltip semantic coverage in the desktop smoke suite.

</details>

## Reproduction steps

1. Run the desktop app and open a message containing Buzz links for a
channel, message, repository, issue, and pull request.
2. Confirm message links show only the channel name and issue links show
only the repository name inline, with no fetched content/title or event
hash; confirm other native links remain compact rather than rendering
raw identifiers or expanded previews.
3. Hover the chips and confirm the neutral secondary tooltip adds
concise context without duplicating the visible label.
4. Open each chip and confirm it follows the same channel, message,
repository, issue, or pull-request navigation as before.
5. Inspect links while metadata is loading, unavailable, or definitively
deleted and confirm the fallback remains compact, legible, and navigable
where appropriate.
6. In Buzz light and Catppuccin Mocha dark themes, compare a simple
channel-members tooltip and the rich Add agents team tooltip; both
should use secondary semantics with readable nested content.
7. Move the pointer from a trigger toward its tooltip; confirm the
tooltip dismisses rather than remaining open as hoverable content.

## Screenshots

The matrix below covers both the default light theme and Catppuccin
Mocha with an orange accent. Message/issue-chip rows affected by the
stable-label pivot are temporarily omitted until they can be recaptured
from the real built UI; the prior images showed the obsolete expanding
inline label.

| State | Default light | Dark · orange accent |
|---|---|---|
| Public stream channel tooltip | ![Public stream channel tooltip —
default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/01-public-stream-channel-tooltip.png)
| ![Public stream channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/01-public-stream-channel-tooltip.png)
|
| Private forum channel tooltip | ![Private forum channel tooltip —
default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/02-private-forum-channel-tooltip.png)
| ![Private forum channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/02-private-forum-channel-tooltip.png)
|
| Public forum channel tooltip | ![Public forum channel tooltip —
default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/03-forum-channel-tooltip.png)
| ![Public forum channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/03-forum-channel-tooltip.png)
|
| Channel references, including unresolved | ![Channel references,
including unresolved — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/04-unresolved-channel-reference.png)
| ![Channel references, including unresolved — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/04-unresolved-channel-reference.png)
|
| Unknown channel permalink | ![Unknown channel permalink — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/05-unknown-channel-permalink.png)
| ![Unknown channel permalink — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/05-unknown-channel-permalink.png)
|
| Archived channel tooltip | ![Archived channel tooltip — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/06-archived-channel-tooltip.png)
| ![Archived channel tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/06-archived-channel-tooltip.png)
|
| Repository entity | ![Repository entity — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/13-repository-entity.png)
| ![Repository entity — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/13-repository-entity.png)
|
| Pull request entity | ![Pull request entity — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/light-default/14-pull-request-entity.png)
| ![Pull request entity — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/chip-metadata/dark-orange/14-pull-request-entity.png)
|
| Entity metadata fallback | ![Corrected entity metadata fallback
tooltip — default
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/17-entity-metadata-fallback-corrected-light.png)
| ![Corrected entity metadata fallback tooltip — dark orange
accent](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6252/17-entity-metadata-fallback-corrected-dark-orange.png)
|



### Tooltip semantic surface spot check

| Buzz light | Catppuccin Mocha dark |
|---|---|
| ![Rich team tooltip on the secondary surface in Buzz
light](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6290/buzz-team-tooltip.png)
| ![Rich team tooltip on the secondary surface in Catppuccin Mocha
dark](https://d24qwcpro867f5.cloudfront.net/repos/buzz/prs/6290/catppuccin-mocha-team-tooltip.png)
|

---------

Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
2026-08-20 19:01:18 -07:00

902 lines
28 KiB
JavaScript

import assert from "node:assert/strict";
import { after, before, test } from "node:test";
import { JSDOM } from "jsdom";
import {
__linkPreviewMetadataTest,
fetchBuzzEntityMetadata,
isBuzzEntityPreview,
resetLinkPreviewMetadataCache,
resolveLinkPreview,
withEntityFallbacks,
} from "./useResolvedLinkPreviews.ts";
const preview = {
kind: "generic-link",
href: "https://example.com/story",
provider: "example.com",
title: "example.com/story",
typeLabel: "link",
};
function metadata(overrides = {}) {
return {
title: "A story",
siteName: "Example",
description: "Story description",
imageDataUrl: null,
imageDomain: null,
imageFetchState: "none",
imageRetryAfterMs: null,
...overrides,
};
}
test("pending external metadata reserves the image treatment", () => {
assert.deepEqual(resolveLinkPreview(preview, undefined), {
...preview,
imageState: "pending",
});
});
test("pending Buzz entity metadata remains image-less", () => {
const entityPreview = {
kind: "buzz-repository",
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: "buzz",
typeLabel: "repo",
};
assert.deepEqual(resolveLinkPreview(entityPreview, undefined), {
...entityPreview,
imageState: "none",
});
});
test("resolved image metadata keeps the reserved image treatment", () => {
const resolved = resolveLinkPreview(preview, {
title: "A story",
siteName: "Example",
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "cdn.example.com",
});
assert.equal(resolved.imageState, "image");
assert.equal(resolved.provider, "Example");
assert.equal(resolved.imageDomain, "cdn.example.com");
});
test("resolved metadata without a complete image collapses to the compact treatment", () => {
const resolved = resolveLinkPreview(preview, {
title: "A story",
siteName: "Example",
imageDataUrl: null,
imageDomain: null,
});
assert.equal(resolved.imageState, "none");
assert.equal(resolved.imageDataUrl, null);
assert.equal(resolved.imageDomain, null);
});
test("transient and rejected image fetches use the stable fallback treatment", () => {
const transient = resolveLinkPreview(
preview,
metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
}),
);
const rejected = resolveLinkPreview(
preview,
metadata({ imageFetchState: "rejected" }),
);
assert.equal(transient.imageState, "fallback");
assert.equal(rejected.imageState, "fallback");
});
test("metadata cache keys deduplicate URL fragments", () => {
assert.equal(
__linkPreviewMetadataTest.metadataCacheKey(
"https://github.com/block/buzz/pull/3834#issuecomment-1",
),
"https://github.com/block/buzz/pull/3834",
);
});
test("transient metadata expires at the server retry boundary", () => {
assert.equal(
__linkPreviewMetadataTest.metadataExpiry(
metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
}),
1_000,
),
901_000,
);
});
test("metadata loader retries transient images after the server cooldown", async () => {
let now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
return calls === 1
? metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 10_000,
})
: metadata({
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "images.example.com",
imageFetchState: "image",
});
},
now: () => now,
});
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"transient_failure",
);
assert.equal(calls, 1);
now += 10_000;
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"image",
);
assert.equal(calls, 2);
});
test("metadata loader retries rejected requests after the negative-cache TTL", async () => {
let now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
if (calls === 1) throw new Error("temporary failure");
return metadata();
},
now: () => now,
});
assert.equal((await loader.load(preview.href)).metadata, null);
assert.equal((await loader.load(preview.href)).metadata, null);
assert.equal(calls, 1);
now += 5 * 60_000;
assert.deepEqual((await loader.load(preview.href)).metadata, metadata());
assert.equal(calls, 2);
});
test("metadata loader coalesces fragment variants and bounds concurrency", async () => {
let active = 0;
let maxActive = 0;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
concurrency: 2,
fetcher: async () => {
calls += 1;
active += 1;
maxActive = Math.max(maxActive, active);
await new Promise((resolve) => setImmediate(resolve));
active -= 1;
return metadata();
},
});
await Promise.all([
loader.load("https://example.com/one#first"),
loader.load("https://example.com/one#second"),
loader.load("https://example.com/two"),
loader.load("https://example.com/three"),
]);
assert.equal(calls, 3);
assert.equal(maxActive, 2);
});
test("withEntityFallbacks re-adds previews dropped by null metadata", () => {
const entityPreview = {
kind: "buzz-pull-request",
href: `buzz://pr?id=${"ab".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: `buzz #${"ab".repeat(4)}`,
typeLabel: "Review",
};
assert.deepEqual(withEntityFallbacks([entityPreview], []), [
{ ...entityPreview, imageState: "none" },
]);
});
test("withEntityFallbacks keeps resolved previews and preserves order", () => {
const first = {
kind: "buzz-repository",
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: "buzz",
typeLabel: "repo",
};
const second = {
kind: "buzz-issue",
href: `buzz://issue?id=${"ef".repeat(32)}&owner=${"cd".repeat(32)}&d=buzz`,
provider: "Buzz",
title: `buzz #${"ef".repeat(4)}`,
typeLabel: "Task",
};
const resolvedSecond = {
...second,
title: "Fix the preview cards",
imageState: "none",
};
assert.deepEqual(withEntityFallbacks([first, second], [resolvedSecond]), [
{ ...first, imageState: "none" },
resolvedSecond,
]);
});
test("entity fallback eligibility is kind-scoped", () => {
assert.equal(
isBuzzEntityPreview({
...preview,
kind: "buzz-repository",
href: `buzz://repo?owner=${"cd".repeat(32)}&d=buzz`,
}),
true,
);
assert.equal(
isBuzzEntityPreview({ ...preview, href: "buzz://future?id=example" }),
false,
);
});
test("withEntityFallbacks still drops unresolved external links", () => {
assert.deepEqual(withEntityFallbacks([preview], []), []);
assert.deepEqual(
withEntityFallbacks([{ ...preview, href: "buzz://future?id=example" }], []),
[],
);
});
function relayEvent({
id,
kind,
pubkey,
content = "",
tags = [],
createdAt = 1,
}) {
return { id, kind, pubkey, created_at: createdAt, content, tags, sig: "" };
}
test("Buzz PR metadata includes repository identity and trusted root context", async () => {
const owner = "cd".repeat(32);
const attacker = "ef".repeat(32);
const id = "ab".repeat(32);
const repoAddress = `30617:${owner}:buzz`;
const commit = "1234567".padEnd(40, "0");
const events = [
relayEvent({
id: "01".repeat(32),
kind: 30617,
pubkey: owner,
tags: [
["d", "buzz"],
["name", "Buzz Desktop"],
["default-branch", "main"],
],
}),
relayEvent({
id,
kind: 1618,
pubkey: owner,
content: "Body",
tags: [
["a", repoAddress],
["subject", "Restore entity cards"],
["branch-name", "fix/cards"],
["target-branch", "release"],
["c", commit],
],
}),
relayEvent({
id: "02".repeat(32),
kind: 1633,
pubkey: attacker,
createdAt: 20,
tags: [["e", id]],
}),
relayEvent({
id: "03".repeat(32),
kind: 1630,
pubkey: owner,
createdAt: 10,
tags: [["e", id]],
}),
...Array.from({ length: 25 }, (_, index) =>
relayEvent({
id: index.toString(16).padStart(64, "0"),
kind: 1633,
pubkey: owner,
createdAt: 100 + index,
tags: [["e", index.toString(16).padStart(64, "f")]],
}),
),
];
const fetchEvents = async (filter) =>
events
.filter(
(event) =>
(!filter.kinds || filter.kinds.includes(event.kind)) &&
(!filter.ids || filter.ids.includes(event.id)) &&
(!filter.authors || filter.authors.includes(event.pubkey)) &&
(!filter["#d"] ||
event.tags.some(
(tag) => tag[0] === "d" && filter["#d"].includes(tag[1]),
)) &&
(!filter["#a"] ||
event.tags.some(
(tag) => tag[0] === "a" && filter["#a"].includes(tag[1]),
)) &&
(!filter["#e"] ||
event.tags.some(
(tag) => tag[0] === "e" && filter["#e"].includes(tag[1]),
)),
)
.sort((left, right) => right.created_at - left.created_at)
.slice(0, filter.limit);
const result = await fetchBuzzEntityMetadata(
`buzz://pr?id=${id}&owner=${owner}&d=buzz`,
fetchEvents,
);
assert.equal(result?.siteName, "Buzz Desktop");
assert.equal(result?.title, "Restore entity cards");
assert.equal(result?.description, null);
assert.equal(result?.faviconDataUrl, null);
assert.equal(result?.imageDataUrl, null);
});
test("Buzz entity roots reject ambiguous repository tags", async () => {
const owner = "cd".repeat(32);
const attacker = "ef".repeat(32);
const targetAddress = `30617:${owner}:buzz`;
const attackerAddress = `30617:${attacker}:other`;
const repository = relayEvent({
id: "01".repeat(32),
kind: 30617,
pubkey: owner,
tags: [
["d", "buzz"],
["name", "Buzz Desktop"],
["default-branch", "main"],
],
});
for (const [type, kind] of [
["pr", 1618],
["issue", 1621],
]) {
const id = (type === "pr" ? "ab" : "bc").repeat(32);
const root = relayEvent({
id,
kind,
pubkey: attacker,
tags: [
["a", attackerAddress],
["a", targetAddress],
["subject", "Misbound entity"],
],
});
const result = await fetchBuzzEntityMetadata(
`buzz://${type}?id=${id}&owner=${owner}&d=buzz`,
async (filter) =>
filter.kinds?.includes(30617)
? [repository]
: filter.ids?.includes(id)
? [root]
: [],
);
assert.equal(result, null, `${type} with multiple repository tags`);
}
});
test("Buzz repository metadata stays image-less and exposes default branch", async () => {
const owner = "cd".repeat(32);
const result = await fetchBuzzEntityMetadata(
`buzz://repo?owner=${owner}&d=relay-tools`,
async () => [
relayEvent({
id: "01".repeat(32),
kind: 30617,
pubkey: owner,
content: "Fallback description",
tags: [
["d", "relay-tools"],
["name", "Relay Tools"],
["description", "Operator tooling for relays"],
["status", "active"],
["default-branch", "trunk"],
],
}),
],
);
assert.equal(result?.siteName, "Relay Tools");
assert.equal(result?.title, "Operator tooling for relays");
assert.equal(result?.description, "active · default: trunk");
assert.equal(result?.faviconDataUrl, null);
assert.equal(result?.imageDataUrl, null);
assert.equal(result?.imageDomain, null);
});
test("Buzz project metadata resolves from the 30621 announcement", async () => {
const owner = "cd".repeat(32);
const result = await fetchBuzzEntityMetadata(
`buzz://project?owner=${owner}&d=pollinator`,
async () => [
relayEvent({
id: "01".repeat(32),
kind: 30621,
pubkey: owner,
tags: [
["d", "pollinator"],
["name", "Pollinator"],
["description", "Cross-repo pollination pipeline"],
["a", `30617:${owner}:flappy-bee`],
["a", `30617:${owner}:hive-tools`],
],
}),
],
);
assert.equal(result?.siteName, "Pollinator");
assert.equal(result?.title, "Cross-repo pollination pipeline");
assert.equal(result?.description, "2 repositories");
assert.equal(result?.imageDataUrl, null);
});
test("Buzz project metadata declines a missing or invalid announcement", async () => {
const owner = "cd".repeat(32);
assert.equal(
await fetchBuzzEntityMetadata(
`buzz://project?owner=${owner}&d=pollinator`,
async () => [],
),
null,
);
// Two `d` tags fail NIP-MP envelope validation.
assert.equal(
await fetchBuzzEntityMetadata(
`buzz://project?owner=${owner}&d=pollinator`,
async () => [
relayEvent({
id: "01".repeat(32),
kind: 30621,
pubkey: owner,
tags: [
["d", "pollinator"],
["d", "duplicate"],
],
}),
],
),
null,
);
});
test("invalidateNegative drops a cached null miss so the next load refetches", async () => {
// A URL freshly entering the composer clears a stale hard miss (null) so it
// refetches, instead of riding the cached blank.
const now = 1_000;
let calls = 0;
let nextResult = null;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
return nextResult;
},
now: () => now,
});
assert.equal((await loader.load(preview.href)).metadata, null);
assert.equal(calls, 1);
loader.invalidateNegative(preview.href);
nextResult = metadata();
assert.deepEqual((await loader.load(preview.href)).metadata, metadata());
assert.equal(calls, 2);
});
test("invalidateNegative drops a cached transient failure so the next load refetches", async () => {
// A transient image failure is a NEGATIVE by contract (option docs + the
// loader's own retry boundary), so re-entering the composer must refetch it
// — not reuse the cached transient entry. Regression for the leak where
// invalidateNegative only cleared hard `null` misses. (PR #5510)
const now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
return calls === 1
? metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 10_000,
})
: metadata({
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "images.example.com",
imageFetchState: "image",
});
},
now: () => now,
});
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"transient_failure",
);
assert.equal(calls, 1);
// Bust well before the retry boundary (now is frozen); the cache-bust — not
// the cooldown — is what forces the refetch.
loader.invalidateNegative(preview.href);
assert.equal(
(await loader.load(preview.href)).metadata?.imageFetchState,
"image",
);
assert.equal(calls, 2);
});
test("invalidateNegative leaves a healthy cached hit untouched", async () => {
// A settled positive (instant card, no redundant fetch) must survive a bust
// so passive scroll re-renders keep riding the cache.
const now = 1_000;
let calls = 0;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: async () => {
calls += 1;
return metadata();
},
now: () => now,
});
assert.deepEqual((await loader.load(preview.href)).metadata, metadata());
assert.equal(calls, 1);
loader.invalidateNegative(preview.href);
assert.deepEqual((await loader.load(preview.href)).metadata, metadata());
assert.equal(calls, 1);
});
test("invalidateNegative leaves an in-flight fetch untouched", async () => {
// A fetch still in flight is cached as a Promise, not a resolved entry.
// Busting mid-flight must not cancel or duplicate it: the pending load
// resolves normally and no second fetch is started.
const now = 1_000;
let calls = 0;
let releaseFetch;
const loader = __linkPreviewMetadataTest.createMetadataLoader({
fetcher: () => {
calls += 1;
return new Promise((resolve) => {
releaseFetch = () => resolve(metadata());
});
},
now: () => now,
});
const pending = loader.load(preview.href);
assert.equal(calls, 1);
// Bust while the fetch is still in flight — the Promise entry is left alone.
loader.invalidateNegative(preview.href);
assert.equal(calls, 1, "no redundant fetch started by the bust");
releaseFetch();
assert.deepEqual((await pending).metadata, metadata());
assert.equal(calls, 1, "the original in-flight fetch resolved, not a retry");
});
// ── Hook-level regression: retained resolved-state invalidation on re-entry ───
//
// The loader tests above prove `invalidateNegative` drops the SHARED loader
// cache entry. But `useResolvedLinkPreviews` also retains its OWN
// `resolvedMetadata` React state, and the render that scheduled the
// invalidating effect has already read the stale negative from it. Dropping the
// loader key alone leaves that local key in place, so a re-entered
// `transient_failure` still resolves to a `snapshotReady` fallback the composer
// can turn into a sendable snapshot tag from STALE metadata before the retry
// lands. This drives the REAL hook to prove the local key is cleared too, so
// the re-entry renders as pending (no `snapshotReady`) until the retry wins.
// (PR #5510) Regression: without the local-state clear the re-entry assertion
// below sees `snapshotReady: true` instead of pending.
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
url: "http://localhost",
});
before(() => {
Object.assign(globalThis, {
document: dom.window.document,
HTMLElement: dom.window.HTMLElement,
IS_REACT_ACT_ENVIRONMENT: true,
window: dom.window,
});
// @tauri-apps/api/core reads window.__TAURI_INTERNALS__.invoke at call time.
// A per-test handler map lets each test control the fetch resolution timing.
dom.window.__TAURI_INTERNALS__ = {
invoke: (cmd, args) => {
const handler = ipcHandlers.get(cmd);
return handler
? handler(args)
: Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
},
transformCallback: () => Math.random(),
};
});
after(() => dom.window.close());
/** @type {Map<string, (args: unknown) => Promise<unknown>>} */
const ipcHandlers = new Map();
const hookPreview = {
kind: "generic-link",
href: "https://example.com/hook-re-entry",
provider: "example.com",
title: "example.com/hook-re-entry",
typeLabel: "link",
};
test("hook clears retained resolved state so a re-entered transient failure renders pending until the retry wins", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { useResolvedLinkPreviews } = await import(
"./useResolvedLinkPreviews.ts"
);
// Isolate from any loader state leaked by earlier tests in this process.
resetLinkPreviewMetadataCache();
ipcHandlers.clear();
// Gate every fetch so we can observe the state between renders. Call 1 caches
// a transient failure; call 2 (the re-entry retry) succeeds.
let calls = 0;
const releases = [];
ipcHandlers.set("fetch_link_preview_metadata", () => {
calls += 1;
const attempt = calls;
return new Promise((resolve) => {
releases.push(() =>
resolve(
attempt === 1
? metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
})
: metadata({
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "images.example.com",
imageFetchState: "image",
}),
),
);
});
});
// scheduleAfterPaint uses requestAnimationFrame -> setTimeout(0); flush both
// plus a microtask turn so the queued load() actually fires.
const flushScheduledLoads = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
const settle = async () => {
await act(async () => {
// Release any pending fetch and let its .then() commit setResolvedMetadata.
while (releases.length > 0) releases.shift()();
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
try {
const { result, rerender, unmount } = renderHook(
({ previews }) =>
useResolvedLinkPreviews(previews, { refetchNewNegatives: true }),
{ initialProps: { previews: [hookPreview] } },
);
// 1. Initial paste: pending until the fetch resolves to a transient failure.
assert.equal(result.current[0].imageState, "pending");
assert.equal(result.current[0].snapshotReady, undefined);
await flushScheduledLoads();
await settle();
// Transient failure cached: a sendable fallback (this is the state that must
// NOT survive re-entry as ready).
assert.equal(result.current[0].imageState, "fallback");
assert.equal(result.current[0].snapshotReady, true);
assert.equal(calls, 1);
// 2. URL removed from the composer.
rerender({ previews: [] });
assert.deepEqual(result.current, []);
// 3. URL re-entered. The invalidation effect must drop BOTH the loader entry
// and the retained local key, so this settles to pending with no
// snapshotReady — not the stale fallback. Without the local-state clear
// the hook returns snapshotReady: true here.
rerender({ previews: [hookPreview] });
await act(async () => {});
assert.equal(
result.current[0].imageState,
"pending",
"re-entered transient failure must render pending, not a stale fallback",
);
assert.equal(
result.current[0].snapshotReady,
undefined,
"no snapshotReady before the retry resolves — nothing sendable from stale metadata",
);
// 4. Successful retry wins.
await flushScheduledLoads();
await settle();
assert.equal(result.current[0].imageState, "image");
assert.equal(result.current[0].snapshotReady, true);
assert.equal(calls, 2, "the re-entry triggered a fresh fetch");
unmount();
} finally {
cleanup();
ipcHandlers.clear();
}
});
// ── Hook-level regression: in-flight shared entry defeats the local clear ─────
//
// The shared metadataLoader is intentionally shared across every hook instance
// (composer + message list). `invalidateNegative` deliberately leaves an
// in-flight Promise entry alone — but that means when a URL re-enters the
// composer WHILE another instance has a fetch in flight for the same canonical
// URL, the shared drop is a no-op. Gating the local-state clear on that drop
// (the earlier fix) leaves this hook's retained `transient_failure` in place,
// so it keeps returning `snapshotReady: true` — a sendable tag built from stale
// metadata — until that shared fetch resolves. This drives the real hook
// through that exact interleaving to prove the local negative is cleared on
// re-entry regardless of the shared entry's shape. (PR #5510, follow-up.)
const otherHookPreview = {
kind: "generic-link",
href: "https://example.com/hook-inflight-re-entry",
provider: "example.com",
title: "example.com/hook-inflight-re-entry",
typeLabel: "link",
};
test("hook clears a re-entered negative even when the shared cache holds an in-flight fetch, not a settled entry", async () => {
const { act, cleanup, renderHook } = await import("@testing-library/react");
const { useResolvedLinkPreviews } = await import(
"./useResolvedLinkPreviews.ts"
);
resetLinkPreviewMetadataCache();
ipcHandlers.clear();
// Gate every fetch. Call 1 (composer's initial paste) resolves to a transient
// failure. Call 2 is the SHARED in-flight fetch a message-list instance starts
// after the shared cache is dropped; it is left unreleased so it is still a
// Promise in the shared cache when the composer re-enters.
let calls = 0;
const releases = [];
ipcHandlers.set("fetch_link_preview_metadata", () => {
calls += 1;
const attempt = calls;
return new Promise((resolve) => {
releases.push(() =>
resolve(
attempt === 1
? metadata({
imageFetchState: "transient_failure",
imageRetryAfterMs: 900_000,
})
: metadata({
imageDataUrl: "data:image/jpeg;base64,abc",
imageDomain: "images.example.com",
imageFetchState: "image",
}),
),
);
});
});
const flushScheduledLoads = async () => {
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
const releaseAll = async () => {
await act(async () => {
while (releases.length > 0) releases.shift()();
await new Promise((resolve) => setTimeout(resolve, 0));
});
};
const composer = renderHook(
({ previews }) =>
useResolvedLinkPreviews(previews, { refetchNewNegatives: true }),
{ initialProps: { previews: [otherHookPreview] } },
);
// A passive message-list instance sharing the same loader (no refetch).
const messageList = renderHook(
({ previews }) => useResolvedLinkPreviews(previews, {}),
{ initialProps: { previews: [] } },
);
try {
// 1. Composer paste resolves to a transient failure -> sendable fallback.
await flushScheduledLoads();
await act(async () => {
releases.shift()(); // release call 1 only
await new Promise((resolve) => setTimeout(resolve, 0));
});
assert.equal(composer.result.current[0].imageState, "fallback");
assert.equal(composer.result.current[0].snapshotReady, true);
assert.equal(calls, 1);
// 2. Composer URL leaves.
composer.rerender({ previews: [] });
assert.deepEqual(composer.result.current, []);
// 3. The shared negative is dropped (simulating expiry/eviction), then a
// message-list instance starts a fresh fetch for the SAME canonical URL
// and it is left IN FLIGHT — a Promise, not a settled entry, in the
// shared cache.
act(() => {
resetLinkPreviewMetadataCache();
});
messageList.rerender({ previews: [otherHookPreview] });
await flushScheduledLoads();
assert.equal(calls, 2, "message-list started the shared in-flight fetch");
// 4. Composer re-enters WHILE that fetch is in flight. invalidateNegative is
// a no-op (the shared entry is a Promise), so the earlier drop-gated clear
// would leave the retained transient failure — a sendable tag from stale
// metadata. The local negative must be cleared regardless: pending, no
// snapshotReady.
composer.rerender({ previews: [otherHookPreview] });
await act(async () => {});
assert.equal(
composer.result.current[0].imageState,
"pending",
"re-entry during a shared in-flight fetch must render pending, not a stale fallback",
);
assert.equal(
composer.result.current[0].snapshotReady,
undefined,
"no snapshotReady while the shared fetch is in flight — nothing sendable from stale metadata",
);
// The composer coalesced onto the in-flight fetch — no third request.
assert.equal(calls, 2, "re-entry coalesced onto the in-flight fetch");
// 5. The shared fetch resolves successfully; both instances settle.
await releaseAll();
await flushScheduledLoads();
assert.equal(composer.result.current[0].imageState, "image");
assert.equal(composer.result.current[0].snapshotReady, true);
} finally {
composer.unmount();
messageList.unmount();
cleanup();
ipcHandlers.clear();
}
});