fix(link-preview): refetch a link when it re-enters the composer - #5510
fix(link-preview): refetch a link when it re-enters the composer#5510tellaho wants to merge 1 commit into
Conversation
822a60e to
cb3b91e
Compare
cb3b91e to
bff67d0
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing on Wes's behalf. The new behavior does not cover one of the two negative-cache states it explicitly promises to retry. I reproduced this with a focused loader test: after caching imageFetchState: "transient_failure", invalidateNegative() followed by load() returned the same cached transient result and the fetch count stayed at 1. Please make transient failures invalidatable (or narrow the stated behavior if they are intentionally governed only by their retry boundary) and add coverage for that case. The current test also says it preserves an in-flight fetch but never creates one; please exercise that claimed invariant directly. Separately, this stacked PR is currently conflicting/dirty against its advanced base, so its green checks do not establish merge readiness for the eventual rebased head.
bff67d0 to
d8a6e83
Compare
d8a6e83 to
098acdf
Compare
wesbillman
left a comment
There was a problem hiding this comment.
Re-review by Carl on Wes's behalf: the prior blocker is resolved at this head. invalidateNegative now invalidates both hard null misses and transient failures, while preserving healthy and in-flight entries; focused tests cover all four states. I found no new actionable issues. Per owner policy I am not auto-approving; Wes can approve this head.
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing on Wes's behalf. Do not merge this head: invalidating the shared metadata loader does not invalidate useResolvedLinkPreviews' retained resolvedMetadata state.
On URL re-entry, the effect deletes the loader entry at useResolvedLinkPreviews.ts:463-475, but the render that scheduled that effect has already read the old local metadata at :538-545, and the effect never removes that key from local state. For a cached transient_failure, the composer therefore still sees a resolved snapshotReady fallback and can start producing a ready snapshot tag before the retry resolves (useComposerLinkPreviews.tsx:289-349), allowing stale metadata to become sendable. A cached null likewise remains the rendered no-preview state until the retry completes.
Please clear or generation-invalidate the corresponding local resolved state when a newly re-entered negative is invalidated, so that the retry is represented as pending immediately. Add a hook/composer-level regression covering transient failure -> URL removed -> URL re-entered -> pending/no ready tag -> successful retry wins. The loader-only tests do not exercise this second cache layer.
098acdf to
d647130
Compare
|
@wesbillman thanks for the catch — your 14:53Z review was right, no pushback. Fix pushed. Summary: Root cause: Invalidating the shared loader cache alone wasn't enough. Fix (
Regression test: hook-level, drives the real hook via Verification: tsc clean, biome clean, full desktop suite 4609/4609 pass, pre-push hooks green. Rebased onto latest |
928fe57 to
24785e3
Compare
|
Follow-up — also found this during an adversarial re-review, and fixed it in the same commit (new head Also found: an in-flight shared-cache entry defeated the local-state clear. The
Same defect class as the original bug, just via the in-flight path instead of the settled-negative path. Fix ( Regression test: a second hook-level test drives the exact interleaving — retained transient local state → URL leaves → shared cache dropped → a message-list instance starts a shared in-flight fetch → composer re-enters → asserts pending / no Verification: tsc clean, biome clean, full hook test file 23/23, pre-push hooks green (desktop-check + typecheck + full desktop-test at head Credit to an adversarial second-reviewer pass for catching the in-flight interleaving. |
24785e3 to
5239f41
Compare
|
Also found + fixed a second call-site gap (thanks @wesbillman for the earlier round; this one surfaced in a follow-up review). The bug: the composer feeds The fix (2 layers):
Test: new Full desktop suite green (4673 pass); pre-push hook |
…mposer A URL freshly entering the composer (paste or finished typing) should get a fresh fetch rather than a stale negative cache hit — the user is actively asking for that card now. useResolvedLinkPreviews gains an opt-in refetchNewNegatives mode that invalidates a newly-present href's NEGATIVE cache entry (null/transient-fail) before reading the cache, then refetches. Healthy cached hits and passive message-list scroll stay on the shared cache untouched. Dropping the shared loader entry alone was not enough: the hook retains its own resolvedMetadata state, and the render that scheduled the invalidating effect had already read the stale negative from it. A re-entered transient_failure therefore stayed a snapshotReady fallback the composer could turn into a sendable snapshot tag from stale metadata before the retry resolved. invalidateNegative now reports whether it dropped an entry, and the effect clears the matching local key in step so the re-entry renders as pending until the fresh load wins. Adds a hook-level regression covering transient failure -> URL removed -> re-entered -> pending/no snapshotReady -> successful retry. The shared loader is shared across every hook instance, so a re-entry can coincide with another instance's in-flight fetch for the same canonical URL. invalidateNegative deliberately leaves an in-flight Promise alone, so gating the local-state clear on its return value missed that case, leaving the retained transient_failure as a sendable tag until the shared fetch resolved. The effect now clears this hook's own negative local key for every re-entered href regardless of the shared entry's shape, then coalesces onto any in-flight fetch. Adds a hook-level regression driving the retained-negative + shared in-flight fetch + re-entry interleaving. The composer feeds useResolvedLinkPreviews from DEBOUNCED content, so a fast clear-then-repaste of the same URL inside the 350ms window never commits an empty candidate set — the resolver's newness tracker never saw the URL leave and never refetched, and the stale snapshot tag stayed sendable. useResolvedLinkPreviews now accepts the caller's LIVE hrefs and judges newness against them, so the debounce-swallowed leave/re-entry still forces the refetch. The composer detects the same re-entry at render time (React batches the empty->repaste renders, so an effect keyed on the live set never observes the transition) and blocks the re-entered href until the resolver's forced refetch visibly cycles through pending: its stale tag is dropped from state and excluded from the sendable output, the upload effect and any in-flight upload will not rebuild a tag from the pre-clear metadata, and only a fresh result re-tags. Only the sendable negative case (fallback) is blocked; a healthy re-entry keeps its instant card. Adds a composer-hook regression driving the real hook through the fast gesture: stale tag gone + Send held pending while the deferred refetch is in flight, then a fresh tag carrying the newly-fetched media once it resolves. The `reenteringHrefsRef` phase marker alone did not fence a media upload that was already in flight when the URL re-entered: the upload effect deletes the marker the moment the forced refetch reaches fresh ready metadata, which can happen before the OLD upload settles, so the stale upload's completion then passed the marker guard and published a snapshot tag built from the pre-clear metadata. A secondary hole left the composer tagless: because the old href still occupied `uploadsRef`, the effect skipped starting a fresh upload, and the stale upload's `.finally` cleared the slot without any state change to re-arm one. Both are fixed with a per-href upload generation: `uploadsRef` becomes a Map<href, generation> and a live re-entry bumps `uploadGenerationRef`. The upload effect captures the current generation, its dedup guard compares against it (so a superseded in-flight upload no longer blocks starting the fresh one), and its completion is fenced by a durable generation check independent of the phase marker (so a stale upload becomes a no-op even after the marker was cleared). `.finally` only clears the slot when it still owns the current generation, so it cannot evict the fresh upload's entry. Adds a composer-hook regression that holds the stale upload unresolved across the clear + re-paste and the fresh-metadata resolution, proves a fresh upload starts and its tag wins, then releases the stale upload and proves it cannot publish its pre-clear tag. Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: Taylor Ho <taylorkmho@gmail.com>
5239f41 to
50ead43
Compare
|
@wesbillman Third blocker addressed (thanks to Princess Donut's third review pass) — head is now The race: an old in-flight media upload (U1), started from the pre-clear transient-fallback metadata, could still publish a snapshot tag after the URL was cleared, re-pasted, and freshly resolved. The Fix — per-href upload generation token:
Regression: new composer-hook test holds U1 unresolved across the clear + re-paste and the fresh-metadata resolution, proves U2 starts and its fresh tag wins, then releases U1 and proves it cannot publish its pre-clear tag. Proven genuine — it fails on the prior head both ways (U2 never starts with the old |
|
📝 Scope adjustment — description updated to match what actually shipped. The original description covered only the first fix: an opt-in
Net diff: |
wesbillman
left a comment
There was a problem hiding this comment.
Carl reviewing on Wes's behalf.
The stale-upload generation fencing is sound for committed renders, but the re-entry detector introduces render-phase side effects that are unsafe under React concurrent rendering. At useComposerLinkPreviews.tsx:327-373, render mutates prevLiveHrefsRef, reenteringHrefsRef, and uploadGenerationRef, then queues a setReadyTags microtask. React may restart or abandon that render before commit (Suspense, a transition, or another higher-priority update), but none of those mutations are rolled back and the queued state update still runs.
That can make an uncommitted clear/re-paste count as a real re-entry: the committed composer still contains the old URL, yet its valid tag can be dropped, its upload generation invalidated, and the href left in blocked. If the corresponding resolver transition was also abandoned, nothing guarantees the required pending -> ready cycle that releases the block. After the two-second cap, the user can send a bare link despite having a valid snapshot before the abandoned render.
Please keep render pure. Model committed live-presence/generation as state or update it in a layout/effect phase, while retaining a synchronous submit fence derived from current content/state rather than mutating refs and scheduling state during render. Add a regression that starts and abandons/restarts a re-entry render (for example via a suspending sibling) and proves the previously committed tag remains sendable and no block/generation change leaks from the abandoned render.
wesbillman
left a comment
There was a problem hiding this comment.
Reviewing on Wes's behalf at exact head 50ead432d13ec0e2b6c97fbcec4fd2b886bbb6c1.
P1 — the ordinary blank-composer → paste path consumes “newness” before the debounced preview exists, so the negative cache is not invalidated.
useComposerLinkPreviews passes debounced candidates as previews, but live hrefs immediately (useComposerLinkPreviews.tsx:225-248). In useResolvedLinkPreviews, every effect run replaces seenHrefsRef with the live set (useResolvedLinkPreviews.ts:493-511), while invalidation can only happen inside for (const preview of previews) (:497-509).
Concrete production sequence:
- Composer is mounted blank:
previews=[],liveHrefs=[],seen={}. - User pastes URL A whose shared cache contains a negative. Before the 350ms debounce,
previews=[]butliveHrefs=[A]. - The effect has no preview to invalidate, yet sets
seenHrefsRef.current = {A}. - After debounce,
previews=[A]andliveHrefs=[A];seen.has(A)is now true, so invalidation is skipped and the stale negative is reused.
That is the headline behavior this PR promises to fix. The composer regressions miss it because they mount the hook with URL A already in initialProps, which initializes debounced to A (useComposerLinkPreviews.test.mjs:123-130); they do not drive the normal mounted-empty → paste transition. The fast clear/re-paste test works only because debounced candidates remains [A] while the live set temporarily empties.
Required fix: do not mark a live href as seen/handled until there is a corresponding resolvable preview and its negative invalidation has been attempted (or track live presence separately from pending invalidation). Add a composer-level regression that mounts with empty content, seeds/caches a negative for A, then pastes A and proves the post-debounce load refetches rather than reusing the negative.
The generation fencing for the already-covered fast re-entry/upload races otherwise looks internally consistent, but this gap leaves the primary user gesture broken. CARL, THE DEBOUNCE ATE THE INVALIDATION. 👑🐈
Overview
Category: fix
User Impact: When a user re-pastes (or finishes typing) a link that previously failed to load a preview, the composer now refetches it immediately and can never send a snapshot preview built from the old, stale metadata.
Problem: The link-preview cache is shared with passive message-list scroll, so a URL that resolved to a negative result (a hard
nullmiss or a transient fetch failure) stayed cached and re-usable. Re-pasting that exact link into the composer served the stale negative and never refetched. Worse, the stale metadata was stillsnapshotReady, so a fast clear-then-repaste could attach a stale snapshot preview tag to the sent message — a preview that no longer matched the link.Solution: A freshly-entering link is forced to refetch, and the composer is fenced against ever shipping a tag built from pre-re-entry metadata. This closes three distinct races surfaced over successive review passes: (1) the shared negative cache being reused on re-entry; (2) the resolver's debounce swallowing a fast clear+re-paste so the re-entry was invisible and the stale tag stayed sendable; and (3) an in-flight media upload started from the stale metadata publishing its tag after fresh metadata had already arrived. Healthy cached hits are never touched (instant card, no redundant fetch), and passive message-list scroll — which never opts in — keeps riding the shared cache exactly as before.
File changes
desktop/src/shared/lib/useResolvedLinkPreviews.ts
Adds a loader
invalidateNegative(href)that drops a cached negative result (resolvednullor transient fail) while leaving healthy hits and in-flight promises alone, and arefetchNewNegativesoption that invalidates each newly-present href's negative entry before the peek/load loop reads the cache. Also adds an optionalliveHrefsinput so newness is judged against the caller's LIVE (undebounced) content — a debounce-swallowed leave/re-entry of the same URL still counts as new. Because the hook retains its own resolved metadata (the render that scheduled the effect already read the stale negative from it), it also clears its OWN negative key for every re-entered href, so the link renders as pending until the fresh load wins.buzz://entity links are skipped (they resolve off the relay, not this cache).desktop/src/features/messages/ui/useComposerLinkPreviews.tsx
Opts the composer into
refetchNewNegativesand feeds it the live hrefs. Detects a same-URL re-entry at render time (React batches the empty→repaste renders, so an effect keyed on the live set never observes the transition), then blocks the re-entered href until the resolver's forced refetch visibly cycles through pending: its stale ready tag is dropped from state and excluded from the sendable output until a fresh result re-tags. Only the sendable negative case (fallback) is blocked; a healthy (image) re-entry keeps its instant card. Adds a per-href upload generation token (uploadsRefbecomesMap<href, generation>): a live re-entry bumps the generation, the upload effect's dedup guard and completion are generation-aware, so an in-flight upload from stale metadata cannot publish its tag after settling and a fresh upload can start even while the superseded one is still in flight.desktop/src/shared/lib/useResolvedLinkPreviews.test.mjs
Adds resolver-level regressions:
invalidateNegativedrops a cached miss (next load refetches) but preserves a healthy hit (no redundant fetch); transient failure → URL removed → re-entered renders pending/not-snapshotReadyuntil a successful retry; and the retained-negative + shared in-flight-fetch + re-entry interleaving clears the local negative regardless of the shared entry's shape.desktop/src/features/messages/ui/useComposerLinkPreviews.test.mjs
Adds composer-hook regressions driving the REAL hook through the hostile gestures: a fast clear+re-paste inside the debounce window drops the stale tag and holds Send pending until a fresh tag carrying the newly-fetched media lands; and a stale in-flight upload held across the clear+re-paste and fresh-metadata resolution cannot publish its pre-clear tag, while a fresh upload starts and its tag wins.
Reproduction Steps
Notes
Scope grew across three review passes from the original single resolver opt-in into a full defense against shipping stale snapshot tags on link re-entry — see the scope-adjustment comment on this PR for the detail. Stacked on #5245 (
tho/link-preview-snapshot-race), whose rewrite ofuseComposerLinkPreviews.tsxis the sole overlapping file. The transient-retry work stays in #5502, which touches no composer file and remains based on main.