diff --git a/docs/reference/Translations/index.md b/docs/reference/Translations/index.md
index 143da02f06..8f0e673543 100644
--- a/docs/reference/Translations/index.md
+++ b/docs/reference/Translations/index.md
@@ -5273,6 +5273,7 @@ Translation keys for the `Payroll.PayrollOverview` i18n namespace.
| `dataViews.taxesTable` | `"Taxes breakdown"` |
| `declineCancelCta` | `"No, go back"` |
| `directDepositLabel` | `"Direct deposits"` |
+| `downloadLoadingMessage` | `"Generating paystub…"` |
| `downloadPaystubLabel` | `"Download paystub pdf"` |
| `editCta` | `"Edit"` |
| `exitFlowCta` | `"Save and exit"` |
diff --git a/src/components/Employee/Dashboard/Dashboard.test.tsx b/src/components/Employee/Dashboard/Dashboard.test.tsx
index b31e45f134..257fbf19a9 100644
--- a/src/components/Employee/Dashboard/Dashboard.test.tsx
+++ b/src/components/Employee/Dashboard/Dashboard.test.tsx
@@ -192,10 +192,11 @@ describe('Dashboard', () => {
it('renders dashboard and loads employee data', async () => {
renderWithProviders()
- await waitFor(() => expect(screen.getByText('Legal name')).toBeInTheDocument())
-
- expect(screen.getByText('Home address')).toBeInTheDocument()
- expect(screen.getByText('Work address')).toBeInTheDocument()
+ await waitFor(() => {
+ expect(screen.getByText('Legal name')).toBeInTheDocument()
+ expect(screen.getByText('Home address')).toBeInTheDocument()
+ expect(screen.getByText('Work address')).toBeInTheDocument()
+ })
})
it('displays employee basic details', async () => {
diff --git a/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx
index 72288c4034..8942237489 100644
--- a/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx
+++ b/src/components/Employee/Paystubs/management/PaystubsCard/PaystubsCard.tsx
@@ -10,6 +10,7 @@ import { DataView, EmptyData, useDataView, Loading } from '@/components/Common'
import { BaseBoundaries, BaseLayout } from '@/components/Base/Base'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
import { useNonce } from '@/contexts/NonceProvider'
+import { openPdfInNewTab } from '@/helpers/openPdfInNewTab'
import { composeErrorHandler } from '@/partner-hook-utils/composeErrorHandler'
import {
usePaymentMethodList,
@@ -124,31 +125,7 @@ function PaystubsCardReady({
payrollUuid,
})
- // Omit `noopener` — it makes window.open return null in modern browsers,
- // which would leave us unable to navigate the new tab to the blob URL.
- const newWindow = window.open('', '_blank')
- const loadingMessage = t('downloadLoadingMessage')
- if (newWindow) {
- // Avoid the user staring at about:blank while we fetch the PDF. The
- // navigation to the Blob URL below replaces this document.
- const doc = newWindow.document
- doc.title = loadingMessage
- const style = doc.createElement('style')
- if (nonce) style.nonce = nonce
- style.textContent =
- 'body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;' +
- 'justify-content:center;height:100vh;margin:0;color:#444;gap:12px}' +
- '.spinner{width:20px;height:20px;border:2px solid #ccc;border-top-color:#444;' +
- 'border-radius:50%;animation:spin .8s linear infinite}' +
- '@keyframes spin{to{transform:rotate(360deg)}}'
- doc.head.appendChild(style)
- const spinner = doc.createElement('div')
- spinner.className = 'spinner'
- spinner.setAttribute('aria-hidden', 'true')
- const label = doc.createElement('span')
- label.textContent = loadingMessage
- doc.body.replaceChildren(spinner, label)
- }
+ const tab = openPdfInNewTab({ loadingMessage: t('downloadLoadingMessage'), nonce })
setDownloadingPayrollUuids(prev => {
const next = new Set(prev)
next.add(payrollUuid)
@@ -157,26 +134,16 @@ function PaystubsCardReady({
try {
const result = await paystubsList.actions.downloadPayStub(payrollUuid)
if (!result) {
- if (newWindow) newWindow.close()
+ tab.close()
return
}
- const url = URL.createObjectURL(result.data)
- if (newWindow) {
- // Revoke after the new tab has loaded the blob; revoking synchronously
- // would race the navigation and leave the tab blank.
- newWindow.addEventListener('load', () => {
- URL.revokeObjectURL(url)
- })
- newWindow.location.href = url
- } else {
- URL.revokeObjectURL(url)
- }
+ tab.navigate(result.data)
onEvent(componentEvents.EMPLOYEE_MANAGEMENT_PAYSTUBS_CARD_DOWNLOADED, {
employeeId,
payrollUuid,
})
} catch (err) {
- if (newWindow) newWindow.close()
+ tab.close()
showBoundary(err instanceof Error ? err : new Error(String(err)))
} finally {
setDownloadingPayrollUuids(prev => {
diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx
index 8737f95201..514a449cfd 100644
--- a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx
+++ b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx
@@ -34,6 +34,8 @@ import {
import { BaseComponent, useBase, type BaseComponentInterface } from '@/components/Base'
import { useComponentDictionary, useI18n } from '@/i18n'
import { readableStreamToBlob } from '@/helpers/readableStreamToBlob'
+import { openPdfInNewTab } from '@/helpers/openPdfInNewTab'
+import { useNonce } from '@/contexts/NonceProvider'
import useNumberFormatter from '@/hooks/useNumberFormatter'
import { useDateFormatter } from '@/hooks/useDateFormatter'
import { useComponentContext } from '@/contexts/ComponentAdapter/useComponentContext'
@@ -176,9 +178,10 @@ const Root = ({
{
refetchInterval: isPolling ? 5_000 : false,
placeholderData: keepPreviousData,
- // Always refetch on mount so a partner QueryClient with a non-zero `staleTime`
- // can't serve a stale pre-calculation snapshot without refetching. SDK-1018.
refetchOnMount: 'always',
+ // Discard the cache entry the moment this component unmounts so re-navigation
+ // never serves a stale pre-calculation snapshot (calculatedAt: null). SDK-1018.
+ gcTime: 0,
},
)
const payrollData = data?.payrollShow
@@ -330,6 +333,11 @@ const Root = ({
const { mutateAsync: cancelPayroll } = usePayrollsCancelMutation()
const gustoEmbedded = useGustoEmbeddedContext()
+ const nonce = useNonce()
+
+ const [downloadingEmployeeIds, setDownloadingEmployeeIds] = useState>(
+ () => new Set(),
+ )
if (!payrollData) {
return
@@ -385,30 +393,31 @@ const Root = ({
}
const onPaystubDownload = async (employeeId: string) => {
- // Open a blank window *synchronously* with the click
- const newWindow = window.open('', '_blank')
-
+ const tab = openPdfInNewTab({ loadingMessage: t('downloadLoadingMessage'), nonce })
+ setDownloadingEmployeeIds(prev => {
+ const next = new Set(prev)
+ next.add(employeeId)
+ return next
+ })
try {
- // Fetch the PDF from your API
const response = await payrollsGetPayStub(gustoEmbedded, { payrollId, employeeId })
if (!response.value?.responseStream) {
+ tab.close()
throw new Error(t('alerts.paystubPdfError'))
}
const pdfBlob = await readableStreamToBlob(response.value.responseStream, 'application/pdf')
-
- const url = URL.createObjectURL(pdfBlob)
-
- // Load the PDF into the new window
- if (newWindow) {
- newWindow.location.href = url
- }
+ tab.navigate(pdfBlob)
onEvent(componentEvents.RUN_PAYROLL_PDF_PAYSTUB_VIEWED, { employeeId })
- URL.revokeObjectURL(url) // Clean up the URL object after use
} catch (err) {
- if (newWindow) {
- newWindow.close()
- }
+ tab.close()
showBoundary(err instanceof Error ? err : new Error(String(err)))
+ } finally {
+ setDownloadingEmployeeIds(prev => {
+ if (!prev.has(employeeId)) return prev
+ const next = new Set(prev)
+ next.delete(employeeId)
+ return next
+ })
}
}
const onSubmit = async () => {
@@ -487,6 +496,7 @@ const Root = ({
withReimbursements={withReimbursements}
paymentSpeed={paymentSpeed}
pagination={pagination}
+ downloadingEmployeeIds={downloadingEmployeeIds}
/>
)
}
diff --git a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx
index 94c2880c97..12bd597274 100644
--- a/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx
+++ b/src/components/Payroll/PayrollOverview/PayrollOverviewPresentation.tsx
@@ -58,6 +58,7 @@ interface PayrollOverviewProps {
onUnblockOptionChange?: (blockerType: string, value: string) => void
withReimbursements?: boolean
paymentSpeed?: PaymentSpeed
+ downloadingEmployeeIds?: ReadonlySet
}
const getPayrollOverviewTitle = (
@@ -93,6 +94,7 @@ export const PayrollOverviewPresentation = ({
withReimbursements = true,
paymentSpeed,
pagination,
+ downloadingEmployeeIds = new Set(),
}: PayrollOverviewProps) => {
const { Alert, Badge, Button, ButtonIcon, Dialog, Heading, Text, Tabs } = useComponentContext()
useI18n('Payroll.PayrollOverview')
@@ -272,21 +274,27 @@ export const PayrollOverviewPresentation = ({
companyPaysColumns.push({
key: 'paystubs',
title: t('tableHeaders.paystub'),
- render: (employeeCompensations: EmployeeCompensations) => (
-
- {
- if (employeeCompensations.employeeUuid) {
- onPaystubDownload(employeeCompensations.employeeUuid)
- }
- }}
- >
-
-
-
- ),
+ render: (employeeCompensations: EmployeeCompensations) => {
+ const isDownloading =
+ !!employeeCompensations.employeeUuid &&
+ downloadingEmployeeIds.has(employeeCompensations.employeeUuid)
+ return (
+
+ {
+ if (employeeCompensations.employeeUuid) {
+ onPaystubDownload(employeeCompensations.employeeUuid)
+ }
+ }}
+ >
+
+
+
+ )
+ },
})
}
const tabs = [
@@ -301,19 +309,25 @@ export const PayrollOverviewPresentation = ({
pagination={pagination}
itemMenu={
isProcessed && !isDesktop
- ? (employeeCompensations: EmployeeCompensations) => (
- {
- if (employeeCompensations.employeeUuid) {
- onPaystubDownload(employeeCompensations.employeeUuid)
- }
- }}
- >
-
-
- )
+ ? (employeeCompensations: EmployeeCompensations) => {
+ const isDownloading =
+ !!employeeCompensations.employeeUuid &&
+ downloadingEmployeeIds.has(employeeCompensations.employeeUuid)
+ return (
+ {
+ if (employeeCompensations.employeeUuid) {
+ onPaystubDownload(employeeCompensations.employeeUuid)
+ }
+ }}
+ >
+
+
+ )
+ }
: undefined
}
footer={() => ({
diff --git a/src/helpers/openPdfInNewTab.ts b/src/helpers/openPdfInNewTab.ts
new file mode 100644
index 0000000000..cd9bfd6354
--- /dev/null
+++ b/src/helpers/openPdfInNewTab.ts
@@ -0,0 +1,51 @@
+interface PdfTabHandle {
+ navigate(blob: Blob): void
+ close(): void
+}
+
+/**
+ * Opens a new browser tab with a loading spinner, returning a handle to
+ * navigate it to a PDF blob or close it on error.
+ *
+ * @internal
+ */
+export function openPdfInNewTab(options: { loadingMessage: string; nonce?: string }): PdfTabHandle {
+ const newWindow = window.open('', '_blank')
+
+ if (newWindow) {
+ const doc = newWindow.document
+ doc.title = options.loadingMessage
+ const style = doc.createElement('style')
+ if (options.nonce) style.nonce = options.nonce
+ style.textContent =
+ 'body{font-family:system-ui,-apple-system,sans-serif;display:flex;align-items:center;' +
+ 'justify-content:center;height:100vh;margin:0;color:#444;gap:12px}' +
+ '.spinner{width:20px;height:20px;border:2px solid #ccc;border-top-color:#444;' +
+ 'border-radius:50%;animation:spin .8s linear infinite}' +
+ '@keyframes spin{to{transform:rotate(360deg)}}'
+ doc.head.appendChild(style)
+ const spinner = doc.createElement('div')
+ spinner.className = 'spinner'
+ spinner.setAttribute('aria-hidden', 'true')
+ const label = doc.createElement('span')
+ label.textContent = options.loadingMessage
+ doc.body.replaceChildren(spinner, label)
+ }
+
+ return {
+ navigate(blob: Blob) {
+ const url = URL.createObjectURL(blob)
+ if (newWindow) {
+ newWindow.addEventListener('load', () => {
+ URL.revokeObjectURL(url)
+ })
+ newWindow.location.href = url
+ } else {
+ URL.revokeObjectURL(url)
+ }
+ },
+ close() {
+ if (newWindow) newWindow.close()
+ },
+ }
+}
diff --git a/src/i18n/en/Payroll.PayrollOverview.json b/src/i18n/en/Payroll.PayrollOverview.json
index 21c0eda2a0..7959a58924 100644
--- a/src/i18n/en/Payroll.PayrollOverview.json
+++ b/src/i18n/en/Payroll.PayrollOverview.json
@@ -17,6 +17,7 @@
"declineCancelCta": "No, go back",
"payrollReceiptCta": "View payroll receipt",
"downloadPaystubLabel": "Download paystub pdf",
+ "downloadLoadingMessage": "Generating paystub…",
"loadingTitle": "Submitting payroll...",
"loadingDescription": "This may take a minute or two. You can navigate away while this happens.",
"dataLoadingTitle": "Loading payroll...",
diff --git a/src/i18n/types.d.ts b/src/i18n/types.d.ts
index 86da0385c7..481903d734 100644
--- a/src/i18n/types.d.ts
+++ b/src/i18n/types.d.ts
@@ -7735,6 +7735,8 @@ export namespace Translations {
payrollReceiptCta: string
/** @defaultValue `"Download paystub pdf"` */
downloadPaystubLabel: string
+ /** @defaultValue `"Generating paystub…"` */
+ downloadLoadingMessage: string
/** @defaultValue `"Submitting payroll..."` */
loadingTitle: string
/** @defaultValue `"This may take a minute or two. You can navigate away while this happens."` */