fix(publishing): keep submit guard active until publish settles

Keep one-shot post and reply submissions guarded until the effect-driven publish settles, so duplicate clicks reuse the in-flight request instead of creating a second signed publish. Adds regression tests for post and reply duplicate-submit paths.
This commit is contained in:
Tommaso Casaburi
2026-06-22 14:53:28 +07:00
committed by GitHub
parent 394fe2af00
commit 04262214e1
7 changed files with 249 additions and 35 deletions
@@ -806,6 +806,39 @@ describe('PostForm', () => {
expect(testState.publishedPostOptions?.content).toBe(`${youtubeLink}\nVideo body`);
});
it('ignores duplicate post clicks while publish is pending', async () => {
let resolvePublish: () => void = () => {};
const publishPromise = new Promise<void>((resolve) => {
resolvePublish = resolve;
});
testState.publishPostMock.mockReturnValue(publishPromise);
await renderPostForm('/all');
await clickByText(container, 'start_new_thread');
const table = container.querySelector('table') as HTMLTableElement;
const select = table.querySelector('select') as HTMLSelectElement;
const textarea = table.querySelector('textarea') as HTMLTextAreaElement;
const postButton = Array.from(table.querySelectorAll('button')).find((button) => button.textContent === 'post') as HTMLButtonElement;
await dispatchChange(select, 'music-posting.eth');
await dispatchInput(textarea, 'Thread body');
await clickByText(table, 'post');
expect(postButton.disabled).toBe(true);
await clickByText(table, 'post');
expect(testState.publishPostMock).toHaveBeenCalledTimes(1);
await act(async () => {
resolvePublish();
await publishPromise;
});
await flushEffects();
expect(postButton.disabled).toBe(false);
});
it('publishes known twimg query-format post links with a path extension without editing the field', async () => {
const twimgLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU?format=jpg&name=medium';
const publishLink = 'https://pbs.twimg.com/media/HJxnhNKWMAAhqFU.jpg';
+1 -1
View File
@@ -713,7 +713,7 @@ const PostFormTable = ({ closeForm, postCid }: { closeForm: () => void; postCid:
};
nonokoRedirectPathRef.current = hasNonokoOption(currentOptions) ? getBoardIndexPath() : null;
publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
await publishPost({ content: publishContent, ...getPublishLinkOptions(currentUrl, appliedYouTubeConversion), ...publishOptions });
});
// redirect to pending page when pending comment is created
@@ -156,6 +156,50 @@ describe('usePublishPost', () => {
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
it('keeps a one-shot publish pending and ignores duplicate publish calls until it settles', async () => {
let resolvePublish: () => void = () => {};
const publishPromise = new Promise<void>((resolve) => {
resolvePublish = resolve;
});
testState.publishCommentMock.mockReturnValue(publishPromise);
let firstPublish: Promise<void> | undefined;
let secondPublish: Promise<void> | undefined;
await act(async () => {
firstPublish = latestValue.publishPost({
content: 'Fresh body',
} as never);
secondPublish = latestValue.publishPost({
content: 'Duplicate body',
} as never);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(firstPublish).toBe(secondPublish);
expect(testState.lastPublishOptions?.content).toBe('Fresh body');
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
let isSettled = false;
firstPublish?.then(() => {
isSettled = true;
});
await act(async () => {
await Promise.resolve();
});
expect(isSettled).toBe(false);
await act(async () => {
resolvePublish();
await publishPromise;
await firstPublish;
});
expect(isSettled).toBe(true);
});
it('clears stale flag data from one-shot publish options', async () => {
await act(async () => {
latestValue.setPublishPostOptions({
+62 -6
View File
@@ -179,7 +179,9 @@ describe('usePublishReply', () => {
});
await act(async () => {
await latestValue.publishReply();
latestValue.publishReply();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
@@ -197,7 +199,9 @@ describe('usePublishReply', () => {
});
await act(async () => {
await latestValue.publishReply();
latestValue.publishReply();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
@@ -215,17 +219,65 @@ describe('usePublishReply', () => {
});
await act(async () => {
await latestValue.publishReply({
latestValue.publishReply({
content: 'Fresh reply',
} as never);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(testState.lastPublishOptions?.content).toBe('Fresh reply');
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
});
it('keeps a one-shot reply pending and ignores duplicate publish calls until it settles', async () => {
let resolvePublish: () => void = () => {};
const publishPromise = new Promise<void>((resolve) => {
resolvePublish = resolve;
});
testState.publishCommentMock.mockReturnValue(publishPromise);
let firstPublish: Promise<void> | undefined;
let secondPublish: Promise<void> | undefined;
await act(async () => {
firstPublish = latestValue.publishReply({
content: 'Fresh reply',
} as never);
secondPublish = latestValue.publishReply({
content: 'Duplicate reply',
} as never);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(firstPublish).toBe(secondPublish);
expect(testState.lastPublishOptions?.content).toBe('Fresh reply');
expect(testState.publishCommentMock).toHaveBeenCalledTimes(1);
let isSettled = false;
firstPublish?.then(() => {
isSettled = true;
});
await act(async () => {
await Promise.resolve();
});
expect(isSettled).toBe(false);
await act(async () => {
resolvePublish();
await publishPromise;
await firstPublish;
});
expect(isSettled).toBe(true);
});
it('clears stale flag data from one-shot reply options', async () => {
await act(async () => {
latestValue.setPublishReplyOptions({
@@ -238,13 +290,15 @@ describe('usePublishReply', () => {
});
await act(async () => {
await latestValue.publishReply({
latestValue.publishReply({
content: 'Plain reply',
challengeRequest: undefined,
flairs: undefined,
} as never);
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(testState.lastPublishOptions?.content).toBe('Plain reply');
@@ -263,8 +317,9 @@ describe('usePublishReply', () => {
});
await act(async () => {
await latestValue.publishReply();
latestValue.publishReply();
await Promise.resolve();
await new Promise((resolve) => setTimeout(resolve, 0));
});
expect(latestValue.publishReplyError).toContain('external_quote_publish_missing');
@@ -276,7 +331,8 @@ describe('usePublishReply', () => {
renderHook();
await act(async () => {
await latestValue.publishReply();
latestValue.publishReply();
await Promise.resolve();
});
expect(latestValue.publishReplyError).toBe('blocked:unresolved');
+44
View File
@@ -0,0 +1,44 @@
import { useCallback, useRef } from 'react';
type PendingPublishRequest = {
id: number;
promise: Promise<void>;
resolve: () => void;
};
const usePendingPublishRequest = () => {
const nextRequestIdRef = useRef(0);
const pendingRequestRef = useRef<PendingPublishRequest | null>(null);
const startPendingPublishRequest = useCallback(() => {
if (pendingRequestRef.current) {
return { request: pendingRequestRef.current, started: false };
}
const id = nextRequestIdRef.current + 1;
nextRequestIdRef.current = id;
let resolveRequest: () => void = () => {};
const promise = new Promise<void>((resolve) => {
resolveRequest = resolve;
});
const request = { id, promise, resolve: resolveRequest };
pendingRequestRef.current = request;
return { request, started: true };
}, []);
const finishPendingPublishRequest = useCallback((requestId: number) => {
const request = pendingRequestRef.current;
if (!request || request.id !== requestId) {
return;
}
pendingRequestRef.current = null;
request.resolve();
}, []);
return { finishPendingPublishRequest, startPendingPublishRequest };
};
export default usePendingPublishRequest;
+24 -11
View File
@@ -4,6 +4,7 @@ import { useShallow } from 'zustand/react/shallow';
import usePublishPostStore from '../stores/use-publish-post-store';
import useChallengesStore from '../stores/use-challenges-store';
import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard';
import usePendingPublishRequest from './use-pending-publish-request';
type UsePublishPostOptions = {
communityAddress?: string;
@@ -30,6 +31,7 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
const [pendingPublishRequestId, setPendingPublishRequestId] = useState(0);
const startedPublishRequestIdRef = useRef(0);
const { blockedReason } = usePublishAuthorDomainGuard();
const { finishPendingPublishRequest, startPendingPublishRequest } = usePendingPublishRequest();
const abandonCurrentPublish = useCallback(async () => {
await abandonPublishRef.current?.();
}, []);
@@ -95,14 +97,14 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
setPublishPostError(null);
}, [author?.displayName, blockedReason, communityAddress, content, flairs, link, spoiler, title]);
const startPublishPost = useCallback(() => {
const startPublishPost = useCallback(async () => {
if (blockedReason) {
setPublishPostError(getPublishAuthorDomainErrorMessage(blockedReason));
return;
}
setPublishPostError(null);
return publishComment();
await publishComment();
}, [blockedReason, publishComment]);
useEffect(() => {
@@ -110,21 +112,32 @@ const usePublishPost = ({ communityAddress }: UsePublishPostOptions) => {
return;
}
startedPublishRequestIdRef.current = pendingPublishRequestId;
startPublishPost();
}, [pendingPublishRequestId, startPublishPost]);
const requestId = pendingPublishRequestId;
startedPublishRequestIdRef.current = requestId;
void startPublishPost()
.catch(() => undefined)
.finally(() => finishPendingPublishRequest(requestId));
}, [finishPendingPublishRequest, pendingPublishRequestId, startPublishPost]);
const publishPost = useCallback(
(options?: Partial<Comment>) => {
if (options) {
setPublishPostOptions(options);
setPendingPublishRequestId((requestId) => requestId + 1);
return;
const pendingRequest = startPendingPublishRequest();
if (!pendingRequest.started) {
return pendingRequest.request.promise;
}
return startPublishPost();
if (options) {
setPublishPostOptions(options);
setPendingPublishRequestId(pendingRequest.request.id);
return pendingRequest.request.promise;
}
void startPublishPost()
.catch(() => undefined)
.finally(() => finishPendingPublishRequest(pendingRequest.request.id));
return pendingRequest.request.promise;
},
[setPublishPostOptions, startPublishPost],
[finishPendingPublishRequest, setPublishPostOptions, startPendingPublishRequest, startPublishPost],
);
return {
+41 -17
View File
@@ -10,6 +10,7 @@ import { extractUnresolvedExternalQuoteReferences, getExternalQuoteStatusMessage
import { resolveExternalQuoteTarget } from '../lib/utils/external-quote-resolver';
import useChallengesStore from '../stores/use-challenges-store';
import usePublishAuthorDomainGuard, { getPublishAuthorDomainErrorMessage } from './use-publish-author-domain-guard';
import usePendingPublishRequest from './use-pending-publish-request';
type UsePublishReplyOptions = {
cid: string;
@@ -47,6 +48,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
const [isResolvingExternalQuotes, setIsResolvingExternalQuotes] = useState(false);
const [publishReplyError, setPublishReplyError] = useState<string | null>(null);
const [publishReplyStateMessage, setPublishReplyStateMessage] = useState<string | null>(null);
const { finishPendingPublishRequest, startPendingPublishRequest } = usePendingPublishRequest();
const abandonCurrentPublish = useCallback(async () => {
await abandonPublishRef.current?.();
}, []);
@@ -166,35 +168,35 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
return;
}
startedPublishRequestIdRef.current = pendingPublishRequestId;
publishComment();
}, [pendingPublishRequestId, publishComment]);
const publishReply = useCallback(
async (options?: Partial<Comment>) => {
if (options) {
setPublishReplyOptions(options);
setPendingSyncedPublishRequestId((requestId) => requestId + 1);
return;
}
const requestId = pendingPublishRequestId;
startedPublishRequestIdRef.current = requestId;
void Promise.resolve()
.then(() => publishComment())
.catch(() => undefined)
.finally(() => finishPendingPublishRequest(requestId));
}, [finishPendingPublishRequest, pendingPublishRequestId, publishComment]);
const preparePublishReply = useCallback(
async (requestId: number) => {
setPublishReplyError(null);
if (blockedReason) {
setPublishReplyStateMessage(null);
setPublishReplyError(getPublishAuthorDomainErrorMessage(blockedReason));
finishPendingPublishRequest(requestId);
return;
}
if (publishResolvableQuoteReferences.length === 0) {
setResolvedExternalQuotedCids(undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
setPendingPublishRequestId(requestId);
return;
}
if (!account?.id) {
setPublishReplyError(t('external_quote_resolution_unavailable'));
finishPendingPublishRequest(requestId);
return;
}
@@ -220,6 +222,7 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
quote: reference.raw,
}),
);
finishPendingPublishRequest(requestId);
return;
}
@@ -228,14 +231,34 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
setResolvedExternalQuotedCids(resolvedCids.size > 0 ? [...resolvedCids] : undefined);
setPublishReplyStateMessage(null);
setPendingPublishRequestId((requestId) => requestId + 1);
setPendingPublishRequestId(requestId);
} catch {
setPublishReplyError(t('external_quote_resolution_unavailable'));
finishPendingPublishRequest(requestId);
} finally {
setIsResolvingExternalQuotes(false);
}
},
[account, blockedReason, directories, publishResolvableQuoteReferences, setPublishReplyOptions, t],
[account, blockedReason, directories, finishPendingPublishRequest, publishResolvableQuoteReferences, t],
);
const publishReply = useCallback(
(options?: Partial<Comment>) => {
const pendingRequest = startPendingPublishRequest();
if (!pendingRequest.started) {
return pendingRequest.request.promise;
}
if (options) {
setPublishReplyOptions(options);
setPendingSyncedPublishRequestId(pendingRequest.request.id);
return pendingRequest.request.promise;
}
void preparePublishReply(pendingRequest.request.id).catch(() => finishPendingPublishRequest(pendingRequest.request.id));
return pendingRequest.request.promise;
},
[finishPendingPublishRequest, preparePublishReply, setPublishReplyOptions, startPendingPublishRequest],
);
useEffect(() => {
@@ -243,9 +266,10 @@ const usePublishReply = ({ cid, communityAddress, postCid }: UsePublishReplyOpti
return;
}
startedSyncedPublishRequestIdRef.current = pendingSyncedPublishRequestId;
publishReply();
}, [pendingSyncedPublishRequestId, publishReply]);
const requestId = pendingSyncedPublishRequestId;
startedSyncedPublishRequestIdRef.current = requestId;
void preparePublishReply(requestId).catch(() => finishPendingPublishRequest(requestId));
}, [finishPendingPublishRequest, pendingSyncedPublishRequestId, preparePublishReply]);
return {
isResolvingExternalQuotes,