mirror of
https://github.com/block/buzz.git
synced 2026-11-08 18:31:56 +00:00
**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 |  |  | | Private forum channel tooltip |  |  | | Public forum channel tooltip |  |  | | Channel references, including unresolved |  |  | | Unknown channel permalink |  |  | | Archived channel tooltip |  |  | | Repository entity |  |  | | Pull request entity |  |  | | Entity metadata fallback |  |  | ### Tooltip semantic surface spot check | Buzz light | Catppuccin Mocha dark | |---|---| |  |  | --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: Carl <acda9e433d19dcd0e6b6840f7f4b98f3a56f1fab98049d444c087019e6d36560@buzz.block.builderlab.xyz>
902 lines
28 KiB
JavaScript
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();
|
|
}
|
|
});
|