From f722fdfa6d7d4a8c2617c49d0260d131a491ae87 Mon Sep 17 00:00:00 2001 From: yyh <92089059+lyzno1@users.noreply.github.com> Date: Wed, 10 Dec 2025 12:46:01 +0800 Subject: [PATCH] fix: prevent popup blocker from blocking async window.open (#29391) --- .../components/app/app-publisher/index.tsx | 29 ++++---- web/app/components/apps/app-card.tsx | 27 ++++--- .../pricing/plans/cloud-plan-item/index.tsx | 12 +++- web/hooks/use-async-window-open.ts | 72 +++++++++++++++++++ 4 files changed, 115 insertions(+), 25 deletions(-) create mode 100644 web/hooks/use-async-window-open.ts diff --git a/web/app/components/app/app-publisher/index.tsx b/web/app/components/app/app-publisher/index.tsx index bba5ebfa21..801345798b 100644 --- a/web/app/components/app/app-publisher/index.tsx +++ b/web/app/components/app/app-publisher/index.tsx @@ -21,7 +21,6 @@ import { import { useKeyPress } from 'ahooks' import Divider from '../../base/divider' import Loading from '../../base/loading' -import Toast from '../../base/toast' import Tooltip from '../../base/tooltip' import { getKeyboardKeyCodeBySystem, getKeyboardKeyNameBySystem } from '../../workflow/utils' import AccessControl from '../app-access-control' @@ -50,6 +49,7 @@ import { AppModeEnum } from '@/types/app' import type { PublishWorkflowParams } from '@/types/workflow' import { basePath } from '@/utils/var' import UpgradeBtn from '@/app/components/billing/upgrade-btn' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' const ACCESS_MODE_MAP: Record = { [AccessMode.ORGANIZATION]: { @@ -216,18 +216,23 @@ const AppPublisher = ({ setPublished(false) }, [disabled, onToggle, open]) - const handleOpenInExplore = useCallback(async () => { - try { - const { installed_apps }: any = await fetchInstalledAppList(appDetail?.id) || {} - if (installed_apps?.length > 0) - window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank') - else + const { openAsync } = useAsyncWindowOpen() + + const handleOpenInExplore = useCallback(() => { + if (!appDetail?.id) return + + openAsync( + async () => { + const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(appDetail.id) || {} + if (installed_apps && installed_apps.length > 0) + return `${basePath}/explore/installed/${installed_apps[0].id}` throw new Error('No app found in Explore') - } - catch (e: any) { - Toast.notify({ type: 'error', message: `${e.message || e}` }) - } - }, [appDetail?.id]) + }, + { + errorMessage: 'Failed to open app in Explore', + }, + ) + }, [appDetail?.id, openAsync]) const handleAccessControlUpdate = useCallback(async () => { if (!appDetail) diff --git a/web/app/components/apps/app-card.tsx b/web/app/components/apps/app-card.tsx index 8356cfd31c..407df23913 100644 --- a/web/app/components/apps/app-card.tsx +++ b/web/app/components/apps/app-card.tsx @@ -7,7 +7,7 @@ import { useTranslation } from 'react-i18next' import { RiBuildingLine, RiGlobalLine, RiLockLine, RiMoreFill, RiVerifiedBadgeLine } from '@remixicon/react' import cn from '@/utils/classnames' import { type App, AppModeEnum } from '@/types/app' -import Toast, { ToastContext } from '@/app/components/base/toast' +import { ToastContext } from '@/app/components/base/toast' import { copyApp, deleteApp, exportAppConfig, updateAppInfo } from '@/service/apps' import type { DuplicateAppModalProps } from '@/app/components/app/duplicate-modal' import AppIcon from '@/app/components/base/app-icon' @@ -31,6 +31,7 @@ import { AccessMode } from '@/models/access-control' import { useGlobalPublicStore } from '@/context/global-public-context' import { formatTime } from '@/utils/time' import { useGetUserCanAccessApp } from '@/service/access-control' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import dynamic from 'next/dynamic' const EditAppModal = dynamic(() => import('@/app/components/explore/create-app-modal'), { @@ -242,20 +243,24 @@ const AppCard = ({ app, onRefresh }: AppCardProps) => { e.preventDefault() setShowAccessControl(true) } - const onClickInstalledApp = async (e: React.MouseEvent) => { + const { openAsync } = useAsyncWindowOpen() + + const onClickInstalledApp = (e: React.MouseEvent) => { e.stopPropagation() props.onClick?.() e.preventDefault() - try { - const { installed_apps }: any = await fetchInstalledAppList(app.id) || {} - if (installed_apps?.length > 0) - window.open(`${basePath}/explore/installed/${installed_apps[0].id}`, '_blank') - else + + openAsync( + async () => { + const { installed_apps }: { installed_apps?: { id: string }[] } = await fetchInstalledAppList(app.id) || {} + if (installed_apps && installed_apps.length > 0) + return `${basePath}/explore/installed/${installed_apps[0].id}` throw new Error('No app found in Explore') - } - catch (e: any) { - Toast.notify({ type: 'error', message: `${e.message || e}` }) - } + }, + { + errorMessage: 'Failed to open app in Explore', + }, + ) } return (
diff --git a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx index 396dd4a1b0..164ad9061a 100644 --- a/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx +++ b/web/app/components/billing/pricing/plans/cloud-plan-item/index.tsx @@ -9,6 +9,7 @@ import Toast from '../../../../base/toast' import { PlanRange } from '../../plan-switcher/plan-range-switcher' import { useAppContext } from '@/context/app-context' import { fetchBillingUrl, fetchSubscriptionUrls } from '@/service/billing' +import { useAsyncWindowOpen } from '@/hooks/use-async-window-open' import List from './list' import Button from './button' import { Professional, Sandbox, Team } from '../../assets' @@ -54,6 +55,8 @@ const CloudPlanItem: FC = ({ })[plan] }, [isCurrent, plan, t]) + const { openAsync } = useAsyncWindowOpen() + const handleGetPayUrl = async () => { if (loading) return @@ -72,8 +75,13 @@ const CloudPlanItem: FC = ({ setLoading(true) try { if (isCurrentPaidPlan) { - const res = await fetchBillingUrl() - window.open(res.url, '_blank') + await openAsync( + () => fetchBillingUrl().then(res => res.url), + { + errorMessage: 'Failed to open billing page', + windowFeatures: 'noopener,noreferrer', + }, + ) return } diff --git a/web/hooks/use-async-window-open.ts b/web/hooks/use-async-window-open.ts new file mode 100644 index 0000000000..582ab28be4 --- /dev/null +++ b/web/hooks/use-async-window-open.ts @@ -0,0 +1,72 @@ +import { useCallback } from 'react' +import Toast from '@/app/components/base/toast' + +export type AsyncWindowOpenOptions = { + successMessage?: string + errorMessage?: string + windowFeatures?: string + onError?: (error: any) => void + onSuccess?: (url: string) => void +} + +export const useAsyncWindowOpen = () => { + const openAsync = useCallback(async ( + fetchUrl: () => Promise, + options: AsyncWindowOpenOptions = {}, + ) => { + const { + successMessage, + errorMessage = 'Failed to open page', + windowFeatures = 'noopener,noreferrer', + onError, + onSuccess, + } = options + + const newWindow = window.open('', '_blank', windowFeatures) + + if (!newWindow) { + const error = new Error('Popup blocked by browser') + onError?.(error) + Toast.notify({ + type: 'error', + message: 'Popup blocked. Please allow popups for this site.', + }) + return + } + + try { + const url = await fetchUrl() + + if (url) { + newWindow.location.href = url + onSuccess?.(url) + + if (successMessage) { + Toast.notify({ + type: 'success', + message: successMessage, + }) + } + } + else { + newWindow.close() + const error = new Error('Invalid URL received') + onError?.(error) + Toast.notify({ + type: 'error', + message: errorMessage, + }) + } + } + catch (error) { + newWindow.close() + onError?.(error) + Toast.notify({ + type: 'error', + message: errorMessage, + }) + } + }, []) + + return { openAsync } +}