From 5657e2d5a81569769291a33611cea70b04f5456d Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 14:17:12 +0200 Subject: [PATCH 1/2] fix(esign): reload loaders before toasting after eSign mutations Creating a request, finishing a CIE signature and retrying a delivery now await router.invalidate({ sync: true }) before closing the dialog or showing the toast, so the shell's pending-signature badge is current. Creating a request no longer shares one catch with the reload and the navigation: once the request exists, a failed reload or navigation is logged instead of showing "Couldn't create the request." and re-enabling Create request. --- src/features/esign/new-request-page.tsx | 21 +++++-- src/features/esign/request-page.tsx | 12 ++-- src/features/esign/sign-with-cie.tsx | 2 +- tests/esign-mutation-refresh.test.mjs | 76 +++++++++++++++++++++++++ 4 files changed, 102 insertions(+), 9 deletions(-) create mode 100644 tests/esign-mutation-refresh.test.mjs diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index 749697f..65fd699 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -1,4 +1,4 @@ -import { useNavigate } from "@tanstack/react-router" +import { useNavigate, useRouter } from "@tanstack/react-router" import { useServerFn } from "@tanstack/react-start" import { ArrowDown, @@ -112,6 +112,7 @@ export function NewEsignRequestPage({ me: string }) { const navigate = useNavigate() + const router = useRouter() const uploadFn = useServerFn(uploadEsignDocument) const importFn = useServerFn(importEsignDriveDocument) const contentFn = useServerFn(getEsignDocumentContent) @@ -315,8 +316,9 @@ export function NewEsignRequestPage({ if (problems.length > 0 || !staged) return setSubmitting(true) setSubmitError(null) + let request: Awaited> try { - const request = await createFn({ + request = await createFn({ data: { title: title.trim(), documentId: staged.id, @@ -327,13 +329,24 @@ export function NewEsignRequestPage({ email: recipients.length ? { to: recipients, message: message.trim() || null } : null, }, }) - appToast.success("Request created.") - await navigate({ to: "/dashboard/esign/requests/$requestId", params: { requestId: request.id } }) } catch (error) { console.error(error) setSubmitError(error instanceof Error ? error.message : "Couldn't create the request.") setSubmitting(false) + return + } + // The request exists from here on: a failed reload or navigation must not offer to create it again. + try { + await router.invalidate({ sync: true }) + } catch (error) { + console.error(error) + } + try { + await navigate({ to: "/dashboard/esign/requests/$requestId", params: { requestId: request.id } }) + } catch (error) { + console.error(error) } + appToast.success("Request created.") } function onDrop(event: DragEvent) { diff --git a/src/features/esign/request-page.tsx b/src/features/esign/request-page.tsx index a927e19..3ac218b 100644 --- a/src/features/esign/request-page.tsx +++ b/src/features/esign/request-page.tsx @@ -191,13 +191,13 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { async function retry() { setRetrying(true) + let delivery: Awaited> | undefined + let failure: string | null = null try { - const delivery = await retryFn({ data: { id: request.id } }) - if (delivery?.status === "failed") appToast.error(`Couldn't deliver the document. ${delivery.error ?? ""}`.trim()) - else appToast.success("Document delivered.") + delivery = await retryFn({ data: { id: request.id } }) } catch (error) { console.error(error) - appToast.error(error instanceof Error ? error.message : "Couldn't deliver the document.") + failure = error instanceof Error ? error.message : "Couldn't deliver the document." } try { await router.invalidate({ sync: true }) @@ -205,6 +205,10 @@ export function EsignRequestPage({ detail }: { detail: Detail }) { console.error(error) } setRetrying(false) + if (failure !== null) appToast.error(failure) + else if (delivery?.status === "failed") + appToast.error(`Couldn't deliver the document. ${delivery.error ?? ""}`.trim()) + else appToast.success("Document delivered.") } const actions = ( diff --git a/src/features/esign/sign-with-cie.tsx b/src/features/esign/sign-with-cie.tsx index d6248c0..d1ab205 100644 --- a/src/features/esign/sign-with-cie.tsx +++ b/src/features/esign/sign-with-cie.tsx @@ -128,11 +128,11 @@ export function SignWithCie({ async (count: number, total: number) => { if (finishing.current) return finishing.current = true + await reload() setPhase(null) popupRef.current = null if (count === total) appToast.success(count === 1 ? "Document signed." : `${count} documents signed.`) else appToast.warning(`${count} of ${total} documents signed.`) - await reload() onSigned?.() }, [reload, onSigned] diff --git a/tests/esign-mutation-refresh.test.mjs b/tests/esign-mutation-refresh.test.mjs new file mode 100644 index 0000000..dfe00bf --- /dev/null +++ b/tests/esign-mutation-refresh.test.mjs @@ -0,0 +1,76 @@ +import assert from "node:assert/strict" +import { readFile } from "node:fs/promises" +import test from "node:test" + +import ts from "typescript" + +async function handler(path, name) { + const text = await readFile(path, "utf8") + const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX) + let found + function visit(node) { + if (ts.isFunctionDeclaration(node) && node.name?.text === name) found = node + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name) found = node + if (!found) ts.forEachChild(node, visit) + } + visit(source) + assert.ok(found, `${name} not found in ${path}`) + return found +} + +function calls(node, pattern) { + const found = [] + function visit(current) { + if (ts.isCallExpression(current) && pattern.test(current.expression.getText())) found.push(current) + ts.forEachChild(current, visit) + } + visit(node) + return found +} + +function insideTryBlock(node) { + for (let current = node.parent; current; current = current.parent) { + if (ts.isBlock(current) && ts.isTryStatement(current.parent) && current.parent.tryBlock === current) return current + } + return null +} + +const RELOAD = /^(router\.invalidate|reload)$/ +const TOAST = /^appToast\./ + +function assertReloadBeforeToasts(fn, label) { + const reloads = calls(fn, RELOAD) + const toasts = calls(fn, TOAST) + assert.ok(reloads.length > 0, `${label} reloads the loaders`) + assert.ok(toasts.length > 0, `${label} shows a toast`) + const reloadAt = Math.min(...reloads.map((c) => c.getStart())) + for (const toast of toasts) + assert.ok(toast.getStart() > reloadAt, `${label}: ${toast.getText()} comes before the loaders reload`) +} + +void test("creating an eSign request reloads the shell before the toast and keeps later failures out of the create error", async () => { + const submit = await handler("src/features/esign/new-request-page.tsx", "submit") + assertReloadBeforeToasts(submit, "submit") + + const [create] = calls(submit, /^createFn$/) + const createTry = insideTryBlock(create) + assert.ok(createTry, "createFn is awaited in its own try") + for (const later of [...calls(submit, RELOAD), ...calls(submit, /^navigate$/), ...calls(submit, TOAST)]) + assert.ok(later.getStart() >= createTry.end, `${later.getText()} is outside createFn's try`) + + const [navigate] = calls(submit, /^navigate$/) + const [invalidate] = calls(submit, RELOAD) + assert.ok(navigate.getStart() > invalidate.getStart(), "the new request opens after the loaders reload") + assert.match(navigate.getText(), /requestId: request\.id/, "it opens the request just created") +}) + +void test("signing with CIE reloads the page before closing the dialog and toasting", async () => { + const finish = await handler("src/features/esign/sign-with-cie.tsx", "finish") + assertReloadBeforeToasts(finish, "finish") + const [close] = calls(finish, /^setPhase$/) + assert.ok(close.getStart() > calls(finish, RELOAD)[0].getStart(), "the dialog closes after the reload") +}) + +void test("retrying an eSign delivery reloads the page before toasting the outcome", async () => { + assertReloadBeforeToasts(await handler("src/features/esign/request-page.tsx", "retry"), "retry") +}) From 5436ec9c1e0bf984ea312c3c5512e8b4ddfed6da Mon Sep 17 00:00:00 2001 From: Lorenzo Corallo Date: Sat, 17 Oct 2026 14:35:06 +0200 Subject: [PATCH 2/2] fix(esign): retain recovery navigation after request creation Keep the created request ID and an Open request link when refresh or navigation fails, while preventing a second creation. Exercise production mutation handlers with deferred refreshes and failure injection instead of static source-order checks. --- src/features/esign/new-request-page.tsx | 15 +- tests/esign-mutation-refresh.test.mjs | 217 +++++++++++++++++++----- 2 files changed, 183 insertions(+), 49 deletions(-) diff --git a/src/features/esign/new-request-page.tsx b/src/features/esign/new-request-page.tsx index 65fd699..ae9fa02 100644 --- a/src/features/esign/new-request-page.tsx +++ b/src/features/esign/new-request-page.tsx @@ -158,6 +158,7 @@ export function NewEsignRequestPage({ const [message, setMessage] = useState("") const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) + const [createdRequest, setCreatedRequest] = useState(null) const [attempted, setAttempted] = useState(false) const pages = staged?.pdf.pages ?? [] @@ -312,6 +313,7 @@ export function NewEsignRequestPage({ ].filter((p): p is string => Boolean(p)) async function submit() { + if (submitting || createdRequest) return setAttempted(true) if (problems.length > 0 || !staged) return setSubmitting(true) @@ -335,7 +337,7 @@ export function NewEsignRequestPage({ setSubmitting(false) return } - // The request exists from here on: a failed reload or navigation must not offer to create it again. + setCreatedRequest(request.id) try { await router.invalidate({ sync: true }) } catch (error) { @@ -346,6 +348,7 @@ export function NewEsignRequestPage({ } catch (error) { console.error(error) } + setSubmitting(false) appToast.success("Request created.") } @@ -363,7 +366,7 @@ export function NewEsignRequestPage({ back={{ label: "requests", link: { to: "/dashboard/esign/requests" } }} context="New request" right={ - + Create request } @@ -494,6 +497,14 @@ export function NewEsignRequestPage({
{attempted && problems.length > 0 && {problems[0]}} {submitError && {submitError}} + {createdRequest && ( + + Request created.{" "} + + Open request + + + )} {staged && ( { + resolve = yes + reject = no + }) + return { promise, resolve, reject } } -function insideTryBlock(node) { - for (let current = node.parent; current; current = current.parent) { - if (ts.isBlock(current) && ts.isTryStatement(current.parent) && current.parent.tryBlock === current) return current +function environment() { + const events = [] + const state = {} + const reload = deferred() + const dependencies = { + Error, + router: { + invalidate: (options) => { + assert.equal(options.sync, true) + events.push("reload") + return reload.promise + }, + }, + console: { error: (error) => events.push(["logged", error.message]) }, + appToast: Object.fromEntries( + ["success", "warning", "error"].map((tone) => [tone, (message) => events.push([tone, message])]) + ), + useCallback: (callback) => callback, + } + function setter(key) { + return (value) => { + state[key] = value + } } - return null + return { events, state, reload, dependencies, setter } } -const RELOAD = /^(router\.invalidate|reload)$/ -const TOAST = /^appToast\./ - -function assertReloadBeforeToasts(fn, label) { - const reloads = calls(fn, RELOAD) - const toasts = calls(fn, TOAST) - assert.ok(reloads.length > 0, `${label} reloads the loaders`) - assert.ok(toasts.length > 0, `${label} shows a toast`) - const reloadAt = Math.min(...reloads.map((c) => c.getStart())) - for (const toast of toasts) - assert.ok(toast.getStart() > reloadAt, `${label}: ${toast.getText()} comes before the loaders reload`) +async function composer(env, overrides = {}) { + return handler("src/features/esign/new-request-page.tsx", "submit", { + ...env.dependencies, + submitting: false, + createdRequest: null, + setAttempted: env.setter("attempted"), + setSubmitting: env.setter("submitting"), + setSubmitError: env.setter("error"), + setCreatedRequest: env.setter("createdRequest"), + problems: [], + staged: { id: "document-1" }, + title: "Request", + ordered: true, + signers: [], + saveToDrive: false, + folder: null, + recipients: [], + createFn: async () => { + env.events.push("create") + return { id: "request-1" } + }, + navigate: async (options) => { + env.events.push(["navigate", options.params.requestId]) + }, + ...overrides, + }) } -void test("creating an eSign request reloads the shell before the toast and keeps later failures out of the create error", async () => { - const submit = await handler("src/features/esign/new-request-page.tsx", "submit") - assertReloadBeforeToasts(submit, "submit") - - const [create] = calls(submit, /^createFn$/) - const createTry = insideTryBlock(create) - assert.ok(createTry, "createFn is awaited in its own try") - for (const later of [...calls(submit, RELOAD), ...calls(submit, /^navigate$/), ...calls(submit, TOAST)]) - assert.ok(later.getStart() >= createTry.end, `${later.getText()} is outside createFn's try`) - - const [navigate] = calls(submit, /^navigate$/) - const [invalidate] = calls(submit, RELOAD) - assert.ok(navigate.getStart() > invalidate.getStart(), "the new request opens after the loaders reload") - assert.match(navigate.getText(), /requestId: request\.id/, "it opens the request just created") +void test("creation waits for loader refresh before navigation and notification", async () => { + const env = environment() + const submit = await composer(env) + const pending = submit() + await setImmediate() + assert.deepEqual(env.events, ["create", "reload"]) + assert.equal(env.state.submitting, true) + env.reload.resolve() + await pending + assert.deepEqual(env.events, ["create", "reload", ["navigate", "request-1"], ["success", "Request created."]]) }) -void test("signing with CIE reloads the page before closing the dialog and toasting", async () => { - const finish = await handler("src/features/esign/sign-with-cie.tsx", "finish") - assertReloadBeforeToasts(finish, "finish") - const [close] = calls(finish, /^setPhase$/) - assert.ok(close.getStart() > calls(finish, RELOAD)[0].getStart(), "the dialog closes after the reload") +void test("a failed create stays retryable without navigation or a success notification", async () => { + const env = environment() + await ( + await composer(env, { + createFn: async () => { + throw new Error("Create refused") + }, + }) + )() + assert.equal(env.state.error, "Create refused") + assert.equal(env.state.submitting, false) + assert.equal(env.state.createdRequest, undefined) + assert.deepEqual(env.events, [["logged", "Create refused"]]) }) -void test("retrying an eSign delivery reloads the page before toasting the outcome", async () => { - assertReloadBeforeToasts(await handler("src/features/esign/request-page.tsx", "retry"), "retry") +void test("refresh and navigation failure preserve the created request without allowing another create", async () => { + const env = environment() + const submit = await composer(env, { + navigate: async () => { + throw new Error("Navigation failed") + }, + }) + const pending = submit() + await setImmediate() + env.reload.reject(new Error("Refresh failed")) + await pending + assert.equal(env.state.createdRequest, "request-1") + assert.equal(env.state.submitting, false) + assert.equal(env.state.error, null) + assert.deepEqual(env.events, [ + "create", + "reload", + ["logged", "Refresh failed"], + ["logged", "Navigation failed"], + ["success", "Request created."], + ]) + await ( + await composer(env, { createdRequest: env.state.createdRequest }) + )() + assert.equal(env.events.filter((event) => event === "create").length, 1) }) + +for (const refreshFails of [false, true]) { + void test(`CIE completion waits for refresh, including failure=${refreshFails}, before closing and notifying`, async () => { + const env = environment() + const dependencies = { + ...env.dependencies, + finishing: { current: false }, + setPhase: env.setter("phase"), + popupRef: { current: {} }, + onSigned: () => env.events.push("signed"), + } + dependencies.reload = await handler("src/features/esign/sign-with-cie.tsx", "reload", dependencies) + const finish = await handler("src/features/esign/sign-with-cie.tsx", "finish", dependencies) + const pending = finish(1, 2) + assert.deepEqual(env.events, ["reload"]) + assert.equal(env.state.phase, undefined) + if (refreshFails) env.reload.reject(new Error("Refresh failed")) + else env.reload.resolve() + await pending + assert.equal(env.state.phase, null) + assert.equal(dependencies.popupRef.current, null) + assert.deepEqual(env.events.slice(-2), [["warning", "1 of 2 documents signed."], "signed"]) + await finish(1, 2) + assert.equal(env.events.filter((event) => event === "reload").length, 1) + }) +} + +for (const outcome of ["delivered", "failed", "throws"]) { + void test(`delivery retry waits for refresh before reporting ${outcome}`, async () => { + const env = environment() + const retry = await handler("src/features/esign/request-page.tsx", "retry", { + ...env.dependencies, + request: { id: "request-1" }, + setRetrying: env.setter("retrying"), + retryFn: async () => { + if (outcome === "throws") throw new Error("Delivery refused") + return outcome === "failed" ? { status: "failed", error: "Mailbox refused" } : { status: "delivered" } + }, + }) + const pending = retry() + await setImmediate() + assert.ok(!env.events.some((event) => Array.isArray(event) && event[0] !== "logged")) + assert.equal(env.state.retrying, true) + env.reload.resolve() + await pending + assert.equal(env.state.retrying, false) + assert.deepEqual( + env.events.at(-1), + outcome === "delivered" + ? ["success", "Document delivered."] + : ["error", outcome === "failed" ? "Couldn't deliver the document. Mailbox refused" : "Delivery refused"] + ) + }) +}