Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .codex/skills/make-closed-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ gh issue create \
--title "ISSUE_TITLE" \
--body "ISSUE_DESCRIPTION" \
--label "LABEL1,LABEL2" \
--assignee plebe1us
--assignee tomcasaburi
```

Capture the issue number from the output.
Expand Down
2 changes: 1 addition & 1 deletion .cursor/skills/make-closed-issue/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ gh issue create \
--title "ISSUE_TITLE" \
--body "ISSUE_DESCRIPTION" \
--label "LABEL1,LABEL2" \
--assignee plebe1us
--assignee tomcasaburi
```

Capture the issue number from the output.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -897,6 +897,7 @@ for (const accountComment of accountComments) {
// it is recommended to show a label in the UI if accountComment.state is 'pending' or 'failed'
console.log("comment", accountComment.index, "is status", accountComment.state);
}
// `state` becomes `failed` as soon as a pending local publish records terminal failure (`publishingState === "failed"` and `state === "stopped"`) or a publish error, instead of waiting for the 20-minute fallback.
// note: accountComment.index can change after deletions; prefer commentCid for stable identifiers

// all my own votes
Expand Down
57 changes: 57 additions & 0 deletions src/hooks/accounts/accounts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1558,6 +1558,63 @@ describe("accounts", () => {
await waitForErr(() => renderedErr.result.current.error !== undefined);
expect(renderedErr.result.current.error?.message).toBe("publish failed");
expect(renderedErr.result.current.errors).toHaveLength(1);
expect(renderedErr.result.current.state).toBe("failed");
});

test("useAccountComment keeps pending when publishingState is failed but publication state is not stopped", async () => {
const state = accountsStore.getState() as any;
const accountId = state.activeAccountId || Object.keys(state.accounts)[0];
const existing = state.accountsComments?.[accountId] || [];
const failedIndex = existing.length;
accountsStore.setState((s: any) => ({
...s,
accountsComments: {
...s.accountsComments,
[accountId]: [
...(s.accountsComments?.[accountId] || []),
{
timestamp: Math.floor(Date.now() / 1000),
communityAddress: "test.eth",
content: "still retrying",
index: failedIndex,
publishingState: "failed",
state: "publishing",
},
],
},
}));
const renderedPending = renderHook(() => useAccountComment({ commentIndex: failedIndex }));
const waitForPending = testUtils.createWaitFor(renderedPending);
await waitForPending(() => renderedPending.result.current.state === "pending");
expect(renderedPending.result.current.state).toBe("pending");
});

test("useAccountComment shows failed when publishingState is failed and publication state is stopped", async () => {
const state = accountsStore.getState() as any;
const accountId = state.activeAccountId || Object.keys(state.accounts)[0];
const existing = state.accountsComments?.[accountId] || [];
const failedIndex = existing.length;
accountsStore.setState((s: any) => ({
...s,
accountsComments: {
...s.accountsComments,
[accountId]: [
...(s.accountsComments?.[accountId] || []),
{
timestamp: Math.floor(Date.now() / 1000),
communityAddress: "test.eth",
content: "failed",
index: failedIndex,
publishingState: "failed",
state: "stopped",
},
],
},
}));
const renderedFailed = renderHook(() => useAccountComment({ commentIndex: failedIndex }));
const waitForFailed = testUtils.createWaitFor(renderedFailed);
await waitForFailed(() => renderedFailed.result.current.state === "failed");
expect(renderedFailed.result.current.state).toBe("failed");
});

test("useAccountVote with no commentCid returns initializing", async () => {
Expand Down
21 changes: 18 additions & 3 deletions src/hooks/accounts/accounts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,15 +312,30 @@ export function useNotifications(options?: UseNotificationsOptions): UseNotifica
}

const getAccountCommentsStates = (accountComments: AccountComment[]) => {
// no longer consider an pending ater an expiry time of 20 minutes, consider failed
// Without a cid, the account comment is still a local pending publish. plebbit-js marks
// terminal publish failures when `publishingState === "failed"` and publication `state`
// is `"stopped"`, so we derive failed from that terminal pair or recorded publish errors.
const now = Math.round(Date.now() / 1000);
const expiryTime = now - 60 * 20;

const states: string[] = [];
for (const accountComment of accountComments) {
let state = "succeeded";
if (!accountComment.cid) {
if (accountComment.timestamp > expiryTime) {
const ac = accountComment as AccountComment & {
error?: Error;
errors?: Error[];
publishingState?: string;
state?: string;
};
const resolvedPublishFailed =
(ac.publishingState === "failed" && ac.state === "stopped") ||
ac.error != null ||
(Array.isArray(ac.errors) && ac.errors.length > 0);

if (resolvedPublishFailed) {
state = "failed";
} else if (accountComment.timestamp > expiryTime) {
state = "pending";
} else {
state = "failed";
Expand Down Expand Up @@ -447,7 +462,7 @@ export function useAccountComments(options?: UseAccountCommentsOptions): UseAcco
parentCid,
]);

// recheck the states for changes every 1 minute because succeeded / failed / pending aren't events, they are time elapsed
// Recheck periodically so the 20-minute “stale pending → failed” transition updates without other store events
const delay = 60_000;
const immediate = false;
useInterval(
Expand Down
9 changes: 4 additions & 5 deletions src/hooks/actions/actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1007,15 +1007,14 @@ describe("actions", () => {
});

// wait for error
await waitFor(() => rendered.result.current.errors.length === 2);
expect(rendered.result.current.errors.length).toBe(2);
expect(rendered.result.current.error.message).toBe("publish error");
await waitFor(() => rendered.result.current.errors.length === 1);
expect(rendered.result.current.errors.length).toBe(1);
expect(rendered.result.current.error.message).toBe("emit error");
expect(rendered.result.current.errors[0].message).toBe("emit error");
expect(rendered.result.current.errors[1].message).toBe("publish error");

// check callbacks
expect(onError).toHaveBeenCalledTimes(1);
expect(onError.mock.calls[0][0].message).toBe("emit error");
expect(onError.mock.calls[1][0].message).toBe("publish error");

// restore mock
Comment.prototype.publish = commentPublish;
Expand Down
90 changes: 89 additions & 1 deletion src/stores/accounts/accounts-actions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1002,6 +1002,49 @@ describe("accounts-actions", () => {
expect(comments.some((c: any) => c.cid)).toBe(true);
});

test("publishComment ignores stale errors from a replaced retry comment", async () => {
const account = Object.values(accountsStore.getState().accounts)[0];
const createdComments: any[] = [];
const origCreateComment = account.plebbit.createComment.bind(account.plebbit);
vi.spyOn(account.plebbit, "createComment").mockImplementation(async (opts: any) => {
const comment = await origCreateComment(opts);
createdComments.push(comment);
return comment;
});

const onError = vi.fn();
await act(async () => {
await accountsActions.publishComment({
communityAddress: "sub.eth",
content: "retry stale error",
onChallenge: (ch: any, c: any) => c.publishChallengeAnswers(["4"]),
onChallengeVerification: () => {},
onError,
});
});

const start = Date.now();
while (createdComments.length < 2 && Date.now() - start < 2000) {
await new Promise((resolve) => setTimeout(resolve, 25));
}

expect(createdComments).toHaveLength(2);
createdComments[0]?.listeners("error")?.[0]?.(new Error("stale retry error"));
await new Promise((resolve) => setTimeout(resolve, 50));

expect(onError).not.toHaveBeenCalled();
const successStart = Date.now();
while (Date.now() - successStart < 2000) {
const currentComments = accountsStore.getState().accountsComments[account.id] || [];
if (currentComments.some((comment: any) => comment.cid === "retry stale error cid")) {
break;
}
await new Promise((resolve) => setTimeout(resolve, 25));
}
const comments = accountsStore.getState().accountsComments[account.id] || [];
expect(comments.some((comment: any) => comment.cid === "retry stale error cid")).toBe(true);
});

test("publishVote retries on challenge failure", async () => {
const opts = {
communityAddress: "sub.eth",
Expand Down Expand Up @@ -2146,7 +2189,7 @@ describe("accounts-actions", () => {
expect(comments.find((c: any) => c.content === "ipfs")).toBeDefined();
});

test("publishComment publish throws: onError called", async () => {
test("publishComment publish throws: stores error on the pending comment and calls onError", async () => {
const account = Object.values(accountsStore.getState().accounts)[0];
const origCreate = account.plebbit.createComment.bind(account.plebbit);
vi.spyOn(account.plebbit, "createComment").mockImplementation(async (opts: any) => {
Expand All @@ -2168,6 +2211,51 @@ describe("accounts-actions", () => {

await new Promise((r) => setTimeout(r, 100));
expect(onError).toHaveBeenCalled();
const comments = accountsStore.getState().accountsComments[account.id] || [];
expect(comments[0]?.error?.message).toBe("publish failed");
expect(comments[0]?.errors?.map((error: Error) => error.message)).toEqual(["publish failed"]);
});

test("publishComment stores terminal publication state and ignores later errors", async () => {
const account = Object.values(accountsStore.getState().accounts)[0];
const origCreate = account.plebbit.createComment.bind(account.plebbit);
let commentRef: any;
let resolveCommentCreated!: () => void;
const commentCreated = new Promise<void>((resolve) => {
resolveCommentCreated = resolve;
});
vi.spyOn(account.plebbit, "createComment").mockImplementation(async (opts: any) => {
const c = await origCreate(opts);
commentRef = c;
resolveCommentCreated();
vi.spyOn(c, "publish").mockResolvedValueOnce(undefined);
return c;
});

const onError = vi.fn();
await act(async () => {
await accountsActions.publishComment({
communityAddress: "sub.eth",
content: "terminal-state",
onChallenge: () => {},
onChallengeVerification: () => {},
onError,
});
});

await commentCreated;
await act(async () => {
commentRef.emit("statechange", "stopped");
commentRef.emit("publishingstatechange", "failed");
});
await Promise.resolve();
commentRef.emit("error", new Error("late terminal error"));
await new Promise((resolve) => setTimeout(resolve, 25));

const comments = accountsStore.getState().accountsComments[account.id] || [];
expect(comments[0]?.state).toBe("stopped");
expect(comments[0]?.publishingState).toBe("failed");
expect(onError).not.toHaveBeenCalled();
});

test("publishComment startUpdatingAccountCommentOnCommentUpdateEvents error: catch logs (line 760)", async () => {
Expand Down
Loading
Loading