Files
accounted/lib/browser/deferred-tab.ts
T
Jakob Wennberg f07a34c51b 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>
2026-07-25 13:43:04 +02:00

70 lines
2.3 KiB
TypeScript

/**
* 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.
}
},
}
}