From 0dc9d6b7b387717c96dc7683ed11cd3bdde88d53 Mon Sep 17 00:00:00 2001 From: Cesar Alcazar Date: Mon, 19 Oct 2026 15:01:17 -0600 Subject: [PATCH] 3dom4wf7p: Poll the feed before reporting a publish failure A freshly published post can take a moment to surface on the home feed, so the single immediate confirm reload reported a false failure even though the post went through. Retry the reload a few times; once the server has accepted the publish, treat a not-yet-visible post as success instead of an error. --- src/lib/post-client.ts | 64 ++++++++++++++++++++++++++++++++---------- tests/post.test.ts | 37 +++++++++++++++++++++--- 2 files changed, 82 insertions(+), 19 deletions(-) diff --git a/src/lib/post-client.ts b/src/lib/post-client.ts index 4dcf6f3..c3edeb4 100644 --- a/src/lib/post-client.ts +++ b/src/lib/post-client.ts @@ -54,33 +54,60 @@ function pageHasPost(page: FeedPage, id: string): boolean { }); } +export interface ConfirmOpts { + /** How many times to reload the first page before giving up. */ + attempts?: number; + /** Delay between reloads, in ms. */ + delayMs?: number; + sleep?: (ms: number) => Promise; +} + +const defaultSleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + /** - * Reload the first page. Returns the page when it holds the post id, else null. - * Throws PostError when the person is signed out or the reload fails. + * Reload the first page, retrying a few times, because a freshly published post + * can take a moment to surface on the home feed. Returns the page as soon as it + * holds the post id, else null after all attempts. Throws PostError when the + * person is signed out or a reload fails. */ export async function confirmOnFirstPage( id: string, fetchPage: (cursor: string | null) => Promise = fetchFeedPage, + opts: ConfirmOpts = {}, ): Promise { - try { - const page = await fetchPage(null); - return pageHasPost(page, id) ? page : null; - } catch (e) { - throw new PostError(e instanceof FeedLoadError && e.signedOut); + const attempts = Math.max(1, opts.attempts ?? 6); + const delayMs = opts.delayMs ?? 800; + const sleep = opts.sleep ?? defaultSleep; + for (let i = 0; i < attempts; i++) { + let page: FeedPage; + try { + page = await fetchPage(null); + } catch (e) { + throw new PostError(e instanceof FeedLoadError && e.signedOut); + } + if (pageHasPost(page, id)) return page; + if (i < attempts - 1) await sleep(delayMs); } + return null; } export interface PublishDeps { send?: (content: string, file: File | null) => Promise; fetchPage?: (cursor: string | null) => Promise; + confirm?: ConfirmOpts; } /** - * Publish a post (text, or text plus one image file) and confirm it on the - * reloaded first page. Returns that page only when the post id is on it. - * `unconfirmed` holds an id that was created earlier but not yet seen, so a - * retry re-checks the feed and does not create a duplicate. Throws PostError - * otherwise; the caller keeps the composer text and the selected file. + * Publish a post (text, or text plus one image or video file) and confirm it on + * the reloaded first page. `unconfirmed` holds an id that was created but not yet + * confirmed, so if the reload itself fails a retry re-checks the feed instead of + * creating a duplicate. + * + * The server only returns an id once Minnect has accepted the publish (sendPost + * throws otherwise), so once we have an id the post IS published. If it has not + * surfaced on the feed after the retries, that is feed lag, not a failure: we + * clear the pending id and return the latest page — the post appears on a later + * poll — rather than showing the person a false error. */ export async function publishAndConfirm( content: string, @@ -88,10 +115,17 @@ export async function publishAndConfirm( unconfirmed: { id: string | null }, deps: PublishDeps = {}, ): Promise { + const fetchPage = deps.fetchPage ?? fetchFeedPage; const id = unconfirmed.id ?? (await (deps.send ?? sendPost)(content, file)); unconfirmed.id = id; - const page = await confirmOnFirstPage(id, deps.fetchPage); - if (!page) throw new PostError(false); + // confirmOnFirstPage throws only on a sign-out / reload failure; in that case + // unconfirmed.id stays set so a retry re-checks without creating again. + const page = await confirmOnFirstPage(id, fetchPage, deps.confirm); unconfirmed.id = null; - return page; + if (page) return page; + try { + return await fetchPage(null); + } catch (e) { + throw new PostError(e instanceof FeedLoadError && e.signedOut); + } } diff --git a/tests/post.test.ts b/tests/post.test.ts index 7dffd86..fc290e0 100644 --- a/tests/post.test.ts +++ b/tests/post.test.ts @@ -6,6 +6,7 @@ import { POST } from "@/app/api/posts/route"; import { Composer } from "@/components/Composer"; import { FeedView } from "@/components/FeedView"; import { PostError, publishAndConfirm, sendPost } from "@/lib/post-client"; +import { FeedLoadError } from "@/lib/feed-client"; import { createSession } from "@/lib/session-store"; function session() { @@ -84,14 +85,42 @@ describe("publishAndConfirm", () => { expect(state.id).toBeNull(); }); - it("throws and keeps the id when the post is not on the page, then a retry does not create again", async () => { + const nap = { delayMs: 0, sleep: async () => {} }; + + it("retries the reload and succeeds once a slow post surfaces", async () => { const state = { id: null as string | null }; const send = vi.fn(async () => "p9"); - const miss = async () => ({ items: [item("p1")], nextCursor: null }); - await expect(publishAndConfirm("hi", null, state, { send, fetchPage: miss })).rejects.toBeInstanceOf(PostError); + let n = 0; + const fetchPage = vi.fn(async () => + n++ < 2 ? { items: [item("p1")], nextCursor: null } : { items: [item("p9"), item("p1")], nextCursor: null }, + ); + const got = await publishAndConfirm("hi", null, state, { send, fetchPage, confirm: nap }); + expect(got.items.map((i) => (i as { post: { id: string } }).post.id)).toContain("p9"); + expect(state.id).toBeNull(); + expect(send).toHaveBeenCalledTimes(1); + expect(n).toBe(3); + }); + + it("does not throw or duplicate when a published post never surfaces (feed lag)", async () => { + const state = { id: null as string | null }; + const send = vi.fn(async () => "p9"); + const miss = vi.fn(async () => ({ items: [item("p1")], nextCursor: null })); + const got = await publishAndConfirm("hi", null, state, { send, fetchPage: miss, confirm: { ...nap, attempts: 3 } }); + expect(got).toBeTruthy(); + expect(state.id).toBeNull(); + expect(send).toHaveBeenCalledTimes(1); + }); + + it("keeps the id when the reload itself fails, so a retry re-checks without creating again", async () => { + const state = { id: null as string | null }; + const send = vi.fn(async () => "p9"); + const boom = async () => { + throw new FeedLoadError(false); + }; + await expect(publishAndConfirm("hi", null, state, { send, fetchPage: boom, confirm: nap })).rejects.toBeInstanceOf(PostError); expect(state.id).toBe("p9"); const hit = async () => ({ items: [item("p9")], nextCursor: null }); - await publishAndConfirm("hi", null, state, { send, fetchPage: hit }); + await publishAndConfirm("hi", null, state, { send, fetchPage: hit, confirm: nap }); expect(send).toHaveBeenCalledTimes(1); });