Skip to content

Commit baab352

Browse files
committed
fix(billing): finish the portal tab after the page that opened it unmounts
1 parent e308b6d commit baab352

2 files changed

Lines changed: 131 additions & 13 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
/** @vitest-environment jsdom */
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
import { renderDataHook, unmountDataHooks } from '@/platform/lib/render-hook.test-support'
4+
5+
const mocks = vi.hoisted(() => ({ requestJson: vi.fn() }))
6+
7+
vi.mock('@/lib/api/client/request', () => ({ requestJson: mocks.requestJson }))
8+
9+
import { useOpenBillingPortal } from '@/hooks/queries/subscription'
10+
11+
const BODY = { context: 'user' } as const
12+
const PORTAL_URL = 'https://billing.example.test/session'
13+
14+
function deferredPortal() {
15+
let resolve: (value: { url: string }) => void = () => {}
16+
let reject: (error: Error) => void = () => {}
17+
mocks.requestJson.mockReturnValueOnce(
18+
new Promise((res, rej) => {
19+
resolve = res
20+
reject = rej
21+
})
22+
)
23+
return { resolve, reject }
24+
}
25+
26+
async function settle() {
27+
await vi.waitFor(() => expect(mocks.requestJson).toHaveBeenCalled())
28+
}
29+
30+
/**
31+
* Failure mode: the tab opened before the request belongs to the viewer's click, so it has to reach
32+
* the portal or close whatever the page does in the meantime, including leave.
33+
*/
34+
describe('useOpenBillingPortal after the page that asked has gone', () => {
35+
const portalTab = { location: { href: '' }, close: vi.fn() }
36+
const openTab = vi.fn()
37+
38+
beforeEach(() => {
39+
mocks.requestJson.mockReset()
40+
portalTab.location.href = ''
41+
portalTab.close.mockReset()
42+
openTab.mockReset().mockReturnValue(portalTab)
43+
vi.stubGlobal('open', openTab)
44+
})
45+
46+
afterEach(async () => {
47+
await unmountDataHooks()
48+
vi.unstubAllGlobals()
49+
})
50+
51+
it('still sends the opened tab to the portal', async () => {
52+
const portal = deferredPortal()
53+
const hook = await renderDataHook(() => useOpenBillingPortal())
54+
hook.result.current.open(BODY, vi.fn())
55+
await settle()
56+
await hook.unmount()
57+
58+
portal.resolve({ url: PORTAL_URL })
59+
60+
await vi.waitFor(() => expect(portalTab.location.href).toBe(PORTAL_URL))
61+
})
62+
63+
it('still closes the opened tab and reports a failed request', async () => {
64+
const portal = deferredPortal()
65+
const onError = vi.fn()
66+
const hook = await renderDataHook(() => useOpenBillingPortal())
67+
hook.result.current.open(BODY, onError)
68+
await settle()
69+
await hook.unmount()
70+
71+
portal.reject(new Error('no portal'))
72+
73+
await vi.waitFor(() => expect(portalTab.close).toHaveBeenCalledOnce())
74+
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: 'no portal' }))
75+
})
76+
77+
it('leaves the current page alone when a blocked tab outlives the page that asked', async () => {
78+
const location = { href: 'https://app.example.test/elsewhere' }
79+
vi.stubGlobal('location', location)
80+
openTab.mockReturnValue(null)
81+
const portal = deferredPortal()
82+
const hook = await renderDataHook(() => useOpenBillingPortal())
83+
hook.result.current.open(BODY, vi.fn())
84+
await settle()
85+
await hook.unmount()
86+
87+
portal.resolve({ url: PORTAL_URL })
88+
await vi.waitFor(() => expect(hook.queryClient.isMutating()).toBe(0))
89+
90+
expect(location.href).toBe('https://app.example.test/elsewhere')
91+
})
92+
93+
it('takes the current tab when one is blocked and the page is still here', async () => {
94+
const location = { href: 'https://app.example.test/billing' }
95+
vi.stubGlobal('location', location)
96+
openTab.mockReturnValue(null)
97+
const portal = deferredPortal()
98+
const hook = await renderDataHook(() => useOpenBillingPortal())
99+
hook.result.current.open(BODY, vi.fn())
100+
await settle()
101+
102+
portal.resolve({ url: PORTAL_URL })
103+
104+
await vi.waitFor(() => expect(location.href).toBe(PORTAL_URL))
105+
})
106+
})

‎apps/sim/hooks/queries/subscription.ts‎

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -204,30 +204,42 @@ export function useUpdateUsageLimit() {
204204

205205
type OpenBillingPortalParams = ContractBodyInput<typeof createBillingPortalContract>
206206

207+
interface OpenBillingPortalVariables {
208+
body: OpenBillingPortalParams
209+
portalWindow: Window | null
210+
onError: (error: Error) => void
211+
}
212+
207213
/**
208214
* Opens the billing portal in a new tab. The tab opens before the request so a popup blocker still
209-
* treats it as the viewer's click; if one blocks it anyway, the portal takes the current tab. A failed
210-
* request closes the tab and reports through `onError`.
215+
* treats it as the viewer's click, and it reaches the portal or closes even if the page that asked
216+
* has gone, so it is never stranded blank. If a blocker stops the tab, the portal takes the current
217+
* tab, but only while that page is still mounted: a viewer who moved on is not pulled away.
211218
*/
212219
export function useOpenBillingPortal() {
213220
const portal = useMutation({
214-
mutationFn: (body: OpenBillingPortalParams) =>
221+
mutationFn: ({ body }: OpenBillingPortalVariables) =>
215222
requestJson(createBillingPortalContract, { body }),
223+
onSuccess: (data, { portalWindow }) => {
224+
if (portalWindow) portalWindow.location.href = data.url
225+
},
226+
onError: (error, { portalWindow, onError }) => {
227+
portalWindow?.close()
228+
onError(error)
229+
},
216230
})
217231
return {
218232
isPending: portal.isPending,
219233
open: (body: OpenBillingPortalParams, onError: (error: Error) => void) => {
220234
const portalWindow = window.open('', '_blank')
221-
portal.mutate(body, {
222-
onSuccess: (data) => {
223-
if (portalWindow) portalWindow.location.href = data.url
224-
else window.location.href = data.url
225-
},
226-
onError: (error) => {
227-
portalWindow?.close()
228-
onError(error)
229-
},
230-
})
235+
portal.mutate(
236+
{ body, portalWindow, onError },
237+
{
238+
onSuccess: (data) => {
239+
if (!portalWindow) window.location.href = data.url
240+
},
241+
}
242+
)
231243
},
232244
}
233245
}

0 commit comments

Comments
 (0)