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
+18 -1
View File
@@ -4,6 +4,7 @@ import { useState } from 'react'
import { ExternalLink, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { useToast } from '@/components/ui/use-toast'
import { openDeferredTab } from '@/lib/browser/deferred-tab'
interface DocumentViewButtonProps {
documentId: string
@@ -35,10 +36,14 @@ export function DocumentViewButton({ documentId, label = 'Visa dokument', classN
return
}
setLoading(true)
// Pre-open inside the click's user activation: a window.open after the
// await is popup-blocked when the signed-URL fetch is slow.
const tab = openDeferredTab('Laddar...')
try {
const res = await fetch(`/api/documents/${documentId}`)
const json = await res.json().catch(() => ({}))
if (!res.ok || !json?.data?.download_url) {
tab.close()
toast({
title: 'Kunde inte öppna dokumentet',
description: json?.error || 'Försök igen om en stund.',
@@ -46,7 +51,19 @@ export function DocumentViewButton({ documentId, label = 'Visa dokument', classN
})
return
}
window.open(json.data.download_url as string, '_blank', 'noopener,noreferrer')
if (!tab.navigate(json.data.download_url as string)) {
tab.close()
toast({
title: 'Kunde inte öppna dokumentet',
description: tab.blocked
? 'Tillåt popupfönster för Accounted i webbläsaren och försök igen.'
: 'Försök igen om en stund.',
variant: 'destructive',
})
}
} catch {
tab.close()
toast({ title: 'Kunde inte öppna dokumentet', variant: 'destructive' })
} finally {
setLoading(false)
}
@@ -13,6 +13,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { openDeferredTab } from '@/lib/browser/deferred-tab'
import {
FileText,
ImageIcon,
@@ -70,6 +71,7 @@ export default function JournalEntryAttachments({
onCountChange,
}: JournalEntryAttachmentsProps) {
const t = useTranslations('journal_attachments')
const tCommon = useTranslations('common')
const { toast } = useToast()
const [documents, setDocuments] = useState<DocumentRecord[]>([])
const [loading, setLoading] = useState(true)
@@ -154,14 +156,23 @@ export default function JournalEntryAttachments({
}, [uploadFiles, fetchDocuments])
const handleDownload = async (docId: string) => {
// Pre-open inside the click's user activation: a window.open after the
// await is popup-blocked when the signed-URL fetch is slow.
const tab = openDeferredTab(tCommon('loading'))
try {
const res = await fetch(`/api/documents/${docId}`)
const { data } = await res.json()
if (data?.download_url) {
window.open(data.download_url, '_blank')
if (!data?.download_url || !tab.navigate(data.download_url)) {
tab.close()
toast({
title: t('download_failed'),
description: tab.blocked ? tCommon('popup_blocked_description') : undefined,
variant: 'destructive',
})
}
} catch {
// Non-critical: silently ignore
tab.close()
toast({ title: t('download_failed'), variant: 'destructive' })
}
}
@@ -1897,6 +1897,17 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setIsLoading(true)
setSelectedProvider(provider)
// Pre-open the OAuth popup inside the click's user activation: opening it
// after the fetch below is popup-blocked when the response is slow (the
// activation expires after ~5s). Kept open only for OAuth providers; the
// token path and every failure path close it again. The opener reference
// stays intact: the provider popup posts back via postMessage.
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
const popup = window.open('', 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
try {
const res = await fetch('/api/extensions/ext/arcim-migration/connect', {
method: 'POST',
@@ -1913,19 +1924,24 @@ export default function ArcimMigrationWorkspace(_props: WorkspaceComponentProps)
setAuthType(data.authType)
if (data.authType === 'oauth' && data.authUrl) {
// Open immediately: this runs inside the button's click handler, so
// the popup is a trusted user gesture and won't be blocked.
const w = 600
const h = 700
const left = window.screenX + (window.outerWidth - w) / 2
const top = window.screenY + (window.outerHeight - h) / 2
window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
if (popup && !popup.closed) {
popup.location.href = data.authUrl
} else {
// The pre-opened popup was blocked or closed; retrying here is a
// long shot (the activation may be gone) but strictly better than
// dropping the flow.
window.open(data.authUrl, 'arcim-oauth', `width=${w},height=${h},left=${left},top=${top}`)
}
setAuthUrl(data.authUrl)
} else if (data.authType === 'token') {
// Re-enter credentials for token-based providers
setStep('connect')
} else {
popup?.close()
if (data.authType === 'token') {
// Re-enter credentials for token-based providers
setStep('connect')
}
}
} catch (err) {
popup?.close()
setError(err instanceof Error ? getUserErrorMessage(err) : 'Kunde inte återansluta')
setAuthExpired(true)
} finally {
+47 -9
View File
@@ -41,6 +41,7 @@ import { useCanWrite } from '@/lib/hooks/use-can-write'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { InvoiceReviewContent } from '@/components/invoices/InvoiceReviewContent'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { openDeferredTab } from '@/lib/browser/deferred-tab'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import CustomerForm from '@/components/customers/CustomerForm'
import { BankDetailsSetupDialog } from '@/components/invoices/BankDetailsSetupDialog'
@@ -129,6 +130,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const t = useTranslations('invoice_editor')
const ts = useTranslations('self_billing')
const ta = useTranslations('accruals')
const tCommon = useTranslations('common')
// Toggle between a normal customer invoice (default) and registering a
// self-billing invoice we received (mottagen självfaktura, ML 17 kap 15§).
// Self-billing is never available when editing an existing draft.
@@ -344,7 +346,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
setValue,
setError,
getValues,
formState: { errors, isDirty, dirtyFields },
formState: { errors, isDirty, dirtyFields, isSubmitting: isFormSubmitting },
} = useForm<FormData>({
resolver: zodResolver(schema),
// Edit mode pre-fills from the existing draft (header + every line incl.
@@ -1020,12 +1022,29 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
await handleSelfBilledSubmit(data)
return
}
// The review dialog only mounts once the picked customer resolves against
// the loaded customers list. Without this guard a click while the list is
// still loading (or failed to load) set showReview on an unmounted dialog:
// the button then silently did nothing (support: cbysea.se).
if (!selectedCustomer) {
toast({
title: t('review_customer_missing_title'),
description: t('review_customer_missing_description'),
variant: 'destructive',
})
return
}
setPendingData(data)
// Re-fetch the preview right before review so the displayed number
// reflects any concurrent invoice creations. Skip for delivery notes.
// Bounded: this blocks the review dialog from opening, and a hung fetch
// must not be able to freeze the flow (the catch below eats the abort).
if (data.document_type !== 'delivery_note') {
try {
const r = await fetch(`/api/invoices/next-number?document_type=${encodeURIComponent(data.document_type)}`)
const r = await fetch(
`/api/invoices/next-number?document_type=${encodeURIComponent(data.document_type)}`,
{ signal: AbortSignal.timeout(5000) },
)
if (r.ok) {
const json = await r.json()
setNumberPreview(json?.data?.preview ?? null)
@@ -1300,6 +1319,12 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
if (!pendingData) return
setIsPreviewing(true)
// Open the tab synchronously inside the click's user activation. A
// window.open after the awaits below is popup-blocked whenever generation
// outlives the activation window (~5s): exactly the slow cold-start case,
// where the preview then silently did nothing (support: cbysea.se).
const tab = openDeferredTab(t('preview_pdf_generating'))
try {
const response = await fetch('/api/invoices/preview-pdf', {
method: 'POST',
@@ -1326,8 +1351,21 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
window.open(url, '_blank')
if (!tab.navigate(url)) {
tab.close()
window.URL.revokeObjectURL(url)
toast({
title: t('preview_pdf_failed'),
description: tCommon('popup_blocked_description'),
variant: 'destructive',
})
return
}
// The blob URL must outlive the tab's load; revoke on a generous delay
// instead of leaking it for the page's lifetime.
window.setTimeout(() => window.URL.revokeObjectURL(url), 60_000)
} catch (error) {
tab.close()
toast({
title: t('preview_pdf_failed'),
description: getErrorMessage(error, { context: 'invoice' }),
@@ -2393,11 +2431,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
type="submit"
className="w-full"
size="lg"
disabled={isSubmitting || isSavingDraft || !canWrite}
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
{isEditMode && isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isFormSubmitting && !isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditMode ? t('save_changes') : isSelfBilled ? ts('register') : t('review_and_create')}
</Button>
{!isEditMode && !isSelfBilled && watchDocumentType === 'invoice' && (
@@ -2406,7 +2444,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
variant="outline"
className="w-full"
size="lg"
disabled={isSubmitting || isSavingDraft || !canWrite}
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : t('save_as_draft_tooltip')}
onClick={handleSubmit(saveDraftData)}
>
@@ -2435,7 +2473,7 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
<Button
type="button"
variant="outline"
disabled={isSubmitting || isSavingDraft || !canWrite}
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
onClick={handleSubmit(saveDraftData)}
>
{isSavingDraft ? <Loader2 className="h-4 w-4 animate-spin" /> : t('save_as_draft_short')}
@@ -2443,11 +2481,11 @@ export default function InvoiceEditor(props: InvoiceEditorProps = { mode: 'creat
)}
<Button
type="submit"
disabled={isSubmitting || isSavingDraft || !canWrite}
disabled={isSubmitting || isSavingDraft || isFormSubmitting || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{!canWrite && <Lock className="mr-2 h-4 w-4 inline" />}
{isEditMode && isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isFormSubmitting && !isSavingDraft && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{isEditMode ? t('save_changes') : isSelfBilled ? ts('register') : t('review_and_create')}
</Button>
</div>
@@ -7,6 +7,7 @@ import { Paperclip, Loader2 } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { openDeferredTab } from '@/lib/browser/deferred-tab'
interface Props {
documentId: string | null | undefined
@@ -47,6 +48,7 @@ export function TransactionAttachmentIndicator({
className,
}: Props) {
const t = useTranslations('tx_underlag')
const tCommon = useTranslations('common')
const { toast } = useToast()
const [isLoading, setIsLoading] = useState(false)
@@ -55,16 +57,28 @@ export function TransactionAttachmentIndicator({
e.preventDefault()
if (isLoading || !documentId) return
setIsLoading(true)
// Pre-open inside the click's user activation: a window.open after the
// await is popup-blocked when the signed-URL fetch is slow.
const tab = openDeferredTab(tCommon('loading'))
try {
const res = await fetch(`/api/documents/${documentId}`)
if (!res.ok) {
tab.close()
toast({ title: t('open_failed'), variant: 'destructive' })
return
}
const { data } = await res.json()
if (data?.download_url) {
window.open(data.download_url, '_blank', 'noopener,noreferrer')
if (!data?.download_url || !tab.navigate(data.download_url)) {
tab.close()
toast({
title: t('open_failed'),
description: tab.blocked ? tCommon('popup_blocked_description') : undefined,
variant: 'destructive',
})
}
} catch {
tab.close()
toast({ title: t('open_failed'), variant: 'destructive' })
} finally {
setIsLoading(false)
}