Merge publish-confirm lag fix into develop

This commit is contained in:
2026-10-19 15:01:18 -06:00
2 changed files with 82 additions and 19 deletions
+49 -15
View File
@@ -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<void>;
}
const defaultSleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
/** /**
* Reload the first page. Returns the page when it holds the post id, else null. * Reload the first page, retrying a few times, because a freshly published post
* Throws PostError when the person is signed out or the reload fails. * 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( export async function confirmOnFirstPage(
id: string, id: string,
fetchPage: (cursor: string | null) => Promise<FeedPage> = fetchFeedPage, fetchPage: (cursor: string | null) => Promise<FeedPage> = fetchFeedPage,
opts: ConfirmOpts = {},
): Promise<FeedPage | null> { ): Promise<FeedPage | null> {
try { const attempts = Math.max(1, opts.attempts ?? 6);
const page = await fetchPage(null); const delayMs = opts.delayMs ?? 800;
return pageHasPost(page, id) ? page : null; const sleep = opts.sleep ?? defaultSleep;
} catch (e) { for (let i = 0; i < attempts; i++) {
throw new PostError(e instanceof FeedLoadError && e.signedOut); 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 { export interface PublishDeps {
send?: (content: string, file: File | null) => Promise<string>; send?: (content: string, file: File | null) => Promise<string>;
fetchPage?: (cursor: string | null) => Promise<FeedPage>; fetchPage?: (cursor: string | null) => Promise<FeedPage>;
confirm?: ConfirmOpts;
} }
/** /**
* Publish a post (text, or text plus one image file) and confirm it on the * Publish a post (text, or text plus one image or video file) and confirm it on
* reloaded first page. Returns that page only when the post id is on it. * the reloaded first page. `unconfirmed` holds an id that was created but not yet
* `unconfirmed` holds an id that was created earlier but not yet seen, so a * confirmed, so if the reload itself fails a retry re-checks the feed instead of
* retry re-checks the feed and does not create a duplicate. Throws PostError * creating a duplicate.
* otherwise; the caller keeps the composer text and the selected file. *
* 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( export async function publishAndConfirm(
content: string, content: string,
@@ -88,10 +115,17 @@ export async function publishAndConfirm(
unconfirmed: { id: string | null }, unconfirmed: { id: string | null },
deps: PublishDeps = {}, deps: PublishDeps = {},
): Promise<FeedPage> { ): Promise<FeedPage> {
const fetchPage = deps.fetchPage ?? fetchFeedPage;
const id = unconfirmed.id ?? (await (deps.send ?? sendPost)(content, file)); const id = unconfirmed.id ?? (await (deps.send ?? sendPost)(content, file));
unconfirmed.id = id; unconfirmed.id = id;
const page = await confirmOnFirstPage(id, deps.fetchPage); // confirmOnFirstPage throws only on a sign-out / reload failure; in that case
if (!page) throw new PostError(false); // unconfirmed.id stays set so a retry re-checks without creating again.
const page = await confirmOnFirstPage(id, fetchPage, deps.confirm);
unconfirmed.id = null; unconfirmed.id = null;
return page; if (page) return page;
try {
return await fetchPage(null);
} catch (e) {
throw new PostError(e instanceof FeedLoadError && e.signedOut);
}
} }
+33 -4
View File
@@ -6,6 +6,7 @@ import { POST } from "@/app/api/posts/route";
import { Composer } from "@/components/Composer"; import { Composer } from "@/components/Composer";
import { FeedView } from "@/components/FeedView"; import { FeedView } from "@/components/FeedView";
import { PostError, publishAndConfirm, sendPost } from "@/lib/post-client"; import { PostError, publishAndConfirm, sendPost } from "@/lib/post-client";
import { FeedLoadError } from "@/lib/feed-client";
import { createSession } from "@/lib/session-store"; import { createSession } from "@/lib/session-store";
function session() { function session() {
@@ -84,14 +85,42 @@ describe("publishAndConfirm", () => {
expect(state.id).toBeNull(); 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 state = { id: null as string | null };
const send = vi.fn(async () => "p9"); const send = vi.fn(async () => "p9");
const miss = async () => ({ items: [item("p1")], nextCursor: null }); let n = 0;
await expect(publishAndConfirm("hi", null, state, { send, fetchPage: miss })).rejects.toBeInstanceOf(PostError); 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"); expect(state.id).toBe("p9");
const hit = async () => ({ items: [item("p9")], nextCursor: null }); 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); expect(send).toHaveBeenCalledTimes(1);
}); });