f07a34c51b
* 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>
70 lines
2.3 KiB
TypeScript
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.
|
|
}
|
|
},
|
|
}
|
|
}
|