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:
Jakob Wennberg
2026-07-25 13:43:04 +02:00
committed by GitHub
co-authored by Claude Fable 5
parent b5e3c476a5
commit f07a34c51b
10 changed files with 297 additions and 25 deletions
@@ -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)
})
})
+69
View File
@@ -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.
}
},
}
}