fix(invoices): stop popup blockers from silently eating the PDF preview tab (#1191)
* fix(invoices): stop popup blockers from silently eating the PDF preview tab A window.open() after an await runs outside the click's transient user activation (~5s, less in Safari), so the preview tab was popup-blocked exactly when generation was slow (cold start + logo re-encode). The request succeeded, nothing opened, no error: the button looked locked (support: carina@cbysea.se). - lib/browser/deferred-tab.ts: open the tab synchronously in the click, navigate it when the result arrives, close it on failure (the pattern AGIPanel already used for its signing tab), with unit tests. - InvoiceEditor: preview uses the deferred tab + popup-blocked toast; revoke the blob URL instead of leaking it; guard the review dialog against an unresolved customer (silent no-op click); 5s timeout on the pre-review next-number fetch; spinner + disable while the submit handler is in flight in create mode. - Same pre-open fix in TransactionAttachmentIndicator, JournalEntryAttachments (failures now toast instead of vanishing), DocumentViewButton, and the Arcim reconnect OAuth popup (its 'trusted gesture' comment was wrong after the await). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(invoices): review triage: close blocked preview tab, precise popup hint, test convention Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
b5e3c476a5
commit
f07a34c51b
@@ -0,0 +1,96 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { openDeferredTab } from '@/lib/browser/deferred-tab'
|
||||
|
||||
function makeFakeTab() {
|
||||
return {
|
||||
closed: false,
|
||||
opener: {} as unknown,
|
||||
close: vi.fn(),
|
||||
location: { href: '' },
|
||||
document: {
|
||||
title: '',
|
||||
body: { appendChild: vi.fn() },
|
||||
createElement: vi.fn(() => ({ textContent: '', style: { cssText: '' } })),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('openDeferredTab', () => {
|
||||
it('is SSR-safe: reports blocked when window is undefined', () => {
|
||||
const tab = openDeferredTab()
|
||||
expect(tab.blocked).toBe(true)
|
||||
expect(tab.navigate('https://example.com')).toBe(false)
|
||||
expect(() => tab.close()).not.toThrow()
|
||||
})
|
||||
|
||||
it('reports blocked and refuses navigation when window.open returns null', () => {
|
||||
vi.stubGlobal('window', { open: vi.fn(() => null) })
|
||||
const tab = openDeferredTab('Laddar...')
|
||||
expect(tab.blocked).toBe(true)
|
||||
expect(tab.navigate('https://example.com')).toBe(false)
|
||||
expect(() => tab.close()).not.toThrow()
|
||||
})
|
||||
|
||||
it('opens synchronously, severs opener, and navigates the pending tab', () => {
|
||||
const fake = makeFakeTab()
|
||||
const open = vi.fn(() => fake)
|
||||
vi.stubGlobal('window', { open })
|
||||
|
||||
const tab = openDeferredTab('Genererar...')
|
||||
expect(open).toHaveBeenCalledWith('', '_blank')
|
||||
expect(tab.blocked).toBe(false)
|
||||
expect(fake.opener).toBeNull()
|
||||
expect(fake.document.title).toBe('Genererar...')
|
||||
expect(fake.document.body.appendChild).toHaveBeenCalledTimes(1)
|
||||
|
||||
expect(tab.navigate('blob:https://app/abc')).toBe(true)
|
||||
expect(fake.location.href).toBe('blob:https://app/abc')
|
||||
})
|
||||
|
||||
it('skips the placeholder when none is given', () => {
|
||||
const fake = makeFakeTab()
|
||||
vi.stubGlobal('window', { open: vi.fn(() => fake) })
|
||||
openDeferredTab()
|
||||
expect(fake.document.body.appendChild).not.toHaveBeenCalled()
|
||||
expect(fake.document.title).toBe('')
|
||||
})
|
||||
|
||||
it('fails navigation once the user closed the pending tab', () => {
|
||||
const fake = makeFakeTab()
|
||||
vi.stubGlobal('window', { open: vi.fn(() => fake) })
|
||||
const tab = openDeferredTab()
|
||||
fake.closed = true
|
||||
expect(tab.navigate('https://example.com')).toBe(false)
|
||||
})
|
||||
|
||||
it('close() closes an open tab and is a no-op afterwards', () => {
|
||||
const fake = makeFakeTab()
|
||||
vi.stubGlobal('window', { open: vi.fn(() => fake) })
|
||||
const tab = openDeferredTab()
|
||||
tab.close()
|
||||
expect(fake.close).toHaveBeenCalledTimes(1)
|
||||
fake.closed = true
|
||||
tab.close()
|
||||
expect(fake.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('survives a placeholder document that throws', () => {
|
||||
const fake = makeFakeTab()
|
||||
fake.document.createElement = vi.fn(() => {
|
||||
throw new Error('detached')
|
||||
})
|
||||
vi.stubGlobal('window', { open: vi.fn(() => fake) })
|
||||
const tab = openDeferredTab('Laddar...')
|
||||
expect(tab.blocked).toBe(false)
|
||||
expect(tab.navigate('https://example.com')).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Pre-open a browser tab inside a click handler, then point it at a URL once
|
||||
* an async result arrives.
|
||||
*
|
||||
* Why: window.open() called after an `await` runs outside the click's
|
||||
* transient user activation. Browsers expire that activation after ~5 seconds
|
||||
* (Safari sooner), so popup blocking kicks in exactly when the request is
|
||||
* slow: a cold serverless start made "Förhandsgranska PDF" silently do
|
||||
* nothing (support: cbysea.se). Opening the tab synchronously keeps the
|
||||
* trusted gesture; the tab is then navigated to the real URL, or closed again
|
||||
* if the request fails. Same pattern as AGIPanel's signing tab.
|
||||
*/
|
||||
|
||||
export interface DeferredTab {
|
||||
/** True when even the synchronous open was blocked (strict popup blocker). */
|
||||
blocked: boolean
|
||||
/** Point the pending tab at the final URL. False if the tab is gone. */
|
||||
navigate(url: string): boolean
|
||||
/** Close the pending tab; call on request failure so no blank tab lingers. */
|
||||
close(): void
|
||||
}
|
||||
|
||||
/**
|
||||
* Must be called synchronously from a user-gesture handler (click), before
|
||||
* any `await`, or the open is popup-blocked in the exact slow cases this
|
||||
* helper exists for.
|
||||
*/
|
||||
export function openDeferredTab(placeholder?: string): DeferredTab {
|
||||
const tab = typeof window !== 'undefined' ? window.open('', '_blank') : null
|
||||
|
||||
if (tab) {
|
||||
try {
|
||||
// Sever the reverse channel up front: the final page never gets a
|
||||
// window.opener, preserving the noopener semantics the direct
|
||||
// window.open(url, '_blank', 'noopener') call sites had.
|
||||
tab.opener = null
|
||||
if (placeholder) {
|
||||
tab.document.title = placeholder
|
||||
const note = tab.document.createElement('p')
|
||||
note.textContent = placeholder
|
||||
note.style.cssText =
|
||||
'font-family: system-ui, sans-serif; color: #666; padding: 24px;'
|
||||
tab.document.body.appendChild(note)
|
||||
}
|
||||
} catch {
|
||||
// The placeholder is cosmetic; never let it break the open.
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
blocked: tab === null,
|
||||
navigate(url: string): boolean {
|
||||
if (!tab || tab.closed) return false
|
||||
try {
|
||||
tab.location.href = url
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
close() {
|
||||
try {
|
||||
if (tab && !tab.closed) tab.close()
|
||||
} catch {
|
||||
// Already gone; nothing to clean up.
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user