Merge pull request #7819 from ouchihao/codex/bug-stripe-payment-unmount

fix(payment): stop late Stripe callbacks after leaving the page
This commit is contained in:
Wesley Liddick
2026-10-08 11:36:59 +08:00
committed by GitHub
2 changed files with 69 additions and 4 deletions
@@ -129,6 +129,7 @@ const wechatQrUrl = ref('')
const redirecting = ref(false)
const showPaymentElement = ref(false)
let disposed = false
let stripeInstance: Stripe | null = null
let elementsInstance: StripeElements | null = null
let redirectTimer: ReturnType<typeof setTimeout> | null = null
@@ -167,6 +168,7 @@ onMounted(async () => {
const { loadStripe } = await import('@stripe/stripe-js/pure')
const stripe = await loadStripe(publishableKey)
if (disposed) return
if (!stripe) { initError.value = t('payment.stripeLoadFailed'); return }
stripeInstance = stripe
@@ -241,6 +243,7 @@ async function confirmWechatPay(stripe: Stripe, clientSecret: string) {
}
function mountPaymentElement(stripe: Stripe, clientSecret: string) {
if (disposed) return
const isDark = document.documentElement.classList.contains('dark')
const elements = stripe.elements({
clientSecret,
@@ -284,7 +287,7 @@ let pollTimer: ReturnType<typeof setInterval> | null = null
function startPolling() {
const orderId = Number(route.query.order_id)
if (!orderId) return
if (!orderId || disposed) return
pollTimer = setInterval(async () => {
const o = await paymentStore.pollOrderStatus(orderId)
if (!o) return
@@ -298,6 +301,7 @@ function startPolling() {
}
function scheduleClose() {
if (disposed) return
if (window.opener) {
redirectTimer = setTimeout(() => { window.close() }, 2000)
} else {
@@ -308,6 +312,7 @@ function scheduleClose() {
}
onUnmounted(() => {
disposed = true
if (redirectTimer) clearTimeout(redirectTimer)
if (pollTimer) clearInterval(pollTimer)
})
@@ -1,5 +1,8 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, shallowMount } from '@vue/test-utils'
enableAutoUnmount(afterEach)
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() })
const routeState = vi.hoisted(() => ({
query: {} as Record<string, unknown>,
@@ -101,7 +104,7 @@ describe('StripePaymentView', () => {
client_secret: 'pi_secret_42',
}
routerPush.mockReset()
getOrder.mockReset()
getOrder.mockReset().mockResolvedValue({ data: orderFactory() })
paymentStore.config = { stripe_publishable_key: 'pk_test' }
paymentStore.fetchConfig.mockReset().mockResolvedValue(undefined)
paymentStore.pollOrderStatus.mockReset()
@@ -118,6 +121,63 @@ describe('StripePaymentView', () => {
window.localStorage.clear()
})
it.each(['alipay', 'wechat_pay', ''])('does not start %s payment after leaving during SDK loading', async (method) => {
routeState.query.method = method
let resolveSDK!: (value: typeof stripeInstance) => void
loadStripe.mockReturnValueOnce(new Promise((resolve) => { resolveSDK = resolve }))
const wrapper = mountView()
await flushPromises()
await flushPromises()
expect(loadStripe).toHaveBeenCalledTimes(1)
wrapper.unmount()
resolveSDK(stripeInstance)
await flushPromises()
await flushPromises()
expect(stripeInstance.confirmAlipayPayment).not.toHaveBeenCalled()
expect(stripeInstance.confirmWechatPayPayment).not.toHaveBeenCalled()
expect(stripeInstance.elements).not.toHaveBeenCalled()
})
it('does not restart polling when a WeChat QR result arrives after leaving', async () => {
vi.useFakeTimers()
routeState.query.method = 'wechat_pay'
let resolvePayment!: (value: unknown) => void
stripeInstance.confirmWechatPayPayment.mockReturnValueOnce(new Promise((resolve) => { resolvePayment = resolve }))
const wrapper = mountView()
await flushPromises()
await flushPromises()
expect(stripeInstance.confirmWechatPayPayment).toHaveBeenCalledTimes(1)
wrapper.unmount()
resolvePayment({ paymentIntent: { status: 'requires_action', next_action: {
wechat_pay_display_qr_code: { image_data_url: 'data:image/png;base64,test' }
} } })
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
expect(paymentStore.pollOrderStatus).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
})
it.each([false, true])('handles a completed poll with page unmounted=%s', async (unmounted) => {
vi.useFakeTimers()
routeState.query.method = 'wechat_pay'
stripeInstance.confirmWechatPayPayment.mockResolvedValueOnce({ paymentIntent: {
status: 'requires_action', next_action: { wechat_pay_display_qr_code: { image_data_url: 'data:image/png;base64,test' } }
} })
let resolvePoll!: (value: PaymentOrder) => void
paymentStore.pollOrderStatus.mockReturnValueOnce(new Promise((resolve) => { resolvePoll = resolve }))
const wrapper = mountView()
await flushPromises()
await flushPromises()
await vi.advanceTimersByTimeAsync(3000)
expect(paymentStore.pollOrderStatus).toHaveBeenCalledTimes(1)
if (unmounted) wrapper.unmount()
resolvePoll(orderFactory({ status: 'PAID' }))
await flushPromises()
await vi.advanceTimersByTimeAsync(2000)
expect(routerPush).toHaveBeenCalledTimes(unmounted ? 0 : 1)
expect(vi.getTimerCount()).toBe(0)
})
it('本地恢复快照缺失时使用订单接口返回的 Stripe 币种展示金额', async () => {
getOrder.mockResolvedValue({
data: orderFactory({ currency: 'HKD', pay_amount: 103 }),