A tag that throws at sign-up never keeps the new account on the page

The account exists before its completion is stated, so track() runs inside a
try: a throwing pixel is logged, and the browser still leaves for the app that
asked.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
zooqueen
2026-10-16 07:09:27 -07:00
co-authored by Hanzo Dev
parent d6d6e203f6
commit 7505a7b8bc
2 changed files with 75 additions and 2 deletions
+67
View File
@@ -0,0 +1,67 @@
/**
* The account exists before the completion is stated, so stating it can never
* be what keeps the person on this page: a tag that throws inside `track()`
* (a broken vendor build, an extension's shim) still lets the browser leave for
* the app that asked.
*/
import { afterEach, test, vi } from 'vitest'
import assert from 'node:assert/strict'
import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react'
import type { Brand } from '@hanzo/id-shared'
import { createAuthClient } from '@hanzo/id-auth'
import { Signup } from './Signup'
vi.mock('@hanzo/event', async (actual) => ({
...(await actual<typeof import('@hanzo/event')>()),
track: () => {
throw new Error('pixel threw')
},
}))
afterEach(cleanup)
const ORG = {
orgId: 'hanzo',
iamUrl: 'https://hanzo.id',
iamIssuer: 'https://hanzo.id',
clientId: 'hanzo-console',
appName: 'hanzo-console',
publicOrigin: 'https://hanzo.id',
brandPackage: '@hanzo/brand',
} as Parameters<typeof createAuthClient>[0]['org']
const BRAND = { name: 'Hanzo', logoUrl: '', faviconUrl: '' } as unknown as Brand
const REDIRECT = 'https://hanzo.ai/auth/callback'
/** IAM registers the account and mints the code the app asked for. */
const iam = (async (input: RequestInfo | URL) => {
const path = new URL(input.toString()).pathname
const body =
path === '/v1/iam/signup'
? { status: 'ok', data: { id: 'u-1', owner: 'hanzo', name: 'ada' } }
: path === '/v1/iam/login'
? { status: 'ok', data: 'c-minted' }
: { status: 'ok', data: { enableSignUp: true } }
return new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } })
}) as unknown as typeof fetch
test('a tag that throws at completion does not keep the new account on this page', async () => {
const request = new URLSearchParams({ client_id: 'hanzo-app', redirect_uri: REDIRECT, response_type: 'code', state: 'S' })
;(window as unknown as { happyDOM: { setURL(u: string): void } }).happyDOM.setURL(`https://hanzo.id/signup?${request}`)
const here = window.location.href
const gone: string[] = []
Object.defineProperty(window.location, 'href', { configurable: true, get: () => here, set: (u: string) => void gone.push(u) })
vi.spyOn(console, 'error').mockImplementation(() => {})
const { container } = render(<Signup client={createAuthClient({ org: ORG, fetchImpl: iam })} brand={BRAND} />)
await waitFor(() => assert.ok(container.querySelector('input[type=password]')))
await act(async () => {
fireEvent.change(container.querySelector('input[type=email]')!, { target: { value: '[email protected]' } })
fireEvent.change(container.querySelector('input[type=password]')!, { target: { value: 'correct horse battery staple' } })
})
await act(async () => void fireEvent.click(container.querySelector('button[type=submit]')!))
await waitFor(() => assert.equal(gone.length, 1, 'the browser leaves for the app'))
assert.ok(gone[0]!.startsWith(REDIRECT), `to the app that asked, got ${gone[0]}`)
assert.equal(container.querySelector('[role=alert]')?.textContent ?? '', '', 'and no error is shown')
})
+8 -2
View File
@@ -162,8 +162,14 @@ export function Signup({ client, brand }: { client: AuthClient; brand: Brand })
if (subject) analytics.identify(subject)
// ONE moment, under one event id, to our stream and to each of the
// brand's tags the visitor's choice allows (GA4 `sign_up`, Meta
// `CompleteRegistration`), so no platform counts it twice.
track(analytics, EVENTS.SIGNUP_COMPLETED, { method: 'email' })
// `CompleteRegistration`), so no platform counts it twice. The
// account exists already and the browser leaves for it next, so a
// tag that throws here must not keep it on this page.
try {
track(analytics, EVENTS.SIGNUP_COMPLETED, { method: 'email' })
} catch (err) {
console.error('[event] signup_completed was not stated', err)
}
// The browser leaves for the app on the next line, so the whole
// buffer goes now rather than waiting on the flush timer.
analytics.flush(true)