diff --git a/desktop/src/features/messages/ui/MessageComposer.tsx b/desktop/src/features/messages/ui/MessageComposer.tsx
index d13cec95eba..022d60b4d1e 100644
--- a/desktop/src/features/messages/ui/MessageComposer.tsx
+++ b/desktop/src/features/messages/ui/MessageComposer.tsx
@@ -57,7 +57,7 @@ import { usePersistentAgentMentionHydration } from "./usePersistentAgentMentionH
import { useComposerContentState } from "./useComposerContentState";
import { useDraftPersistLifecycle } from "./useDraftPersistSnapshot";
import { submitMessageEdit } from "./submitMessageEdit";
-import { useComposerLinkPreviews } from "./useComposerLinkPreviews";
+import { useManagedComposerLinkPreviews } from "./useComposerLinkPreviews";
import { scheduleSettleGatedAutoSubmit } from "./messageComposerAutoSubmit";
import type { MessageComposerProps } from "./MessageComposer.types";
function MessageComposerImpl({
@@ -100,14 +100,14 @@ function MessageComposerImpl({
syncComposerContentFromEditor,
syncContentRefFromEditorRef,
} = useComposerContentState();
- const [previewContent, setPreviewContent] = React.useState("");
const {
previewList: composerLinkPreviews,
getReadyTags: getReadyLinkPreviewTags,
hasPendingSnapshots: hasPendingLinkPreviewSnapshots,
// Ref lets the submit guard block Enter/form/auto-submit until snapshots settle.
hasPendingSnapshotsRef: hasPendingLinkPreviewSnapshotsRef,
- } = useComposerLinkPreviews(previewContent, editTarget == null);
+ updateContent: updateLinkPreviewContent,
+ } = useManagedComposerLinkPreviews(editTarget == null);
const [isEmojiPickerOpen, setIsEmojiPickerOpen] = React.useState(false);
const [isFormattingOpen, setIsFormattingOpen] = React.useState(false);
const [spoileredAttachmentUrls, setSpoileredAttachmentUrls] = React.useState<
@@ -269,7 +269,7 @@ function MessageComposerImpl({
onLinkShortcut: () => onLinkShortcutRef.current?.() ?? false,
onUpdate: ({ cursor, linkPreviewContent, text }) => {
setComposerContentFromText(text);
- setPreviewContent(linkPreviewContent);
+ updateLinkPreviewContent(linkPreviewContent);
mentions.updateMentionQuery(text, cursor);
channelLinks.updateChannelQuery(text, cursor);
emojiAutocomplete.updateEmojiQuery(text, cursor);
diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs
new file mode 100644
index 00000000000..049c57f70c4
--- /dev/null
+++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs
@@ -0,0 +1,752 @@
+import assert from "node:assert/strict";
+import { after, before, test } from "node:test";
+
+import { JSDOM } from "jsdom";
+
+// ── Composer-hook regression: fast clear + re-paste of the same URL ───────────
+//
+// useComposerLinkPreviews feeds useResolvedLinkPreviews from DEBOUNCED content
+// (350ms) but tracks URL-presence newness from the LIVE content. Without that
+// live-href signal a fast clear-then-repaste of the same URL inside the debounce
+// window never commits an empty debounced set, so the resolver never sees the
+// URL leave and never refetches — and the stale snapshot tag built from the
+// pre-clear metadata stays sendable. This drives the real composer hook through
+// that gesture and asserts the stale tag is not sendable and a fresh fetch is
+// forced. (PR #5510, second follow-up.)
+
+const dom = new JSDOM("
", {
+ url: "http://localhost",
+});
+
+/** @type {Map Promise>} */
+const ipcHandlers = new Map();
+
+before(() => {
+ Object.assign(globalThis, {
+ document: dom.window.document,
+ HTMLElement: dom.window.HTMLElement,
+ IS_REACT_ACT_ENVIRONMENT: true,
+ window: dom.window,
+ });
+ 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());
+
+const HREF = "https://example.com/composer-re-entry";
+const DEBOUNCE_WAIT_MS = 400; // > LINK_PREVIEW_DEBOUNCE_MS (350)
+
+function metadata(overrides = {}) {
+ return {
+ title: "A story",
+ siteName: "Example",
+ description: "Story description",
+ imageDataUrl: null,
+ imageDomain: null,
+ imageFetchState: "none",
+ imageRetryAfterMs: null,
+ faviconDataUrl: null,
+ ...overrides,
+ };
+}
+
+test("composer input versions retain only active hrefs while re-entry advances", async () => {
+ const { updateComposerLinkPreviewInput } = await import(
+ "./useComposerLinkPreviews.tsx"
+ );
+ const secondHref = "https://example.com/second";
+ let input = {
+ content: "",
+ hrefs: new Set(),
+ hrefVersions: new Map(),
+ nextHrefVersion: 0,
+ };
+
+ input = updateComposerLinkPreviewInput(input, `see ${HREF}`);
+ const firstVersion = input.hrefVersions.get(HREF);
+ assert.equal(input.hrefVersions.size, 1);
+
+ input = updateComposerLinkPreviewInput(input, `see ${secondHref}`);
+ assert.deepEqual(
+ [...input.hrefVersions.keys()],
+ [secondHref],
+ "departed href history is pruned instead of retained for the composer lifetime",
+ );
+
+ input = updateComposerLinkPreviewInput(input, `see ${HREF}`);
+ assert.deepEqual([...input.hrefVersions.keys()], [HREF]);
+ assert.ok(
+ input.hrefVersions.get(HREF) > firstVersion,
+ "a re-entered href receives a new monotonic version after its old entry was pruned",
+ );
+});
+
+test("composer forces a refetch and drops the stale tag on a fast clear+re-paste of the same URL", async () => {
+ const { act, cleanup, renderHook } = await import("@testing-library/react");
+ const { resetLinkPreviewMetadataCache } = await import(
+ "@/shared/lib/useResolvedLinkPreviews.ts"
+ );
+ const { updateComposerLinkPreviewInput, useComposerLinkPreviews } =
+ await import("./useComposerLinkPreviews.tsx");
+
+ resetLinkPreviewMetadataCache();
+ ipcHandlers.clear();
+
+ // Relay origin for media URLs (composer fetches it once on mount).
+ ipcHandlers.set("get_relay_http_url", () =>
+ Promise.resolve("https://relay.example.com"),
+ );
+ // Media upload always succeeds instantly so a snapshot tag can be built.
+ ipcHandlers.set("upload_media_bytes", () =>
+ Promise.resolve({
+ url: "https://relay.example.com/media/x",
+ sha256: "deadbeef",
+ size: 1,
+ type: "image/png",
+ uploaded: 0,
+ }),
+ );
+ // Call 1 (initial paste) resolves to a transient failure -> sendable fallback,
+ // instantly. Call 2 (the forced re-entry refetch) resolves to a success but
+ // only when we release `resolveRefetch`, so the test can observe the
+ // intermediate window where the stale tag is gone and Send is held pending
+ // BEFORE the fresh result lands (in the app the refetch is a real network
+ // round-trip; instant resolution would collapse the window under test).
+ let fetchCalls = 0;
+ let resolveRefetch;
+ ipcHandlers.set("fetch_link_preview_metadata", () => {
+ fetchCalls += 1;
+ if (fetchCalls === 1) {
+ return Promise.resolve(
+ metadata({
+ imageFetchState: "transient_failure",
+ imageRetryAfterMs: 900_000,
+ }),
+ );
+ }
+ return new Promise((resolve) => {
+ resolveRefetch = () =>
+ resolve(
+ metadata({
+ imageDataUrl: "data:image/png;base64,QQ==",
+ imageDomain: "images.example.com",
+ imageFetchState: "image",
+ }),
+ );
+ });
+ });
+
+ const flushDebounceAndSettle = async () => {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS));
+ });
+ };
+
+ try {
+ let previewInput = updateComposerLinkPreviewInput(
+ {
+ content: "",
+ hrefs: new Set(),
+ hrefVersions: new Map(),
+ nextHrefVersion: 0,
+ },
+ `see ${HREF}`,
+ );
+ const { result, rerender, unmount } = renderHook(
+ ({ content, hrefVersions }) =>
+ useComposerLinkPreviews(content, true, hrefVersions),
+ { initialProps: previewInput },
+ );
+
+ // 1. Paste settles to a transient-failure fallback with a ready snapshot tag.
+ await flushDebounceAndSettle();
+ assert.equal(fetchCalls, 1);
+ assert.equal(
+ result.current.getReadyTags().length,
+ 1,
+ "the transient fallback produced a sendable snapshot tag",
+ );
+ assert.equal(result.current.hasPendingSnapshots, false);
+
+ // 2. Fast gesture: clear the URL, then re-paste the SAME URL, with both
+ // editor updates folded into one React batch. The debounced candidates
+ // and the committed live href set therefore never observe empty.
+ // Model two editor onUpdate calls folded into one React batch. The final
+ // href set equals the previous commit, but the update-boundary version has
+ // advanced because the URL left and re-entered between those updates.
+ await act(async () => {
+ previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
+ previewInput = updateComposerLinkPreviewInput(
+ previewInput,
+ `see ${HREF}`,
+ );
+ rerender(previewInput);
+ });
+
+ // 3a. The stale tag must be gone AS SOON AS the same URL re-enters — this is
+ // the core invariant and it holds synchronously (render-time detection
+ // drops the tag and excludes the href from sendable output), so no timer
+ // needs to fire first. Send is held pending until a fresh tag lands.
+ await act(async () => {});
+ assert.equal(
+ result.current.getReadyTags().length,
+ 0,
+ "stale snapshot tag must not be sendable the moment the URL re-enters",
+ );
+ assert.equal(
+ result.current.hasPendingSnapshots,
+ true,
+ "Send must be held pending after a clear+re-paste",
+ );
+
+ // 3b. Once the debounce settles, the re-entry has forced a fresh fetch. It is
+ // still in flight (the deferred refetch has NOT resolved), so the stale
+ // tag stays gone and Send stays pending. Pre-fix the re-entry is
+ // invisible, so no refetch starts and this fails fast.
+ await flushDebounceAndSettle();
+ assert.equal(
+ fetchCalls,
+ 2,
+ "the re-entry forced a fresh fetch (still in flight)",
+ );
+ assert.equal(
+ result.current.getReadyTags().length,
+ 0,
+ "stale snapshot tag must not be sendable while the re-entry refetches",
+ );
+ assert.equal(
+ result.current.hasPendingSnapshots,
+ true,
+ "Send must be held pending while the re-entry refetches",
+ );
+
+ // 4. The forced refetch resolves (success); a fresh tag becomes sendable and
+ // its media is the freshly-fetched image, not the pre-clear empty
+ // fallback (proving the tag was rebuilt from new metadata).
+ await act(async () => {
+ resolveRefetch();
+ });
+ await flushDebounceAndSettle();
+ const [freshTag] = result.current.getReadyTags();
+ assert.equal(
+ result.current.getReadyTags().length,
+ 1,
+ "a fresh sendable tag lands after the refetch",
+ );
+ assert.ok(
+ freshTag?.includes("https://relay.example.com/media/x"),
+ "the sendable tag carries the freshly-fetched snapshot media",
+ );
+ assert.equal(result.current.hasPendingSnapshots, false);
+
+ unmount();
+ } finally {
+ cleanup();
+ ipcHandlers.clear();
+ }
+});
+
+// A blocked re-entry can leave again before its forced refetch settles. The
+// abandoned phase must not poison a later paste of the now-healthy cached result.
+test("a removed blocked re-entry can later use metadata that resolved while absent", async () => {
+ const { act, cleanup, renderHook } = await import("@testing-library/react");
+ const { resetLinkPreviewMetadataCache } = await import(
+ "@/shared/lib/useResolvedLinkPreviews.ts"
+ );
+ const { updateComposerLinkPreviewInput, useComposerLinkPreviews } =
+ await import("./useComposerLinkPreviews.tsx");
+
+ resetLinkPreviewMetadataCache();
+ ipcHandlers.clear();
+ ipcHandlers.set("get_relay_http_url", () =>
+ Promise.resolve("https://relay.example.com"),
+ );
+ ipcHandlers.set("upload_media_bytes", () =>
+ Promise.resolve({
+ url: "https://relay.example.com/media/fresh-after-absence",
+ sha256: "f8e5",
+ size: 1,
+ type: "image/png",
+ uploaded: 0,
+ }),
+ );
+
+ let fetchCalls = 0;
+ let resolveRefetch;
+ ipcHandlers.set("fetch_link_preview_metadata", () => {
+ fetchCalls += 1;
+ if (fetchCalls === 1) {
+ return Promise.resolve(
+ metadata({
+ imageFetchState: "transient_failure",
+ imageRetryAfterMs: 900_000,
+ }),
+ );
+ }
+ return new Promise((resolve) => {
+ resolveRefetch = () =>
+ resolve(
+ metadata({
+ imageDataUrl: "data:image/png;base64,QQ==",
+ imageDomain: "images.example.com",
+ imageFetchState: "image",
+ }),
+ );
+ });
+ });
+
+ const settle = async () => {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS));
+ });
+ };
+
+ try {
+ let previewInput = updateComposerLinkPreviewInput(
+ {
+ content: "",
+ hrefs: new Set(),
+ hrefVersions: new Map(),
+ nextHrefVersion: 0,
+ },
+ `see ${HREF}`,
+ );
+ const { result, rerender, unmount } = renderHook(
+ ({ content, hrefVersions }) =>
+ useComposerLinkPreviews(content, true, hrefVersions),
+ { initialProps: previewInput },
+ );
+
+ await settle();
+ assert.equal(result.current.getReadyTags().length, 1);
+
+ // Re-enter the cached negative and wait until its forced refetch is in flight.
+ await act(async () => {
+ previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
+ previewInput = updateComposerLinkPreviewInput(
+ previewInput,
+ `see ${HREF}`,
+ );
+ rerender(previewInput);
+ });
+ await settle();
+ assert.equal(fetchCalls, 2);
+ assert.equal(result.current.getReadyTags().length, 0);
+ assert.equal(result.current.hasPendingSnapshots, true);
+
+ // Remove the blocked href, then let its refetch populate healthy metadata
+ // while no candidate is active.
+ await act(async () => {
+ previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
+ rerender(previewInput);
+ });
+ await settle();
+ await act(async () => resolveRefetch());
+ await settle();
+ assert.equal(result.current.getReadyTags().length, 0);
+
+ // A later paste should use the healthy result immediately after debounce;
+ // the abandoned "blocked" phase must not survive and suppress its tag.
+ await act(async () => {
+ previewInput = updateComposerLinkPreviewInput(
+ previewInput,
+ `see ${HREF}`,
+ );
+ rerender(previewInput);
+ });
+ await settle();
+ const [freshTag] = result.current.getReadyTags();
+ assert.equal(
+ fetchCalls,
+ 2,
+ "healthy metadata is preserved without a third fetch",
+ );
+ assert.equal(result.current.getReadyTags().length, 1);
+ assert.ok(
+ freshTag?.includes("https://relay.example.com/media/fresh-after-absence"),
+ "the later paste becomes sendable with metadata resolved while absent",
+ );
+ assert.equal(result.current.hasPendingSnapshots, false);
+
+ unmount();
+ } finally {
+ cleanup();
+ ipcHandlers.clear();
+ }
+});
+
+// ── Composer-hook regression: stale in-flight upload after re-entry ───────────
+//
+// A pre-clear transient-fallback snapshot upload (U1) can still be in flight
+// when the URL is cleared and re-pasted. The re-entry forces a refetch to fresh
+// metadata, and the upload effect starts a fresh upload (U2). When the stale U1
+// finally settles it must NOT publish a snapshot tag built from the pre-clear
+// metadata — even though its re-entry phase marker was already cleared once the
+// refetch reached fresh ready. The per-href upload generation fence makes the
+// stale completion a no-op, and the generation-aware dedup guard lets U2 start
+// even while U1's slot is still occupied. Reverting either the generation fence
+// in `.then` or the generation-aware dedup guard makes this fail (U1 publishes
+// its stale favicon, or U2 never starts). (PR #5510, third follow-up.)
+test("a stale in-flight upload cannot publish after the URL re-enters and a fresh upload wins", async () => {
+ const { act, cleanup, renderHook } = await import("@testing-library/react");
+ const { resetLinkPreviewMetadataCache } = await import(
+ "@/shared/lib/useResolvedLinkPreviews.ts"
+ );
+ const { updateComposerLinkPreviewInput, useComposerLinkPreviews } =
+ await import("./useComposerLinkPreviews.tsx");
+
+ resetLinkPreviewMetadataCache();
+ ipcHandlers.clear();
+
+ ipcHandlers.set("get_relay_http_url", () =>
+ Promise.resolve("https://relay.example.com"),
+ );
+
+ // Gate the FIRST media upload (U1's favicon — its image is null in the
+ // transient fallback, so the favicon is U1's only upload) so it stays in
+ // flight across the clear + re-paste and the fresh-metadata resolution. Every
+ // later upload resolves instantly with a "fresh" URL. If a stale tag ever
+ // reaches the sendable set it will carry the STALE favicon URL, which the
+ // assertions forbid.
+ let uploadCalls = 0;
+ let releaseStaleUpload;
+ ipcHandlers.set("upload_media_bytes", () => {
+ uploadCalls += 1;
+ if (uploadCalls === 1) {
+ return new Promise((resolve) => {
+ releaseStaleUpload = () =>
+ resolve({
+ url: "https://relay.example.com/media/STALE",
+ sha256: "5741313",
+ size: 1,
+ type: "image/png",
+ uploaded: 0,
+ });
+ });
+ }
+ return Promise.resolve({
+ url: "https://relay.example.com/media/FRESH",
+ sha256: "f8e5",
+ size: 1,
+ type: "image/png",
+ uploaded: 0,
+ });
+ });
+
+ // Call 1 (initial paste): transient failure WITH a favicon, so it produces a
+ // sendable fallback whose upload (the favicon) is the gated U1. Call 2 (the
+ // forced re-entry refetch): a full success that only resolves when released,
+ // so the intermediate window (U1 in flight, refetch pending) is observable.
+ let fetchCalls = 0;
+ let resolveRefetch;
+ ipcHandlers.set("fetch_link_preview_metadata", () => {
+ fetchCalls += 1;
+ if (fetchCalls === 1) {
+ return Promise.resolve(
+ metadata({
+ faviconDataUrl: "data:image/png;base64,QQ==",
+ imageFetchState: "transient_failure",
+ imageRetryAfterMs: 900_000,
+ }),
+ );
+ }
+ return new Promise((resolve) => {
+ resolveRefetch = () =>
+ resolve(
+ metadata({
+ faviconDataUrl: "data:image/png;base64,Qg==",
+ imageDataUrl: "data:image/png;base64,Qw==",
+ imageDomain: "images.example.com",
+ imageFetchState: "image",
+ }),
+ );
+ });
+ });
+
+ const flushDebounceAndSettle = async () => {
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS));
+ });
+ };
+
+ try {
+ let previewInput = updateComposerLinkPreviewInput(
+ {
+ content: "",
+ hrefs: new Set(),
+ hrefVersions: new Map(),
+ nextHrefVersion: 0,
+ },
+ `see ${HREF}`,
+ );
+ const { result, rerender, unmount } = renderHook(
+ ({ content, hrefVersions }) =>
+ useComposerLinkPreviews(content, true, hrefVersions),
+ { initialProps: previewInput },
+ );
+
+ // 1. Paste settles to a transient-failure fallback. Its favicon upload (U1)
+ // is gated and stays in flight, so no sendable tag exists yet.
+ await flushDebounceAndSettle();
+ assert.equal(fetchCalls, 1);
+ assert.equal(
+ uploadCalls,
+ 1,
+ "U1 (the stale fallback favicon) is in flight",
+ );
+ assert.equal(
+ result.current.getReadyTags().length,
+ 0,
+ "U1 has not settled, so no snapshot tag is sendable yet",
+ );
+
+ // 2. Fast gesture: clear then re-paste the SAME URL inside the debounce.
+ await act(async () => {
+ previewInput = updateComposerLinkPreviewInput(previewInput, "see ");
+ previewInput = updateComposerLinkPreviewInput(
+ previewInput,
+ `see ${HREF}`,
+ );
+ rerender(previewInput);
+ });
+
+ // 3. The re-entry forces a fresh fetch; resolve it to fresh success. The
+ // upload effect must start a FRESH upload (U2) even though U1 still holds
+ // the slot, then produce a fresh sendable tag.
+ await flushDebounceAndSettle();
+ assert.equal(fetchCalls, 2, "the re-entry forced a fresh fetch");
+ await act(async () => {
+ resolveRefetch();
+ });
+ await flushDebounceAndSettle();
+ assert.ok(
+ uploadCalls >= 2,
+ "a fresh upload (U2) started despite U1 still holding the slot",
+ );
+ const [freshTag] = result.current.getReadyTags();
+ assert.equal(
+ result.current.getReadyTags().length,
+ 1,
+ "the fresh upload produced a sendable tag",
+ );
+ assert.ok(
+ freshTag?.includes("https://relay.example.com/media/FRESH"),
+ "the sendable tag carries the freshly-uploaded media",
+ );
+ assert.ok(
+ !freshTag?.includes("https://relay.example.com/media/STALE"),
+ "the sendable tag must not carry the stale pre-clear media",
+ );
+
+ // 4. Release the stale U1. Its completion must be a no-op: it cannot
+ // overwrite the fresh tag with one built from pre-clear metadata.
+ await act(async () => {
+ releaseStaleUpload();
+ });
+ await flushDebounceAndSettle();
+ const [tagAfterStale] = result.current.getReadyTags();
+ assert.equal(
+ result.current.getReadyTags().length,
+ 1,
+ "still exactly one sendable tag after the stale upload settles",
+ );
+ assert.ok(
+ tagAfterStale?.includes("https://relay.example.com/media/FRESH") &&
+ !tagAfterStale?.includes("https://relay.example.com/media/STALE"),
+ "the stale upload cannot publish its pre-clear tag after settling",
+ );
+
+ unmount();
+ } finally {
+ cleanup();
+ ipcHandlers.clear();
+ }
+});
+
+// The ordinary production gesture starts from a mounted empty composer. Live
+// href tracking must not mark the pasted href handled before the debounced
+// candidate exists, or the eventual resolver pass will reuse a cached negative.
+test("composer refetches a cached negative when a link is pasted into an empty draft", async () => {
+ const { act, cleanup, renderHook } = await import("@testing-library/react");
+ const { resetLinkPreviewMetadataCache } = await import(
+ "@/shared/lib/useResolvedLinkPreviews.ts"
+ );
+ const { useComposerLinkPreviews } = await import(
+ "./useComposerLinkPreviews.tsx"
+ );
+
+ resetLinkPreviewMetadataCache();
+ ipcHandlers.clear();
+ ipcHandlers.set("get_relay_http_url", () =>
+ Promise.resolve("https://relay.example.com"),
+ );
+ ipcHandlers.set("upload_media_bytes", () =>
+ Promise.resolve({
+ url: "https://relay.example.com/media/fresh",
+ sha256: "f8e5",
+ size: 1,
+ type: "image/png",
+ uploaded: 0,
+ }),
+ );
+
+ let fetchCalls = 0;
+ ipcHandlers.set("fetch_link_preview_metadata", () => {
+ fetchCalls += 1;
+ return Promise.resolve(
+ fetchCalls === 1
+ ? metadata({
+ imageFetchState: "transient_failure",
+ imageRetryAfterMs: 900_000,
+ })
+ : metadata({
+ imageDataUrl: "data:image/png;base64,QQ==",
+ imageDomain: "images.example.com",
+ imageFetchState: "image",
+ }),
+ );
+ });
+
+ try {
+ // Seed the shared cache with the negative result before the composer sees A.
+ const { useResolvedLinkPreviews } = await import(
+ "@/shared/lib/useResolvedLinkPreviews.ts"
+ );
+ const cached = renderHook(() =>
+ useResolvedLinkPreviews([
+ {
+ kind: "generic-link",
+ href: HREF,
+ title: HREF,
+ provider: "example.com",
+ imageUrl: null,
+ },
+ ]),
+ );
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+ assert.equal(fetchCalls, 1, "the negative was cached before paste");
+ cached.unmount();
+
+ const { result, rerender, unmount } = renderHook(
+ ({ content }) => useComposerLinkPreviews(content),
+ { initialProps: { content: "" } },
+ );
+ await act(async () => rerender({ content: `see ${HREF}` }));
+ assert.equal(
+ fetchCalls,
+ 1,
+ "debounce has not resolved the pasted href yet",
+ );
+ assert.equal(result.current.hasPendingSnapshots, true);
+
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS));
+ });
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ });
+ assert.equal(fetchCalls, 2, "paste invalidated and refetched the negative");
+ assert.equal(result.current.getReadyTags().length, 1);
+ unmount();
+ } finally {
+ cleanup();
+ ipcHandlers.clear();
+ }
+});
+// A concurrent render may execute the hook and then suspend before commit. No
+// href presence, block, generation, or tag mutation from that abandoned render
+// may affect the previously committed composer.
+test("an abandoned concurrent render cannot invalidate the committed snapshot tag", async () => {
+ const React = await import("react");
+ const { act, cleanup, render } = await import("@testing-library/react");
+ const { resetLinkPreviewMetadataCache } = await import(
+ "@/shared/lib/useResolvedLinkPreviews.ts"
+ );
+ const { useComposerLinkPreviews } = await import(
+ "./useComposerLinkPreviews.tsx"
+ );
+
+ resetLinkPreviewMetadataCache();
+ ipcHandlers.clear();
+ ipcHandlers.set("get_relay_http_url", () =>
+ Promise.resolve("https://relay.example.com"),
+ );
+ ipcHandlers.set("upload_media_bytes", () =>
+ Promise.resolve({
+ url: "https://relay.example.com/media/stable",
+ sha256: "57ab1e",
+ size: 1,
+ type: "image/png",
+ uploaded: 0,
+ }),
+ );
+ ipcHandlers.set("fetch_link_preview_metadata", () =>
+ Promise.resolve(
+ metadata({
+ imageFetchState: "transient_failure",
+ imageRetryAfterMs: 900_000,
+ }),
+ ),
+ );
+
+ let latest;
+ const never = new Promise(() => {});
+ function Suspender() {
+ throw never;
+ }
+ function Harness({ content, suspend }) {
+ latest = useComposerLinkPreviews(content);
+ return suspend ? React.createElement(Suspender) : null;
+ }
+
+ try {
+ const view = render(
+ React.createElement(Harness, { content: `see ${HREF}`, suspend: false }),
+ );
+ await act(async () => {
+ await new Promise((resolve) => setTimeout(resolve, DEBOUNCE_WAIT_MS));
+ });
+ assert.equal(latest.getReadyTags().length, 1);
+
+ // Render a clear that executes the hook but suspends before commit, then
+ // supersede it with the unchanged committed content.
+ await act(async () => {
+ React.startTransition(() =>
+ view.rerender(
+ React.createElement(Harness, { content: "", suspend: true }),
+ ),
+ );
+ await Promise.resolve();
+ });
+ await act(async () => {
+ view.rerender(
+ React.createElement(Harness, {
+ content: `see ${HREF}`,
+ suspend: false,
+ }),
+ );
+ await Promise.resolve();
+ });
+
+ assert.equal(
+ latest.getReadyTags().length,
+ 1,
+ "the committed tag remains sendable after the abandoned render",
+ );
+ assert.equal(latest.hasPendingSnapshots, false);
+ view.unmount();
+ } finally {
+ cleanup();
+ ipcHandlers.clear();
+ }
+});
diff --git a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
index 3f251a719d1..1ea94821cf8 100644
--- a/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
+++ b/desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
@@ -192,7 +192,75 @@ async function uploadSnapshotMedia(
}
}
-export function useComposerLinkPreviews(content: string, enabled = true) {
+export function extractComposerLinkPreviewHrefs(content: string): string[] {
+ return extractSupportedLinkPreviews(content)
+ .filter((preview) =>
+ preview.href.startsWith("buzz://")
+ ? true
+ : isValidLinkPreviewSnapshotCanonicalUrl(preview.href),
+ )
+ .map((preview) => preview.href);
+}
+
+export interface ComposerLinkPreviewInput {
+ content: string;
+ hrefs: Set;
+ hrefVersions: Map;
+ nextHrefVersion: number;
+}
+
+export function updateComposerLinkPreviewInput(
+ current: ComposerLinkPreviewInput,
+ content: string,
+): ComposerLinkPreviewInput {
+ const nextHrefs = new Set(extractComposerLinkPreviewHrefs(content));
+ const nextVersions = new Map();
+ let nextHrefVersion = current.nextHrefVersion;
+ for (const href of nextHrefs) {
+ if (current.hrefs.has(href)) {
+ const version = current.hrefVersions.get(href);
+ if (version !== undefined) nextVersions.set(href, version);
+ continue;
+ }
+ nextHrefVersion += 1;
+ nextVersions.set(href, nextHrefVersion);
+ }
+ return {
+ content,
+ hrefs: nextHrefs,
+ hrefVersions: nextVersions,
+ nextHrefVersion,
+ };
+}
+
+export function useComposerLinkPreviewInput() {
+ const [input, setInput] = React.useState(() => ({
+ content: "",
+ hrefs: new Set(),
+ hrefVersions: new Map(),
+ nextHrefVersion: 0,
+ }));
+ const update = React.useCallback(
+ (content: string) =>
+ setInput((current) => updateComposerLinkPreviewInput(current, content)),
+ [],
+ );
+ return [input, update] as const;
+}
+
+export function useManagedComposerLinkPreviews(enabled = true) {
+ const [input, updateContent] = useComposerLinkPreviewInput();
+ return {
+ ...useComposerLinkPreviews(input.content, enabled, input.hrefVersions),
+ updateContent,
+ };
+}
+
+export function useComposerLinkPreviews(
+ content: string,
+ enabled = true,
+ liveHrefVersions?: ReadonlyMap,
+) {
const [suppressed, setSuppressed] = React.useState(false);
// Debounce the content that drives resolution so typing a URL character by
// character does not churn a new candidate href (and a flickering card) per
@@ -201,16 +269,14 @@ export function useComposerLinkPreviews(content: string, enabled = true) {
// `hasUnresolvedLiveCandidates` below, which keeps Send disabled until the
// live candidates resolve — so no synchronous flush is needed at submit.
const [debounced, setDebounced] = React.useState(content);
- const debouncedRef = React.useRef(debounced);
- debouncedRef.current = debounced;
React.useEffect(() => {
- if (content === debouncedRef.current) return;
+ if (content === debounced) return;
const timer = window.setTimeout(
() => setDebounced(content),
LINK_PREVIEW_DEBOUNCE_MS,
);
return () => window.clearTimeout(timer);
- }, [content]);
+ }, [content, debounced]);
const extractCandidates = React.useCallback(
(source: string) =>
enabled
@@ -230,12 +296,37 @@ export function useComposerLinkPreviews(content: string, enabled = true) {
// resolved (debounce not yet fired after a paste/keystroke), Send must still
// treat the preview as pending so a fast Enter cannot ship a bare link ahead
// of resolution.
- const liveCandidatesRef = React.useRef([]);
- liveCandidatesRef.current = extractCandidates(content).map(
- (preview) => preview.href,
+ const liveCandidates = React.useMemo(
+ () => extractCandidates(content).map((preview) => preview.href),
+ [extractCandidates, content],
);
+ const liveCandidatesKey = liveCandidates.join("\n");
+ const liveHrefVersionsKey = liveCandidates
+ .map((href) => `${href}\0${liveHrefVersions?.get(href) ?? ""}`)
+ .join("\n");
+ // Submit and async-completion paths read only the last COMMITTED live set.
+ // Updating this during render would let an abandoned concurrent render leak
+ // uncommitted editor content into a later submit or upload completion.
+ const liveCandidatesRef = React.useRef([]);
+ React.useLayoutEffect(() => {
+ liveCandidatesRef.current = liveCandidates;
+ }, [liveCandidates]);
+ // A URL freshly entering the composer (paste, or finishing typing one) should
+ // get a fresh fetch rather than a stale negative cache hit — the user is
+ // actively asking for this link's card now. useResolvedLinkPreviews handles
+ // the timing (invalidate a newly-present href's NEGATIVE cache entry before
+ // it reads the cache); healthy hits and passive message-list scroll are
+ // untouched, so the shared cache still does its job everywhere else. Pass the
+ // LIVE hrefs for newness tracking so a fast clear-then-repaste of the same URL
+ // within the debounce window (which never commits an empty `candidates`) is
+ // still seen as a re-entry and refetched — not served the stale negative.
const resolvedPreviews = useResolvedLinkPreviews(
suppressed ? [] : candidates,
+ {
+ refetchNewNegatives: true,
+ liveHrefs: liveCandidates,
+ liveHrefVersions,
+ },
);
// Entity links resolve to null metadata when the relay lookup has nothing
// for them; keep their safe fallback cards rather than dropping them.
@@ -246,7 +337,7 @@ export function useComposerLinkPreviews(content: string, enabled = true) {
// Clear a "hide previews" suppression as soon as the LIVE draft has no
// supported candidates — not the debounced set, whose lag would otherwise let
// a clear-then-retype race keep suppression stuck on after the draft changed.
- const liveCandidatesEmpty = liveCandidatesRef.current.length === 0;
+ const liveCandidatesEmpty = liveCandidates.length === 0;
React.useEffect(() => {
if (liveCandidatesEmpty) setSuppressed(false);
}, [liveCandidatesEmpty]);
@@ -258,9 +349,17 @@ export function useComposerLinkPreviews(content: string, enabled = true) {
readyTagsByHrefRef.current = readyTags;
const suppressedRef = React.useRef(suppressed);
suppressedRef.current = suppressed;
- const uploadsRef = React.useRef(new Set());
+ const uploadsRef = React.useRef(new Map());
+ // Per-href upload generation. Bumped whenever a stale-negative href re-enters
+ // (below), so an upload started before a re-entry can be recognized as stale
+ // when it settles and dropped without publishing its pre-re-entry tag — the
+ // `reenteringHrefsRef` phase marker alone is not enough, since it is cleared
+ // the moment fresh metadata arrives, which can be before the OLD upload
+ // resolves. Keyed uploads also let a fresh upload start while a superseded one
+ // is still in flight (its generation no longer matches), so the composer is
+ // never left tagless waiting on a doomed upload.
+ const uploadGenerationRef = React.useRef(new Map());
const activeHrefsRef = React.useRef(new Set());
- activeHrefsRef.current = new Set(candidates.map((preview) => preview.href));
React.useEffect(() => {
if (getCachedRelayOrigin()) return;
@@ -279,15 +378,117 @@ export function useComposerLinkPreviews(content: string, enabled = true) {
);
}, [candidates]);
+ // Compare live content with the LAST COMMITTED href set during render, but do
+ // not mutate anything here. This gives synchronous submit/pending selectors a
+ // pure fence for a stale fallback on the candidate render. The layout effect
+ // below commits the block, generation bump, and tag removal only if React
+ // actually commits this render; an abandoned concurrent render leaks nothing.
+ const committedLiveHrefsRef = React.useRef>(new Set());
+ const committedLiveHrefVersionsRef = React.useRef