mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
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:
@@ -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';
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user