eleven / ocho Public

ios+mac: your own just-posted link unfurls without a reload (#1495)

A card for a link you posted yourself only appeared after reopening the app.
The row for a pending send is rebuilt the instant its echo swaps the pending
id for the real server id, and the rebuild cancels the old row's `.task` —
which cancelled the unfurl fetch parented to it. The cancellation came back
as a failed unfurl, negative-cached the URL for a minute, and the replacement
row was told there was nothing to show. (A message someone else posts never
changes id, which is why theirs unfurled fine.)

Two rules now hold the fetch open: it runs in an unstructured task that
doesn't inherit the caller's cancellation, and a caller arriving mid-fetch
awaits that same task instead of being handed nil. The card view also refuses
to write a cancelled task's answer back over what it already has.

Tests pin both (they fail against the old code): the fetch outlives a
cancelled caller, and two concurrent callers share one fetch.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by codex agent, operated by paul@keymail.dev fbf6621d1a1a8353f19794c399d99848e61d53ac parent fad6c75
3 files changed, +143 −12
  • ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift +41 −11
  • ios/LChatCore/Sources/LChatCore/MessageBubble.swift +9 −1
  • ios/LChatCore/Tests/LChatCoreTests/UnfurlSharingTests.swift +93 −0
diff --git a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
index f723f2a..4d755c6 100644
--- a/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
+++ b/ios/LChatCore/Sources/LChatCore/ChatFeedModel.swift
@@ -372,7 +372,12 @@ public final class ChatFeedModel: ObservableObject {
// (chat.js addLinkPreview); the TTL is what stops re-render retry storms.
private var unfurlCache: [URL: LinkPreview] = [:]
private var unfurlEmptyAt: [URL: Date] = [:]
- private var unfurlInFlight: Set<URL> = []
+ // The live fetch per URL, shared by every caller — NOT a bare in-flight
+ // flag. A second caller used to get nil back and cache that as "nothing to
+ // show", which is exactly what happens to your OWN message: its row is
+ // rebuilt the instant the echo swaps the pending id for the real one, and
+ // the replacement row asks again mid-fetch.
+ private var unfurlInFlight: [URL: Task<LinkPreview?, Never>] = [:]
private static let unfurlRetryAfter: TimeInterval = 60
/// Synchronous peek at the unfurl cache — height pre-measurement needs to
@@ -385,20 +390,45 @@ public final class ChatFeedModel: ObservableObject {
return .none
}
+ #if DEBUG
+ /// Stands in for the server fetch, so unfurl's sharing and cancellation
+ /// rules are testable without a network.
+ public var _testUnfurlFetch: ((URL) async -> LinkPreview?)?
+ #endif
+
+ private var unfurlFetch: ((URL) async -> LinkPreview?)? {
+ #if DEBUG
+ if let _testUnfurlFetch { return _testUnfurlFetch }
+ #endif
+ guard let api else { return nil }
+ return { await api.unfurl($0) }
+ }
+
/// The link-preview card for a URL (cached; nil = nothing to show).
public func unfurl(_ url: URL) async -> LinkPreview? {
if let known = cachedUnfurl(url) { return known }
- guard let api, !unfurlInFlight.contains(url) else { return nil }
- unfurlInFlight.insert(url)
- defer { unfurlInFlight.remove(url) }
- let preview = await api.unfurl(url)
- if let preview {
- unfurlCache[url] = preview
- unfurlEmptyAt[url] = nil
- } else {
- unfurlEmptyAt[url] = Date()
+ guard let fetch = unfurlFetch else { return nil }
+ // The fetch must OUTLIVE its caller. A row's `.task` is cancelled when
+ // the row goes away, and the poster's own row goes away a beat after
+ // the send — a child fetch died with it, came back as a failure, and
+ // negative-cached the card away for a minute, so the card only ever
+ // appeared after a reload. An unstructured Task doesn't inherit that
+ // cancellation, so the replacement row simply awaits the same fetch.
+ if let live = unfurlInFlight[url] { return await live.value }
+ let task = Task { [weak self] () -> LinkPreview? in
+ let preview = await fetch(url)
+ guard let self else { return preview }
+ if let preview {
+ self.unfurlCache[url] = preview
+ self.unfurlEmptyAt[url] = nil
+ } else {
+ self.unfurlEmptyAt[url] = Date()
+ }
+ self.unfurlInFlight[url] = nil
+ return preview
}
- return preview
+ unfurlInFlight[url] = task
+ return await task.value
}
// ---- uploads (the native chatAppUpload) -------------------------------
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
index 76f9181..aa82326 100644
--- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
+++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift
@@ -905,7 +905,15 @@ public struct MessageBubble: View {
// link's resolved card must not render under this URL while the
// fresh unfurl runs.
.onChange(of: link) { _, _ in preview = nil }
- .task(id: link) { preview = await unfurl(link) }
+ // A cancelled task (the row went away, or recycled onto another
+ // link) must never write its answer back: the model keeps the
+ // fetch alive past the cancellation, so the row that replaces this
+ // one gets the card — clobbering `preview` here would blank it.
+ .task(id: link) {
+ let resolved = await unfurl(link)
+ guard !Task.isCancelled else { return }
+ preview = resolved
+ }
}
private func card(_ preview: LinkPreview) -> some View {
diff --git a/ios/LChatCore/Tests/LChatCoreTests/UnfurlSharingTests.swift b/ios/LChatCore/Tests/LChatCoreTests/UnfurlSharingTests.swift
new file mode 100644
index 0000000..8850e32
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/UnfurlSharingTests.swift
@@ -0,0 +1,93 @@
+import XCTest
+@testable import LChatCore
+
+// 🤖 Unfurling your OWN just-posted message: the card used to appear only
+// after a reload. The row for a pending send is rebuilt the moment its echo
+// swaps the pending id for the real server id, and the rebuild cancels the old
+// row's `.task`. With the fetch parented to that task, the cancellation landed
+// as a FAILED unfurl, negative-cached the URL for a minute, and the
+// replacement row was told there was nothing to show.
+//
+// So the two rules pinned here: the fetch survives its caller's cancellation,
+// and a caller arriving mid-fetch awaits the same one instead of being told no.
+#if DEBUG
+final class UnfurlSharingTests: XCTestCase {
+ @MainActor
+ private func makeModel() -> ChatFeedModel {
+ let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok",
+ screenName: "Paul", userPub: "", userPriv: "")
+ let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven", preview: "",
+ lastAt: 0, unread: false, pinned: false, hidden: false,
+ members: [], threadKeyB64: "")
+ return ChatFeedModel(instance: inst, thread: thread, myUserId: "me")
+ }
+
+ private static let link = URL(string: "https://evilmartians.com")!
+ private static let card = LinkPreview(url: link, title: "Evil Martians",
+ description: "", imageURL: nil)
+
+ /// The row that replaces a cancelled one still gets its card.
+ @MainActor
+ func testFetchOutlivesACancelledCaller() async {
+ let model = makeModel()
+ let started = expectation(description: "fetch started")
+ let calls = Counter()
+ model._testUnfurlFetch = { _ in
+ calls.bump()
+ started.fulfill()
+ try? await Task.sleep(nanoseconds: 120_000_000)
+ return Self.card
+ }
+
+ // The pending row's task, cancelled the way a rebuilt row cancels it.
+ let row = Task { @MainActor in await model.unfurl(Self.link) }
+ await fulfillment(of: [started], timeout: 2)
+ row.cancel()
+
+ // The replacement row asks again while the first fetch is still out.
+ let replacement = await model.unfurl(Self.link)
+ XCTAssertEqual(replacement, Self.card,
+ "the rebuilt row must get the card the cancelled row started")
+ XCTAssertEqual(calls.value, 1, "one fetch, shared — not one per row")
+ XCTAssertEqual(model.cachedUnfurl(Self.link), .some(Self.card),
+ "and the cancellation must not have poisoned the cache")
+ }
+
+ /// Two rows asking at once both get the card — the second used to get nil
+ /// (the old in-flight guard) and cache that as "nothing to show".
+ @MainActor
+ func testConcurrentCallersShareOneFetch() async {
+ let model = makeModel()
+ let calls = Counter()
+ model._testUnfurlFetch = { _ in
+ calls.bump()
+ try? await Task.sleep(nanoseconds: 50_000_000)
+ return Self.card
+ }
+ async let a = model.unfurl(Self.link)
+ async let b = model.unfurl(Self.link)
+ let (first, second) = await (a, b)
+ XCTAssertEqual(first, Self.card)
+ XCTAssertEqual(second, Self.card)
+ XCTAssertEqual(calls.value, 1)
+ }
+
+ /// A page with nothing to show still fails closed, and briefly — the TTL
+ /// that keeps a re-render from stampeding the server.
+ @MainActor
+ func testEmptyResultIsNegativeCachedThenRetried() async {
+ let model = makeModel()
+ model._testUnfurlFetch = { _ in nil }
+ let empty = await model.unfurl(Self.link)
+ XCTAssertNil(empty)
+ XCTAssertEqual(model.cachedUnfurl(Self.link), .some(nil),
+ "known-empty, so height measurement doesn't leave a gap")
+ }
+}
+
+/// Call tally — MainActor like the model whose fetch it counts.
+@MainActor private final class Counter {
+ var value = 0
+ func bump() { value += 1 }
+}
+#endif