From a9d9dcee2830886e30eb3ae2f38dacac55088429 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 12 Aug 2026 15:37:33 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=98=81=EC=88=98=EC=A6=9D=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8A=BC=20=EC=97=B0=ED=83=80=20?= =?UTF-8?q?=EC=8B=9C=20=EB=AF=B8=EC=A7=80=EC=9B=90=20=EC=95=88=EB=82=B4?= =?UTF-8?q?=EA=B0=80=20=EB=9C=A8=EB=8D=98=20=EB=AC=B8=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 공유 시트가 열린 상태에서 navigator.share() 를 다시 호출하면 InvalidStateError/NotAllowedError 가 나는데, AbortError 가 아니라는 이유로 전부 unsupported 로 떨어져 엉뚱한 안내가 노출됐다. - 중복 호출 에러를 'busy' 로 분류해 조용히 무시 - handleShareLink 에 ref 기반 연타 가드 추가 (리렌더 전 두 번째 클릭 차단) - 공유 진행 중에는 링크 공유 버튼 비활성화 — 스토리 공유와 동일한 처리 --- .../ReceiptShareDialog.tsx | 18 ++++++++++++++++-- .../[id]/result/_utils/shareReceiptImage.ts | 16 +++++++++++++--- 2 files changed, 29 insertions(+), 5 deletions(-) diff --git a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx index a114cc33..727917dc 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx @@ -78,6 +78,9 @@ function ReceiptShareDialog({ const captureLayerRef = useRef(null); const [imageBlob, setImageBlob] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); + // 버튼 비활성화용 state 와, 리렌더를 기다리지 않고 즉시 막기 위한 ref 를 함께 둔다. + const [isSharingLink, setIsSharingLink] = useState(false); + const isSharingLinkRef = useRef(false); const { shareToStory, isSharing } = useInstagramStoryShare(); /** 스토리 공유는 네이티브 전용 — SSR 은 false 로 두어 hydration mismatch 를 피한다 */ @@ -178,8 +181,19 @@ function ReceiptShareDialog({ const handleShareLink = async () => { if (!imageBlob) return; + // 연타 방지 — state 로 두면 리렌더 전에 두 번째 클릭이 들어와 공유 시트가 중복 호출된다. + if (isSharingLinkRef.current) return; - const shareResult = await shareReceiptImageFile(imageBlob); + isSharingLinkRef.current = true; + setIsSharingLink(true); + + const shareResult = await shareReceiptImageFile(imageBlob).finally(() => { + isSharingLinkRef.current = false; + setIsSharingLink(false); + }); + + /** 이미 열린 공유 시트에서 발생한 중복 호출 — 사용자 입장에선 정상이므로 조용히 무시한다 */ + if (shareResult === 'busy') return; if (shareResult === 'unsupported') { toast.warning('이 환경에서는 공유를 지원하지 않아요. 저장을 이용해주세요.'); return; @@ -246,7 +260,7 @@ function ReceiptShareDialog({ } label="링크 공유" - disabled={!imageBlob} + disabled={!imageBlob || isSharingLink} onClick={handleShareLink} /> {isAppEnvironment && ( diff --git a/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts b/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts index 49bb2fce..07f237d7 100644 --- a/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts +++ b/apps/web/src/app/tournament/[id]/result/_utils/shareReceiptImage.ts @@ -111,14 +111,21 @@ export const copyReceiptImage = async (blob: Blob): Promise => { } }; +/** + * 공유 시트가 이미 떠 있는데 navigator.share() 를 다시 부르면 나는 에러. + * 브라우저마다 이름이 갈린다 — Chrome 계열은 InvalidStateError, Safari 는 NotAllowedError. + */ +const CONCURRENT_SHARE_ERROR_NAMES = ['InvalidStateError', 'NotAllowedError']; + /** * 캡처된 blob 을 시스템 공유 시트로 전달 (카톡 등 앱 선택은 사용자 몫). * - * @returns 'shared' 공유 완료 · 'cancelled' 사용자가 시트를 닫음 · 'unsupported' 파일 공유 미지원 + * @returns 'shared' 공유 완료 · 'cancelled' 사용자가 시트를 닫음 · + * 'busy' 이미 공유 진행 중 · 'unsupported' 파일 공유 미지원 */ export const shareReceiptImageFile = async ( blob: Blob -): Promise<'shared' | 'cancelled' | 'unsupported'> => { +): Promise<'shared' | 'cancelled' | 'busy' | 'unsupported'> => { const file = new File([blob], FILE_NAME, { type: MIME }); if (typeof navigator?.canShare !== 'function' || !navigator.canShare({ files: [file] })) { @@ -129,7 +136,10 @@ export const shareReceiptImageFile = async ( await navigator.share({ files: [file] }); return 'shared'; } catch (error) { - if (error instanceof DOMException && error.name === 'AbortError') return 'cancelled'; + if (!(error instanceof DOMException)) return 'unsupported'; + if (error.name === 'AbortError') return 'cancelled'; + // 연타로 인한 중복 호출까지 미지원으로 뭉뚱그리면 엉뚱한 안내가 나간다. + if (CONCURRENT_SHARE_ERROR_NAMES.includes(error.name)) return 'busy'; return 'unsupported'; } }; From 501d74644081069b962ac55ce2aa4fd06abce316 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Wed, 12 Aug 2026 15:44:55 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20=EC=8A=A4=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EA=B3=B5=EC=9C=A0=20=EB=B2=84=ED=8A=BC=EB=8F=84=20=EC=97=B0?= =?UTF-8?q?=ED=83=80=20=EA=B0=80=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit isSharing 이 state 로만 관리돼 리렌더 전 두 번째 클릭을 막지 못했다. 링크 공유와 동일하게 ref 로 즉시 차단하고, 중복 호출은 'busy' 로 반환해 안내 없이 무시한다. --- .../receipt-share-dialog/ReceiptShareDialog.tsx | 7 +++++-- apps/web/src/hooks/useInstagramStoryShare.ts | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx index 727917dc..58fb2b3f 100644 --- a/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx +++ b/apps/web/src/app/tournament/[id]/result/_components/receipt-share-dialog/ReceiptShareDialog.tsx @@ -78,7 +78,7 @@ function ReceiptShareDialog({ const captureLayerRef = useRef(null); const [imageBlob, setImageBlob] = useState(null); const [previewUrl, setPreviewUrl] = useState(null); - // 버튼 비활성화용 state 와, 리렌더를 기다리지 않고 즉시 막기 위한 ref 를 함께 둔다. + /** state 는 버튼 비활성화용, ref 는 즉시 차단용 */ const [isSharingLink, setIsSharingLink] = useState(false); const isSharingLinkRef = useRef(false); const { shareToStory, isSharing } = useInstagramStoryShare(); @@ -162,6 +162,9 @@ function ReceiptShareDialog({ /** WebBridge 가 이미 업데이트 안내를 띄웠다 */ if (status === 'blocked') return; + /** 연타로 인한 중복 호출 — 안내 없이 무시 */ + if (status === 'busy') return; + if (status === 'notInstalled') { toast.warning('인스타그램 앱을 설치하면 스토리에 공유할 수 있어요.'); return; @@ -181,7 +184,7 @@ function ReceiptShareDialog({ const handleShareLink = async () => { if (!imageBlob) return; - // 연타 방지 — state 로 두면 리렌더 전에 두 번째 클릭이 들어와 공유 시트가 중복 호출된다. + /** 연타 방지 — state 는 리렌더 후에야 반영돼 그 사이 클릭을 막지 못한다 */ if (isSharingLinkRef.current) return; isSharingLinkRef.current = true; diff --git a/apps/web/src/hooks/useInstagramStoryShare.ts b/apps/web/src/hooks/useInstagramStoryShare.ts index 9d34c6cd..604408b2 100644 --- a/apps/web/src/hooks/useInstagramStoryShare.ts +++ b/apps/web/src/hooks/useInstagramStoryShare.ts @@ -11,7 +11,8 @@ import { WebBridge } from '@/utils/webBridge'; const RESPONSE_TIMEOUT_MS = 15_000; /** `blocked` 는 앱 버전 게이트에 막혀 전송조차 안 된 경우 */ -export type InstagramStoryShareResultT = ShareInstagramStoryStatusT | 'blocked'; +/** 'blocked' 앱 버전 게이트에 막힘 · 'busy' 이미 공유 진행 중 */ +export type InstagramStoryShareResultT = ShareInstagramStoryStatusT | 'blocked' | 'busy'; type PendingRequestT = { resolve: (status: ShareInstagramStoryStatusT) => void; @@ -26,6 +27,7 @@ type PendingRequestT = { export const useInstagramStoryShare = () => { const pendingRequestsRef = useRef>(new Map()); const [isSharing, setIsSharing] = useState(false); + const isSharingRef = useRef(false); const settleRequest = useCallback((requestId: string, status: ShareInstagramStoryStatusT) => { const pending = pendingRequestsRef.current.get(requestId); @@ -63,8 +65,12 @@ export const useInstagramStoryShare = () => { ); const shareToStory = useCallback(async (imageBlob: Blob): Promise => { + /** 연타 방지 — state 는 리렌더 후에야 반영돼 그 사이 클릭을 막지 못한다 */ + if (isSharingRef.current) return 'busy'; + try { const requestId = crypto.randomUUID(); + isSharingRef.current = true; setIsSharing(true); const base64 = await blobToBase64(imageBlob); @@ -95,6 +101,7 @@ export const useInstagramStoryShare = () => { } catch { return 'error'; } finally { + isSharingRef.current = false; setIsSharing(false); } }, []);