Skip to content

fix(link-preview): reliably render previews sent right after they resolve - #5245

Merged
tellaho merged 8 commits into
mainfrom
tho/link-preview-snapshot-race
Aug 11, 2026
Merged

fix(link-preview): reliably render previews sent right after they resolve#5245
tellaho merged 8 commits into
mainfrom
tho/link-preview-snapshot-race

Conversation

@tellaho

@tellaho tellaho commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Overview

Category: fix
User impact: Link previews no longer disappear when a message is sent while preview metadata or media is still settling. Fast Enter, rapid Enter, and confirmed-draft auto-send now preserve the preview without duplicate sends or stale tags.

Problem: The composer could look ready before its sender-authored snapshot tag existed. Send paths could then race preview resolution/upload, while debounced preview state could attach a tag for a URL that had already been removed. The same timing also caused confirmed-draft auto-send to be consumed without sending.

Solution:

  • Debounce preview resolution to avoid card flicker while typing, then disable every submit path while a supported external preview settles. A 2-second escape cap still permits a bare-link send if resolution stalls.
  • Keep submit synchronous: acquire a composer-local lock before asynchronous send work, read ready tags from the live URL set, and reject Enter/form submits while a snapshot is pending.
  • Retry confirmed-draft auto-submit until preview settling clears, then submit exactly once.
  • Upload thumbnail and favicon independently. A failed upload shows a toast and degrades to the surviving media (or text-only) rather than leaving the card spinning.
  • Exclude message-edit mode from preview resolution, upload, and Save gating. Edit-time preview snapshots remain follow-up feat(composer): support link previews on message edit (Option B, follow-up to #5245) #5273.
  • Canonicalize fragment-bearing URLs for preview lookup/snapshot identity while preserving the original fragment links in message text.

Link preview state walkthrough

Captured using PR #5245's actual public Open Graph metadata and artwork. The deterministic E2E bridge controls only upload timing so the transient disabled state can be captured reliably.

State Expected behavior Screenshot
1. Snapshot upload pending The real PR preview is visible, but Submit remains disabled until its sendable snapshot tag is ready. Click and Enter cannot send a bare link during the settling window. PR 5245 pasted with its real preview visible and Submit disabled
2. Snapshot ready Once snapshot upload settles and the tag is ready, the same preview remains and Submit becomes active. PR 5245 preview ready in the composer with Submit enabled
3. Message sent The sent event carries the snapshot tag and renders the PR title, description, and artwork inline instead of degrading to a bare URL. PR 5245 real link preview rendered inline in the message list

Regression coverage

  • Enter during metadata resolution or snapshot upload cannot send early.
  • Paste-and-immediate-Enter sends after settling; rapid Enter submits exactly once.
  • Confirmed-draft auto-send waits for settling and fires exactly once.
  • Removed/replaced URLs cannot leak stale snapshot tags or media refs.
  • Thumbnail upload failure toasts and sends with the surviving favicon.
  • Edit mode does not resolve/upload previews or gate Save.
  • Fragment variants share a canonical preview while original fragment links remain clickable.
  • Existing ready-preview, suppression, bare-link fallback, and multi-preview behavior remains covered.

Reproduction steps

  1. Open a channel and paste a supported external URL into the composer.
  2. Press Enter immediately, before preview metadata/media finishes settling.
  3. Before this fix, the event could be sent without its preview snapshot (or confirmed-draft auto-send could be lost). With this fix, submit waits behind the disabled state and fires once with the matching snapshot tag.
  4. Remove or replace the URL and press Enter inside the debounce window. The sent event contains tags only for URLs still present in the submitted content.

Validation

All required PR checks are green, including Desktop Core, Desktop Smoke E2E shards, Desktop E2E Integration shards, macOS build, security checks, and DCO.

@air3ijai

air3ijai commented Aug 8, 2026

Copy link
Copy Markdown

A quick Slack/Buzz comparison - https://github.com/block/buzz/pull/3818

Slack

Screenshot 2026-08-08 at 07 48 27

Buzz

Screenshot 2026-08-08 at 07 55 55

@DocNR

DocNR commented Aug 9, 2026

Copy link
Copy Markdown

Still reproduces on Desktop 0.5.7 (macOS 26, self-hosted relay).

Data point on the size of the race window: my relay is self-hosted and reached over Tailscale, so the snapshot upload leg is slower than a hosted setup — the gap between "card looks resolved" and "snapshot tag exists" is correspondingly wider. Pasting a link and waiting ~2s before Enter lands the preview; hitting Enter straight away reliably does not. Matches the mechanism described above.

Possibly a second symptom of the same root cause: when send happens inside that window, the preview card is also not cleared from the composer — the message posts as a bare link and the card stays sitting in the input box afterwards. If the card's "done" state is what drives teardown, a card that never genuinely resolved would explain both halves. Happy to file that separately if it's unrelated.

@tellaho

tellaho commented Aug 10, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the self-hosted angle @DocNR will keep poking at this

@tellaho tellaho changed the title fix(composer): land link preview snapshots sent from a resolved-looking card fix(link-preview): reliably render previews sent right after they resolve Aug 10, 2026
@tellaho
tellaho force-pushed the tho/link-preview-snapshot-race branch from 05974d4 to 4f04994 Compare August 10, 2026 21:51
@tellaho
tellaho marked this pull request as ready for review August 10, 2026 21:59
@tellaho
tellaho requested a review from a team as a code owner August 10, 2026 21:59
@tellaho
tellaho force-pushed the tho/link-preview-snapshot-race branch from 4f04994 to 9061992 Compare August 10, 2026 22:01

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewing on Wes’s behalf.

Blocker — draft auto-send is consumed while preview gating is pending, so the confirmed draft is never sent.

This change explicitly adds hasPendingLinkPreviewSnapshotsRef.current to submitMessage as a guard for “Enter, form, auto-submit.” But the auto-submit effect is a one-shot: it clears ?autoSend first, schedules exactly one submitMessageRef.current() call, and never retries. A draft containing a supported HTTPS link will normally still be inside the new 350 ms debounce/metadata/upload settling window when that zero-delay callback runs. submitMessage therefore returns at the new pending-snapshot guard; the trigger has already been cleared and the mount-only effect cannot fire again. The user confirmed “Send message,” but nothing sends.

Please make auto-submit wait/retry until preview settling finishes (or the two-second escape cap fires) before consuming the trigger, while preserving the loop-prevention invariant. Add coverage that drives the actual draft auto-send path with a supported link and proves exactly one message is eventually sent. The current preview tests only exercise manual button/Enter paths.

The PR description also needs updating to match the final implementation: it still says Send awaits a capped in-flight upload and that getReadyTags became async, but head 90619924f instead disables/guards submission until readiness and keeps getReadyTags synchronous.

I did not rerun CI-equivalent suites locally; existing CI is green at this head, but it does not cover this one-shot auto-send transition.

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adversarial review at exact head 90619924f. I found a stale-tag race caused by using the debounced candidate set as the submit identity. This is distinct from the draft auto-submit blocker already reported by another reviewer. I did not duplicate CI-equivalent suites locally; CI is green, and the finding follows from the synchronous submit path versus the intentional 350 ms debounce.

Comment thread desktop/src/features/messages/ui/useComposerLinkPreviews.tsx Outdated
@tellaho

tellaho commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@wesbillman both blockers now have regression tests, pushed as 58f65ed.

Blocker A (auto-send drop) — e2e + unit
Extracted the mount-effect logic into a pure scheduleSettleGatedAutoSubmit helper: poll while a snapshot is pending, then submit exactly once when settling clears (never zero, never twice). New e2e drives the real Drafts-panel Send→confirm flow via in-app nav (page.goto to /channels/... 404s under the static test server) and asserts exactly one send carrying the resolved snapshot tag. New fake-timer unit test proves the drop-guard: waits while pending, sends once on clear, cleanup cancels the orphan send.

Blocker B (tag leak) — unit
Extracted selectSubmitTags, keyed off the LIVE href set (never the debounced one), and rewired getReadyTags to it. Unit test proves a deleted URL's tag is never emitted, a replacement link emits only its own tag, live order is preserved, and suppression emits the "none" marker.

Full disclosure on Blocker B: I wrote it as an e2e first and it was flaky — sending inside the 350ms debounce window didn't reliably fire a submit headless, so it caught nothing. Killed it, replaced with the deterministic unit test above. Both the leak-guard and drop-guard cases were verified to fail on pre-fix behavior, so they actually catch the bugs.

Bonus: extracting the two helpers also pulled MessageComposer.tsx back under the file-size cap.

Green locally: full unit suite 4595 passed, Blocker A e2e passed, typecheck + biome + file-size clean.

…ng card

The composer card flipped to a "done" state on local metadata resolution, but the sendable snapshot tag only exists after the snapshot media finishes uploading to the relay. Sending during that window shipped a bare link with no snapshot tag, so the message rendered without an inline preview even though the composer looked ready.

Make the card claim "done" only once the snapshot tag is actually built, and have Send briefly wait (capped) for in-flight snapshot uploads so a fast Enter still lands the preview. The intentional no-wait path for genuinely-pending metadata is preserved.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
The send button flickered ready -> not-ready -> ready as a pasted link resolved: it showed ready before metadata came back, disabled once the snapshot upload started, then re-enabled when the tag landed. It never signalled 'not ready yet' during the initial metadata phase.

Disable Send for the whole settling window (metadata pending, or snapshot uploading before its tag exists) so the button reads as one continuous not-ready state with no flicker. buzz:// links never snapshot, so they are excluded and never disable Send. A disable cap re-enables Send if a link's metadata or upload stalls, so a dead or slow link never traps the composer.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…load failure

A resolved link could fail to render inline in the sent message, and rapid
Enter could double-submit or ship a bare link ahead of its snapshot. This
hardens the whole composer send path:

- Take a synchronous submit lock before any await so a second Enter cannot
  race in and double-submit.
- Enforce disabled-until-ready on every entry point (Enter, form, auto-submit)
  via a hasPendingSnapshots ref, not just the send button.
- Exclude edit mode from preview resolution, upload, and gating (edits never
  persist snapshots), passing editTarget == null to disable the hook.
- Debounce the preview-driving content so typing a URL character by character
  no longer churns a flickering card per keystroke.
- Key the suppression reset on live candidates, not the debounced set, so
  "hide previews" cannot get stuck across clear-then-retype.
- Upload thumbnail and favicon independently: a single failed upload degrades
  to the survivor instead of dropping both, with one toast naming what failed.

Tests: real Enter-during-upload guard, rapid-Enter-exactly-once,
paste-and-immediate-Enter, thumbnail-upload-failure degrades to favicon, and
edit-mode-excludes-previews. Full messaging spec: 53 passed.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
A composer link with a URL fragment (e.g. a GitHub PR review anchor like
pull/3767#pullrequestreview-...) rendered no preview card at all, and with
multiple links only the last fragment-less link previewed.

Two fragment-handling faults compounded here:
- The composer candidate filter runs each preview href through
  isValidLinkPreviewSnapshotCanonicalUrl, which rejects any URL containing
  '#', so fragment links never entered the candidate set.
- Metadata was fetched by the raw fragment-bearing href while the cache and
  render lookup keyed on the fragment-stripped canonical URL, an identity
  mismatch.

Fix both at the identity source: createPreview now strips the fragment so
every downstream consumer -- eligibility filter, dedupe, snapshot
canonicalUrl, card key, and fetch input -- shares one fragment-less page
identity. Fragment variants of the same page collapse to a single card, and
the message body keeps the raw URL so click-through to the anchor still
works. Buzz entity/git links are unaffected (built via separate canonical
builders that preserve query strings and only clear the hash).

Tests: new unit coverage that parseSupportedLinkPreview strips the fragment
and extractSupportedLinkPreviews collapses fragment variants of one page
while keeping a distinct second page; new e2e asserting two fragments of one
page plus a second page render exactly two cards with both original prose
links present and clickable; updated the trailing-fragment e2e to expect
canonical (fragment-less) snapshot tags. Full unit suite 4537 passed;
messaging e2e 54 passed.

Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
…d tag-leak blockers

Two reviewer-flagged blockers on the settling-window fix lacked regression tests; both fixes are now backed by deterministic coverage and the fix logic is extracted into pure, testable helpers.

Blocker A (auto-send drop): a Drafts-panel Send for a draft with a supported link is normally still inside the ~350 ms settling window when the mount-only auto-submit effect fires. The prior effect cleared ?autoSend then fired submit once at setTimeout(0); submit bailed at the pending-snapshot guard and the one-shot never retried, silently dropping the draft. Extracted scheduleSettleGatedAutoSubmit (poll while pending, then submit exactly once) as a timer-injectable helper. New unit test drives it with a fake timer (waits while pending, sends once on clear, cleanup cancels); new e2e drives the real Drafts-panel Send confirm flow (in-app nav, not page.goto to /channels/... which 404s under the static test server) and asserts exactly one send carrying the resolved snapshot tag.

Blocker B (tag leak): resolving link A, deleting it, and sending replacement text within the debounce left A in the debounced active set, so submit rode A's snapshot tag (with media refs) onto a body no longer containing A. Extracted the pure selectSubmitTags selector keyed off the LIVE href set (never the debounced one) and rewired getReadyTags to it. New unit test proves a removed URL's tag is never emitted, a replacement link emits only its own tag, live order is preserved, and suppression emits the none marker. The e2e form of this was flaky (send inside the 350 ms window did not reliably fire headless), so a deterministic unit test replaces it.

Verified: leak-guard and drop-guard cases fail on pre-fix behavior. Full unit suite 4595 passed; Blocker A e2e passed; typecheck, biome, and file-size checks clean.
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho
tellaho force-pushed the tho/link-preview-snapshot-race branch from 58f65ed to 6ec255a Compare August 11, 2026 01:15

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewing on Wes's behalf at exact head 6ec255a6050fa40a85a380e3d82eda250b5aae46.

The two prior code blockers are fixed:

  • scheduleSettleGatedAutoSubmit now retries the real one-shot draft auto-send while snapshot settling is pending, submits once after readiness/cap expiry, and cancels its timer on unmount. The new Drafts-panel E2E exercises the production confirmation/navigation path and asserts exactly one send with the snapshot tag.
  • Submit-tag selection now intersects the ready-tag map with the live candidate hrefs via selectSubmitTags, so a removed URL lingering in debounced state cannot leak its snapshot metadata/media references. Deterministic unit coverage exercises removed and replaced hrefs.

Remaining blocker — update the PR description to describe the code actually being reviewed. It still says getReadyTags became async, Send awaits it, and SNAPSHOT_SEND_WAIT_CAP_MS caps the wait. None of that exists at this head. The implementation uses synchronous tag selection plus hasPendingSnapshots/SNAPSHOT_SETTLE_DISABLE_CAP_MS submission gating, and now includes a settle-gated draft auto-submit scheduler. The reproduction/test summary is stale for the same reason. Please rewrite the Solution, file-change details, and verification text to match the shipped design.

Focused local regression tests passed 9/9 at this exact head. I did not duplicate the broad CI suite; GitHub's Desktop Core job has already passed lint, unit tests, build, clippy, check, and Rust tests and is still completing its final compiled-flag verification step. All other applicable checks currently pass.

wesbillman
wesbillman previously approved these changes Aug 11, 2026

@wesbillman wesbillman left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed the exact updated head 6ec255a6050fa40a85a380e3d82eda250b5aae46 adversarially. The two prior blockers are fixed:

  • Submit-tag selection now intersects the ready-tag map with the current live candidate ordering (liveCandidatesRef.current), so a resolved/debounced URL that has been removed cannot contribute its snapshot or media references to the outgoing event. The selector coverage includes removal, replacement, ordering, missing tags, and suppression.
  • Draft auto-send now stays armed while the snapshot guard is pending and invokes the real current submitMessage exactly once after the guard clears (including the existing 2s anti-trap expiry). Cleanup clears both initial and retry timers, so channel navigation/unmount cannot leave an orphan send. The Drafts-panel E2E drives the actual confirmation/navigation route and asserts exactly one send_channel_message with the expected snapshot URL; this is materially stronger than the helper tests alone.

I also traced the helper-to-composer lifecycle, ref freshness, cap transition, submit lock, disabled/upload/edit/mention guards, and thread capture path. I found no remaining code blocker in this head. The scheduler deliberately retries only preview settling; other submit guards retain the existing one-shot auto-send semantics rather than being broadened by this fix.

Non-code cleanup still needed: the PR body is materially stale. It says getReadyTags is async and Send awaits a capped in-flight upload, while this head uses synchronous live-set tag selection plus UI/submit gating and settle-gated draft auto-submit. Please rewrite that description before merge so reviewers and release readers are not told the opposite architecture.

Resolve the composer preview conflict by preserving snapshot-ready gating for
external links while retaining recipient-resolved Buzz entity fallbacks.

Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
wesbillman
wesbillman previously approved these changes Aug 11, 2026
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
Co-authored-by: Taylor Ho <taylorkmho@gmail.com>
Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
@tellaho
tellaho merged commit be48ce9 into main Aug 11, 2026
26 checks passed
@tellaho
tellaho deleted the tho/link-preview-snapshot-race branch August 11, 2026 03:27
atishpatel added a commit that referenced this pull request Aug 11, 2026
…overy

* origin/main:
  fix(link-preview): reliably render previews sent right after they resolve (#5245)
  fix(link-preview): restore Buzz entity link cards (#5494)
  chore(release): release Buzz Desktop version 0.5.9 (#5521)
  feat(cli): add --visibility flag to channels update (#5119)
  Polish desktop onboarding flow (#5310)
  fix(desktop): quiesce renderer polling while hidden (#3677) (#5490)
  fix(channels): restore member invitations to private channels (#5493)
  perf(ci): experiment with sccache for relay builds (#5224)
  fix(desktop): bound nine unbounded localStorage stores (#5454)
  feat(desktop): time-based sweep for stale localStorage caches (#5453)
  ci(release): gate OSS desktop auto-update promotion (#5398)
  fix(release): pin desktop PR operations to block/buzz (#5212)
  fix(search): surface exact short profile names (#5480)
  Reduce repeated ACP session context (#5423)
  feat(desktop): NIP-AM agent-usage backend — P2 emission/transport/archive + P4a aggregation/D6 (#4000)
  fix(desktop): resolve overlapping member mentions (#5225)

Signed-off-by: Brainy Bumble <0ed7657b57c0e8a9f5288390dd6c8d5d0a3a06abe9b01b9006814f52077d6cdf@buzz.block.builderlab.xyz>
Co-authored-by: Atish Patel <atish@squareup.com>
Signed-off-by: Atish Patel <atish@squareup.com>
wpfleger96 added a commit that referenced this pull request Aug 11, 2026
Bring in main's runtime.rs mesh acp_model wire translation so local
checks and CI both run on the merged tree. Clean auto-merge; the PR's
fail-closed spawn gating and main's model translation touch disjoint
regions of spawn_agent_child.

* origin/main: (24 commits)
  Improve desktop search scoping (#5306)
  Add glass appearance and cohesive settings (#5478)
  Add Send to channel for thread messages (#5305)
  Fix macOS attachment picker lifecycle and allow inert HTML downloads (#5569)
  fix(desktop): preserve fresh channel timelines (#5577)
  fix(desktop): suppress fresh focus-return refetches for channels and home-feed (#5535)
  chore: mesh upgrade, clean up legacy special case code, simplify model selection for mesh (#5289)
  fix(desktop): preserve theme when opening communities (#5266)
  fix(link-preview): resolve YouTube videos through oEmbed (#5520)
  fix(buzz-agent): harden Databricks OAuth token cache and callback (#5534)
  fix(link-preview): reliably render previews sent right after they resolve (#5245)
  fix(link-preview): restore Buzz entity link cards (#5494)
  chore(release): release Buzz Desktop version 0.5.9 (#5521)
  feat(cli): add --visibility flag to channels update (#5119)
  Polish desktop onboarding flow (#5310)
  fix(desktop): quiesce renderer polling while hidden (#3677) (#5490)
  fix(channels): restore member invitations to private channels (#5493)
  perf(ci): experiment with sccache for relay builds (#5224)
  fix(desktop): bound nine unbounded localStorage stores (#5454)
  feat(desktop): time-based sweep for stale localStorage caches (#5453)
  ...

Signed-off-by: Duncan <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@buzz.block.builderlab.xyz>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants