Files
textbee/web/components/shared/support-hq-widget.test.tsx
isra el 83d0c3803a fix(web): stop the SupportHQ widget rebuilding itself on every session refetch
The effect depended on the whole session object. SessionProvider returns a
new object on every refetch, and React compares deps by reference, so an
identical session still ran the cleanup and the body again: the widget was
destroyed and its script re-injected even though nothing about the user
had changed, closing any chat the user had open.

It now depends on the individual strings the effect actually reads, which
compare by value, so it only re-initialises when the metadata genuinely
changes.

Two related leaks fixed at the same time:

- cleanup called destroy() but left the script tag in the document, so
  every re-run added another one for the life of the page. It is now
  removed.
- a script whose src is already cached can finish loading after cleanup
  ran, and initialising then left a widget behind that nothing would
  destroy. That late load is now ignored.

Tests cover all four behaviours and fail against the previous version.

Also gives the API guide page focus before the clipboard test: writeText
rejects on an unfocused document, which is where a page sits while other
workers run, and granting clipboard permissions does not cover it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-22 14:45:40 +03:00

130 lines
3.9 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { render } from '@testing-library/react'
import SupportHQWidget from './support-hq-widget'
const useSessionMock = vi.hoisted(() => vi.fn())
vi.mock('next-auth/react', async (importOriginal) => {
const actual = await importOriginal<typeof import('next-auth/react')>()
return { ...actual, useSession: useSessionMock }
})
const init = vi.fn()
const destroy = vi.fn()
// A fresh object every call, the way SessionProvider hands one back after a
// refetch even when the underlying user has not changed.
const sessionWith = (overrides: Record<string, unknown> = {}) => ({
data: {
user: {
id: 'user_1',
name: 'Test User',
email: 'test@example.com',
phone: '',
...overrides,
},
},
})
const scripts = () =>
document.querySelectorAll('script[src*="supporthq-widget.js"]')
// jsdom does not fetch external scripts, so loading is simulated.
const fireLoad = () =>
scripts().forEach((s) => (s as HTMLScriptElement).onload?.(new Event('load')))
beforeEach(() => {
init.mockClear()
destroy.mockClear()
// @ts-ignore
window.SupportHQWidget = { init, destroy }
})
afterEach(() => {
document
.querySelectorAll('script[src*="supporthq-widget.js"]')
.forEach((s) => s.remove())
})
describe('SupportHQWidget', () => {
it('injects the widget script once and initialises with the user metadata', () => {
useSessionMock.mockReturnValue(sessionWith())
render(<SupportHQWidget />)
expect(scripts()).toHaveLength(1)
fireLoad()
expect(init).toHaveBeenCalledTimes(1)
expect(init.mock.calls[0][0].metadata).toEqual({
userId: 'user_1',
name: 'Test User',
email: 'test@example.com',
phone: '',
})
})
// The reported defect: the effect depended on the session object, so a
// refetch that returned identical data still destroyed and re-injected the
// widget, closing any chat the user had open.
it('does not reinitialise when a refetch returns identical data', () => {
useSessionMock.mockReturnValue(sessionWith())
const { rerender } = render(<SupportHQWidget />)
fireLoad()
useSessionMock.mockReturnValue(sessionWith())
rerender(<SupportHQWidget />)
expect(scripts()).toHaveLength(1)
expect(destroy).not.toHaveBeenCalled()
expect(init).toHaveBeenCalledTimes(1)
})
it('reinitialises when the user metadata actually changes', () => {
useSessionMock.mockReturnValue(sessionWith())
const { rerender } = render(<SupportHQWidget />)
fireLoad()
useSessionMock.mockReturnValue(sessionWith({ email: 'new@example.com' }))
rerender(<SupportHQWidget />)
fireLoad()
expect(destroy).toHaveBeenCalledTimes(1)
expect(init).toHaveBeenCalledTimes(2)
expect(init.mock.calls[1][0].metadata.email).toBe('new@example.com')
})
// destroy() tears down the widget but leaves the tag, so without an explicit
// removal every re-run left another one behind.
it('leaves no script tags behind when it unmounts', () => {
useSessionMock.mockReturnValue(sessionWith())
const { unmount } = render(<SupportHQWidget />)
fireLoad()
unmount()
expect(scripts()).toHaveLength(0)
expect(destroy).toHaveBeenCalledTimes(1)
})
it('does not initialise if the script loads after cleanup', () => {
useSessionMock.mockReturnValue(sessionWith())
const { unmount } = render(<SupportHQWidget />)
const pending = scripts()[0] as HTMLScriptElement
unmount()
// A cached src can resolve after the effect was torn down; initialising
// then would leave a widget that nothing destroys.
pending.onload?.(new Event('load'))
expect(init).not.toHaveBeenCalled()
})
it('omits metadata when nobody is signed in', () => {
useSessionMock.mockReturnValue({ data: null })
render(<SupportHQWidget />)
fireLoad()
expect(init).toHaveBeenCalledTimes(1)
expect(init.mock.calls[0][0]).not.toHaveProperty('metadata')
})
})