From b16f979eb33622aee5a85252be501bbeae0eb9f9 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 14 Jul 2026 16:44:22 +0800 Subject: [PATCH 1/3] fix: improve editor autosave (#3057) --- .../envelope-recipient-autosave-race.spec.ts | 199 ++++++++++++++++++ .../hooks/use-envelope-autosave.ts | 116 +++++----- 2 files changed, 265 insertions(+), 50 deletions(-) create mode 100644 packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-autosave-race.spec.ts diff --git a/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-autosave-race.spec.ts b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-autosave-race.spec.ts new file mode 100644 index 000000000..1d1035527 --- /dev/null +++ b/packages/app-tests/e2e/envelope-editor-v2/envelope-recipient-autosave-race.spec.ts @@ -0,0 +1,199 @@ +import { prisma } from '@documenso/prisma'; +import { expect, type Page, test } from '@playwright/test'; + +import { + clickAddSignerButton, + clickEnvelopeEditorStep, + getRecipientEmailInputs, + openDocumentEnvelopeEditor, + setRecipientEmail, + setRecipientName, + type TEnvelopeEditorSurface, +} from '../fixtures/envelope-editor'; + +/** + * Reproduction for the recipient autosave race condition. + * + * Symptom (production only, where there is real network lag): + * 1. The author adds a recipient and types its name/email. + * 2. They navigate to the "Add Fields" step. + * 3. The recipient selector shows the default "Recipient 1" placeholder + * instead of the recipient they just typed, and the typed name/email is + * silently lost. + * + * Theory (see packages/lib/client-only/hooks/use-envelope-autosave.ts): + * When the author navigates, `flushAutosave()` is awaited before the Add + * Fields page renders. If an *earlier* (empty) recipient save is still + * in-flight at that moment, `flush()` awaits that in-flight save and returns + * WITHOUT committing the newer typed data sitting in `lastArgsRef` (whose + * debounce timer it just cleared). The typed data is dropped, the empty + * recipient persists, and the selector renders "Recipient 1". + * + * This only happens when a save is still in-flight at navigation time, which is + * why it never reproduces locally (fast saves) but does on a laggy network. + * + * The test below simulates that lag by holding the first `envelope.recipient.set` + * request open. It asserts the CORRECT behaviour (typed recipient survives), so + * it is RED while the bug exists and GREEN once the autosave hook is fixed. + */ + +const RECIPIENT_SET_PROCEDURE = 'envelope.recipient.set'; + +// How long to hold the first recipient autosave "in-flight" to emulate prod lag. +const SIMULATED_NETWORK_LAG_MS = 5000; + +const FIRST_RECIPIENT = { + name: 'Alice Author', + email: 'alice-autosave-race@example.com', +}; + +const SECOND_RECIPIENT = { + name: 'Bob Builder', + email: 'bob-autosave-race@example.com', +}; + +type RecipientSetLagHandle = { + /** Resolves the instant the first recipient.set request is in-flight on the client. */ + firstRecipientSetInFlight: Promise; + /** Raw request bodies of every recipient.set call we intercepted. */ + recipientSetRequestBodies: string[]; +}; + +/** + * Installs a fake "production network lag" on the recipient autosave mutation. + * + * Only the FIRST recipient.set request is held open for `lagMs` (this is the save + * that must still be in-flight at navigation time for the race to occur). It + * resolves `firstRecipientSetInFlight` the instant it is intercepted so the test + * can keep typing while that save is pending. Subsequent recipient.set requests + * (e.g. the follow-up save the fixed hook issues) are forwarded immediately so the + * test does not pay the lag twice. + */ +const installRecipientSetLag = async (page: Page, lagMs: number): Promise => { + let markFirstInFlight: () => void = () => {}; + + const firstRecipientSetInFlight = new Promise((resolve) => { + markFirstInFlight = resolve; + }); + + const recipientSetRequestBodies: string[] = []; + + await page.route('**/api/trpc/**', async (route) => { + const request = route.request(); + + if (request.method() !== 'POST' || !request.url().includes(RECIPIENT_SET_PROCEDURE)) { + await route.continue(); + return; + } + + const callIndex = recipientSetRequestBodies.length + 1; + recipientSetRequestBodies.push(request.postData() ?? ''); + + if (callIndex === 1) { + // eslint-disable-next-line no-console + console.log(`[test] holding first ${RECIPIENT_SET_PROCEDURE} for ${lagMs}ms (simulated network lag)`); + + // The empty save is now in-flight from the client's perspective. + markFirstInFlight(); + + await new Promise((resolve) => setTimeout(resolve, lagMs)); + } else { + // eslint-disable-next-line no-console + console.log(`[test] forwarding ${RECIPIENT_SET_PROCEDURE} #${callIndex} (no lag)`); + } + + await route.continue(); + }); + + return { firstRecipientSetInFlight, recipientSetRequestBodies }; +}; + +const assertEnvelopeRecipientsPersisted = async (surface: TEnvelopeEditorSurface) => { + if (!surface.envelopeId) { + throw new Error('Expected the document editor surface to have an envelopeId'); + } + + const envelope = await prisma.envelope.findFirstOrThrow({ + where: { id: surface.envelopeId }, + include: { + recipients: { + orderBy: { signingOrder: 'asc' }, + }, + }, + }); + + const persistedEmails = envelope.recipients.map((recipient) => recipient.email).filter(Boolean); + + // eslint-disable-next-line no-console + console.log( + '[test] persisted recipients:', + JSON.stringify( + envelope.recipients.map((recipient) => ({ name: recipient.name, email: recipient.email })), + null, + 2, + ), + ); + + expect(persistedEmails).toContain(FIRST_RECIPIENT.email); + expect(persistedEmails).toContain(SECOND_RECIPIENT.email); +}; + +test.describe('envelope editor recipient autosave race (network lag)', () => { + test('document editor: typed recipient survives navigation to Add Fields', async ({ page }) => { + const surface = await openDocumentEnvelopeEditor(page); + + const { firstRecipientSetInFlight, recipientSetRequestBodies } = await installRecipientSetLag( + page, + SIMULATED_NETWORK_LAG_MS, + ); + + // 1. Add a second signer row. A blank document already has one empty default + // signer, so this schedules an autosave of TWO empty recipients + // (name='' / email='') - this is the save that will be in-flight. + await clickAddSignerButton(surface.root); + await expect(getRecipientEmailInputs(surface.root)).toHaveCount(2); + + // 2. Wait until that empty autosave is actually in-flight on the client. This + // is the precondition the bug needs: a slow save holding the autosave lock. + await firstRecipientSetInFlight; + + // 3. The author now fills in the recipients they are adding. + await setRecipientName(surface.root, 0, FIRST_RECIPIENT.name); + await setRecipientEmail(surface.root, 0, FIRST_RECIPIENT.email); + await setRecipientName(surface.root, 1, SECOND_RECIPIENT.name); + await setRecipientEmail(surface.root, 1, SECOND_RECIPIENT.email); + + // 4. Immediately navigate to Add Fields (before the typed data's debounce + // fires). flushAutosave() awaits the in-flight EMPTY save; with the bug + // present it returns without ever committing the typed data. + await clickEnvelopeEditorStep(surface.root, 'addFields'); + + // 5. Wait for the Add Fields page to render (after the lagged flush resolves). + await expect(surface.root.getByText('Selected Recipient')).toBeVisible({ + timeout: SIMULATED_NETWORK_LAG_MS + 15000, + }); + + // Diagnostics - the request bodies show what actually reached the server. + // Buggy: only the first (empty) save is ever sent. Fixed: a follow-up save + // carrying the typed recipients is sent too. + // eslint-disable-next-line no-console + console.log('\n===== AUTOSAVE RACE DIAGNOSTICS ====='); + // eslint-disable-next-line no-console + console.log(`recipient.set requests sent to server: ${recipientSetRequestBodies.length}`); + // eslint-disable-next-line no-console + console.log( + `server ever received "${FIRST_RECIPIENT.email}": ${recipientSetRequestBodies.some((body) => body.includes(FIRST_RECIPIENT.email))}`, + ); + // eslint-disable-next-line no-console + console.log('=====================================\n'); + + // 6. THE USER-VISIBLE BUG: the selected recipient must be the one we typed + // (Alice), not the default "Recipient 1" placeholder. + const selectedRecipientSection = surface.root.locator('section').filter({ hasText: 'Selected Recipient' }); + + await expect(selectedRecipientSection.getByRole('combobox')).toContainText(FIRST_RECIPIENT.name); + + // 7. THE DATA LOSS: the typed recipients must actually be persisted. + await assertEnvelopeRecipientsPersisted(surface); + }); +}); diff --git a/packages/lib/client-only/hooks/use-envelope-autosave.ts b/packages/lib/client-only/hooks/use-envelope-autosave.ts index 1ca70c28c..93cc8a162 100644 --- a/packages/lib/client-only/hooks/use-envelope-autosave.ts +++ b/packages/lib/client-only/hooks/use-envelope-autosave.ts @@ -1,84 +1,100 @@ import { useCallback, useEffect, useRef, useState } from 'react'; +/** + * Debounced autosave for the envelope editor (recipients, fields, settings). + * + * Only one save runs at a time and the latest edit always wins. If the user + * keeps editing while a save is on the wire, their newest changes get saved + * right after, never dropped. + */ export function useEnvelopeAutosave(saveFn: (data: T) => Promise, delay = 1000) { const timeoutRef = useRef | null>(null); - const lastArgsRef = useRef(null); - const pendingPromiseRef = useRef | null>(null); + + // The edit waiting to be saved. Wrapped in an object so null always means "nothing queued". + const pendingRef = useRef<{ value: T } | null>(null); + + // The save currently running, if any. Shared so we never kick off two at once. + const commitPromiseRef = useRef | null>(null); + + // saveFn closes over editor state, so keep the latest one around without + // making triggerSave/flush depend on it. + const saveFnRef = useRef(saveFn); + saveFnRef.current = saveFn; const [isPending, setIsPending] = useState(false); const [isCommiting, setIsCommiting] = useState(false); + /** + * Runs saves one at a time until the queue is empty. Anything queued + * mid-save gets picked up on the next loop. + */ + const commit = useCallback((): Promise => { + if (commitPromiseRef.current) { + return commitPromiseRef.current; + } + + if (!pendingRef.current) { + return Promise.resolve(); + } + + const pump = (async () => { + try { + setIsCommiting(true); + + while (pendingRef.current) { + const { value } = pendingRef.current; + pendingRef.current = null; + + await saveFnRef.current(value); + } + } finally { + // eslint-disable-next-line require-atomic-updates + commitPromiseRef.current = null; + setIsCommiting(false); + setIsPending(false); + } + })(); + + commitPromiseRef.current = pump; + + return pump; + }, []); + const triggerSave = useCallback( (data: T) => { - lastArgsRef.current = data; + pendingRef.current = { value: data }; - // A debounce or promise means something is pending setIsPending(true); if (timeoutRef.current) { clearTimeout(timeoutRef.current); } - // eslint-disable-next-line @typescript-eslint/no-misused-promises - timeoutRef.current = setTimeout(async () => { - if (!lastArgsRef.current) { - return; - } - - const args = lastArgsRef.current; - lastArgsRef.current = null; + timeoutRef.current = setTimeout(() => { timeoutRef.current = null; - - setIsCommiting(true); - pendingPromiseRef.current = saveFn(args); - - try { - await pendingPromiseRef.current; - } finally { - // eslint-disable-next-line require-atomic-updates - pendingPromiseRef.current = null; - setIsCommiting(false); - setIsPending(false); - } + void commit(); }, delay); }, - [saveFn, delay], + [commit, delay], ); + /** + * Skip the debounce and save now. The editor calls this when it needs + * everything persisted, e.g. before sending or switching steps. + */ const flush = useCallback(async () => { if (timeoutRef.current) { clearTimeout(timeoutRef.current); timeoutRef.current = null; } - if (pendingPromiseRef.current) { - // Already running → wait for it - await pendingPromiseRef.current; - return; - } - - if (lastArgsRef.current) { - const args = lastArgsRef.current; - lastArgsRef.current = null; - - setIsCommiting(true); - setIsPending(true); - - pendingPromiseRef.current = saveFn(args); - try { - await pendingPromiseRef.current; - } finally { - // eslint-disable-next-line require-atomic-updates - pendingPromiseRef.current = null; - setIsCommiting(false); - setIsPending(false); - } - } - }, [saveFn]); + await commit(); + }, [commit]); + // Last-ditch attempt to save if the tab closes with unsaved edits. useEffect(() => { const handleBeforeUnload = () => { - if (timeoutRef.current || pendingPromiseRef.current) { + if (timeoutRef.current || pendingRef.current || commitPromiseRef.current) { void flush(); } }; From 12223c79cb6469108480bbd5c42c3aa39708a3de Mon Sep 17 00:00:00 2001 From: Ephraim Duncan <55143799+ephraimduncan@users.noreply.github.com> Date: Tue, 14 Jul 2026 09:40:40 +0000 Subject: [PATCH 2/3] fix(ui): improve destructive button contrast in dark mode (#3071) --- apps/docs/src/app/global.css | 2 +- packages/ui/styles/theme.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/docs/src/app/global.css b/apps/docs/src/app/global.css index 7d9c89316..ce71134c8 100644 --- a/apps/docs/src/app/global.css +++ b/apps/docs/src/app/global.css @@ -83,7 +83,7 @@ --accent: hsl(0 0% 27.8431%); --accent-foreground: hsl(95.0847 71.0843% 67.451%); --destructive: hsl(0 86.5979% 61.9608%); - --destructive-foreground: hsl(0 87.6289% 19.0196%); + --destructive-foreground: hsl(0 0% 98.0392%); --border: hsl(0 0% 27.8431%); --input: hsl(0 0% 27.8431%); --ring: hsl(95.0847 71.0843% 67.451%); diff --git a/packages/ui/styles/theme.css b/packages/ui/styles/theme.css index caa767433..fb125c026 100644 --- a/packages/ui/styles/theme.css +++ b/packages/ui/styles/theme.css @@ -174,7 +174,7 @@ --accent-foreground: 95.08 71.08% 67.45%; --destructive: 0 87% 62%; - --destructive-foreground: 0 87% 19%; + --destructive-foreground: 0 0% 98%; --ring: 95.08 71.08% 67.45%; From d6268b1d7df560d984cb019ea719c972a68b6ee7 Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Tue, 14 Jul 2026 19:13:40 +0800 Subject: [PATCH 3/3] fix: missing noreply email for resend (#3094) --- package-lock.json | 22 ++++---- package.json | 2 +- packages/email/package.json | 4 +- packages/email/transports/mailchannels.ts | 5 ++ .../email/transports/normalize-headers.ts | 55 +++++++++++++++++++ 5 files changed, 74 insertions(+), 14 deletions(-) create mode 100644 packages/email/transports/normalize-headers.ts diff --git a/package-lock.json b/package-lock.json index ac1c7d2f1..4f4159749 100644 --- a/package-lock.json +++ b/package-lock.json @@ -49,7 +49,7 @@ "inngest-cli": "^1.17.9", "lint-staged": "^16.2.7", "nanoid": "^5.1.6", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.0", "pdfjs-dist": "5.4.296", "pino": "^9.14.0", "pino-pretty": "^13.1.2", @@ -3048,15 +3048,15 @@ "link": true }, "node_modules/@documenso/nodemailer-resend": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/@documenso/nodemailer-resend/-/nodemailer-resend-4.0.0.tgz", - "integrity": "sha512-o2Wpz8jJXOov+CbEzWUhqXlBsdx/l/W0au054j5HUExaLpS+sCoJfdOM0A5uHGTInWOxqbNB0WSvxf9dWAzVSg==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@documenso/nodemailer-resend/-/nodemailer-resend-5.0.0.tgz", + "integrity": "sha512-PDC7Yjzg35cS5i2TSJEZSP9j86BsYv4pEZOODurkK8+KFd3ymtWUqJrM6PrSNQ4MQpFp7chHlbBFP0ALYl8n0A==", "license": "MIT", "dependencies": { - "resend": "^4.0.0" + "resend": "^4.8.0" }, "peerDependencies": { - "nodemailer": "^6.9.3" + "nodemailer": "^9.0.0" } }, "node_modules/@documenso/nodemailer-resend/node_modules/@react-email/render": { @@ -24494,9 +24494,9 @@ "license": "MIT" }, "node_modules/nodemailer": { - "version": "8.0.11", - "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-8.0.11.tgz", - "integrity": "sha512-nrO/pDAUKl+wXX+lx16tDLbnm0fW6sK/x8mgohaCpg+CdCEl482bD4tCuAZk2DyliruiNTIZxRCoWkDqJEnAiA==", + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-9.0.3.tgz", + "integrity": "sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==", "license": "MIT-0", "engines": { "node": ">=6.0.0" @@ -31270,7 +31270,7 @@ "version": "0.0.0", "license": "MIT", "dependencies": { - "@documenso/nodemailer-resend": "4.0.0", + "@documenso/nodemailer-resend": "5.0.0", "@documenso/tailwind-config": "*", "@react-email/body": "0.2.0", "@react-email/button": "0.2.0", @@ -31291,7 +31291,7 @@ "@react-email/section": "0.0.16", "@react-email/tailwind": "^2.0.1", "@react-email/text": "0.1.5", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.0", "react-email": "^5.0.6", "resend": "^6.5.2" }, diff --git a/package.json b/package.json index 808577cbc..2800c7f84 100644 --- a/package.json +++ b/package.json @@ -66,7 +66,7 @@ "inngest-cli": "^1.17.9", "lint-staged": "^16.2.7", "nanoid": "^5.1.6", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.0", "pdfjs-dist": "5.4.296", "pino": "^9.14.0", "pino-pretty": "^13.1.2", diff --git a/packages/email/package.json b/packages/email/package.json index bb549f70b..5fbc4a88c 100644 --- a/packages/email/package.json +++ b/packages/email/package.json @@ -17,7 +17,7 @@ "clean": "rimraf node_modules" }, "dependencies": { - "@documenso/nodemailer-resend": "4.0.0", + "@documenso/nodemailer-resend": "5.0.0", "@documenso/tailwind-config": "*", "@react-email/body": "0.2.0", "@react-email/button": "0.2.0", @@ -38,7 +38,7 @@ "@react-email/section": "0.0.16", "@react-email/tailwind": "^2.0.1", "@react-email/text": "0.1.5", - "nodemailer": "^8.0.5", + "nodemailer": "^9.0.0", "react-email": "^5.0.6", "resend": "^6.5.2" }, diff --git a/packages/email/transports/mailchannels.ts b/packages/email/transports/mailchannels.ts index 93e82804d..923346f79 100644 --- a/packages/email/transports/mailchannels.ts +++ b/packages/email/transports/mailchannels.ts @@ -3,6 +3,8 @@ import type { SentMessageInfo, Transport } from 'nodemailer'; import type { Address } from 'nodemailer/lib/mailer'; import type MailMessage from 'nodemailer/lib/mailer/mail-message'; +import { normalizeMailHeaders } from './normalize-headers'; + const VERSION = '1.0.0'; type NodeMailerAddress = string | Address | Array | undefined; @@ -54,6 +56,7 @@ export class MailChannelsTransport implements Transport { const mailBcc = this.toMailChannelsAddresses(mail.data.bcc); const [from] = this.toMailChannelsAddresses(mail.data.from); + const [replyTo] = this.toMailChannelsAddresses(mail.data.replyTo); if (!from) { return callback(new Error('Missing required field "from"'), null); @@ -72,6 +75,8 @@ export class MailChannelsTransport implements Transport { headers: requestHeaders, body: JSON.stringify({ from: from, + reply_to: replyTo, + headers: normalizeMailHeaders(mail.data.headers), subject: mail.data.subject, personalizations: [ { diff --git a/packages/email/transports/normalize-headers.ts b/packages/email/transports/normalize-headers.ts new file mode 100644 index 000000000..4dca6c8f1 --- /dev/null +++ b/packages/email/transports/normalize-headers.ts @@ -0,0 +1,55 @@ +import type Mail from 'nodemailer/lib/mailer'; + +/** + * Normalizes nodemailer mail headers into the flat `Record` + * shape accepted by HTTP email APIs such as Resend and MailChannels. + * + * Kept in sync with `toResendHeaders` in the `@documenso/nodemailer-resend` + * package, which applies the same normalization for the Resend transport. + */ +export const normalizeMailHeaders = (headers: Mail.Options['headers']): Record | undefined => { + if (!headers) { + return undefined; + } + + const normalized: Record = {}; + + const appendHeader = (key: string, value: unknown) => { + if (value === null || value === undefined) { + return; + } + + const stringValue = String(value); + + normalized[key] = normalized[key] ? `${normalized[key]}, ${stringValue}` : stringValue; + }; + + if (Array.isArray(headers)) { + for (const { key, value } of headers) { + appendHeader(key, value); + } + } else { + for (const [key, value] of Object.entries(headers)) { + if (Array.isArray(value)) { + for (const item of value) { + appendHeader(key, item); + } + + continue; + } + + if (typeof value === 'object' && value !== null) { + appendHeader(key, value.value); + continue; + } + + appendHeader(key, value); + } + } + + if (Object.keys(normalized).length === 0) { + return undefined; + } + + return normalized; +};