From 7ebda7f6214f8b36998773195554e691766cc0ba Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Tue, 25 Aug 2026 14:50:21 -0600 Subject: [PATCH 1/4] fix(SDK-1231): add paystub download loading indicator to PayrollOverview PayrollOverview's paystub download opened a blank tab while fetching the PDF. Port the spinner + deferred blob-revoke pattern from PaystubsCard, extract shared new-tab logic into openPdfInNewTab helper, and add per-row button loading state. Co-Authored-By: Claude Opus 4.6 --- .../management/PaystubsCard/PaystubsCard.tsx | 43 ++---------- .../PayrollOverview/PayrollOverview.tsx | 39 +++++++---- .../PayrollOverviewPresentation.tsx | 70 +++++++++++-------- src/helpers/openPdfInNewTab.ts | 51 ++++++++++++++ src/i18n/en/Payroll.PayrollOverview.json | 1 + src/i18n/types.d.ts | 2 + 6 files changed, 125 insertions(+), 81 deletions(-) create mode 100644 src/helpers/openPdfInNewTab.ts 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..398658861f 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' @@ -330,6 +332,11 @@ const Root = ({ const { mutateAsync: cancelPayroll } = usePayrollsCancelMutation() const gustoEmbedded = useGustoEmbeddedContext() + const nonce = useNonce() + + const [downloadingEmployeeIds, setDownloadingEmployeeIds] = useState>( + () => new Set(), + ) if (!payrollData) { return @@ -385,30 +392,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 +495,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."` */ From 4024730e4e8954f62a0cd170df3150d0c5d19a77 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Tue, 25 Aug 2026 20:55:34 +0000 Subject: [PATCH 2/4] chore: update derived files --- docs/reference/Translations/index.md | 1 + 1 file changed, 1 insertion(+) 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"` | From 2d79f1d62f9ba35a9106d7f208150501880a09e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 22:43:56 +0000 Subject: [PATCH 3/4] fix: disable payroll cache persistence on PayrollOverview unmount MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Set gcTime: 0 on the payroll query in PayrollOverview so the cache entry is garbage-collected immediately when the component unmounts. Previously, the default 5-minute gcTime let a stale pre-calculation snapshot survive across navigation cycles — when the user re-entered the overview after calculating, TanStack Query served the cached data (calculatedAt: null) while the background refetch was in flight, hitting the "Payroll is not calculated" error boundary in the brief window before isFetching became true. With gcTime: 0, remounting always starts with data: undefined (which shows the loading screen) and fetches fresh. keepPreviousData still works for in-session pagination since the observer stays active while mounted. Co-Authored-By: Claude Opus 4.6 Claude-Session: https://claude.ai/code/session_017QqwvgKv1sGfyjFxwxUmw5 --- src/components/Payroll/PayrollOverview/PayrollOverview.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx index 398658861f..514a449cfd 100644 --- a/src/components/Payroll/PayrollOverview/PayrollOverview.tsx +++ b/src/components/Payroll/PayrollOverview/PayrollOverview.tsx @@ -178,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 From 5ee729afb34af06e5b4c34dfc36bcf1aaedb8ca4 Mon Sep 17 00:00:00 2001 From: Steve Jensen Date: Thu, 27 Aug 2026 13:08:50 -0600 Subject: [PATCH 4/4] fix: stabilize flaky Dashboard test by awaiting all self-fetching cards The "renders dashboard and loads employee data" test waited only for ProfileCard ("Legal name") before synchronously asserting WorkAddressCard ("Work address"). Since each card fetches independently, WorkAddressCard may not be in the DOM yet. Move all assertions into a single waitFor. Co-Authored-By: Claude Opus 4.6 --- src/components/Employee/Dashboard/Dashboard.test.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) 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 () => {