Fix the blank window after an export

Every successful export left the window blank and unresponsive, so the app had to be killed. The "Saved to ..." notice
(and the export-failed notice) held buttons inside a Carbon InlineNotification. Carbon throws "component should have no
interactive child nodes" in an effect, and an uncaught throw makes React unmount the whole tree. Found by driving the
real debug binary through WebKitWebDriver: the page body was empty and the error listener showed that throw.

- The buttons now sit beside the notice, and the failed-export notice uses ActionableNotification's own Retry action.
- ErrorBoundary around the app (reload screen instead of a blank window) and around each export notice.
- A lint test that fails if a Carbon notification wraps an interactive element.
- The real-webview self-test mounts the saved and failed notices and fails on an uncaught React error, and issues and
  exports a second invoice after the first.

Claude-Session: https://claude.ai/code/session_01PZypiWDfMkDTeEPeXjRhW5
This commit is contained in:
2026-10-06 09:35:10 +05:30
parent 66a39b2291
commit cd067ad584
9 changed files with 225 additions and 22 deletions
+51
View File
@@ -0,0 +1,51 @@
import { Component, type ErrorInfo, type ReactNode } from "react";
interface Props {
children: ReactNode;
/** What to show instead of the children. Without it, a full-window notice with a Reload button. */
fallback?: (error: Error, reset: () => void) => ReactNode;
}
interface State {
error: Error | null;
}
/**
* React unmounts the whole tree when a component throws and nothing catches it, which leaves a blank window that
* looks like a hang. This keeps the failure to the part that threw (or, at the top, shows what happened and a way back).
* Plain elements on purpose: the component library may be what threw.
*/
export default class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("Voiced UI error:", error, info.componentStack);
}
reset = () => this.setState({ error: null });
render() {
const { error } = this.state;
if (!error) return this.props.children;
if (this.props.fallback) return this.props.fallback(error, this.reset);
return (
<div
role="alert"
style={{ padding: "2rem", maxWidth: "40rem", margin: "0 auto", color: "var(--cds-text-primary, #161616)", background: "var(--cds-background, #f4f4f4)", minHeight: "100vh" }}
>
<h1 style={{ fontSize: "1.5rem", marginBottom: "0.75rem" }}>Something went wrong</h1>
<p style={{ marginBottom: "1rem" }}>
Voiced hit an unexpected error. Your invoices and drafts are saved. Reload to carry on.
</p>
<pre style={{ whiteSpace: "pre-wrap", fontSize: "0.8125rem", marginBottom: "1.5rem" }}>{error.message}</pre>
<button type="button" className="cds--btn cds--btn--primary" onClick={() => window.location.reload()}>
Reload Voiced
</button>
</div>
);
}
}
+23 -18
View File
@@ -1,5 +1,5 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Button, InlineLoading, InlineNotification } from "@carbon/react"; import { ActionableNotification, Button, InlineLoading, InlineNotification } from "@carbon/react";
import { api } from "../lib/api"; import { api } from "../lib/api";
import type { InvoiceExport } from "../hooks/useInvoiceExport"; import type { InvoiceExport } from "../hooks/useInvoiceExport";
import FlattenProgressModal from "./FlattenProgressModal"; import FlattenProgressModal from "./FlattenProgressModal";
@@ -37,13 +37,18 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
{busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null} {busy && !modalOpen ? <InlineLoading description={progress?.phase === "saving" ? "Waiting for the save dialog…" : "Preparing the PDF…"} /> : null}
{error && !flattenFailed ? ( {error && !flattenFailed ? (
<InlineNotification kind="error" lowContrast title="The PDF was not exported" subtitle={error.message} onCloseButtonClick={exp.dismiss}> // Carbon throws if a notification has buttons inside it, and an uncaught throw blanks the whole window.
<div className="voiced-notice-actions"> // A single action goes through ActionableNotification's own button instead.
<Button kind="ghost" size="sm" onClick={exp.retry}> <ActionableNotification
Retry inline
</Button> kind="error"
</div> lowContrast
</InlineNotification> title="The PDF was not exported"
subtitle={error.message}
actionButtonLabel="Retry"
onActionButtonClick={exp.retry}
onCloseButtonClick={exp.dismiss}
/>
) : null} ) : null}
{result ? ( {result ? (
@@ -54,16 +59,16 @@ export default function ExportFeedback({ exp }: { exp: InvoiceExport }) {
title={`Saved to ${result.path}`} title={`Saved to ${result.path}`}
subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined} subtitle={result.identicalToIssued === true ? "The layout is identical to the issued invoice." : undefined}
onCloseButtonClick={exp.dismiss} onCloseButtonClick={exp.dismiss}
> />
<div className="voiced-notice-actions"> {/* Beside the notification, not inside it: Carbon throws on interactive children. */}
<Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}> <div className="voiced-notice-actions">
Open file <Button kind="ghost" size="sm" onClick={() => void run(() => api.openFile(result.path))}>
</Button> Open file
<Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}> </Button>
Show in folder <Button kind="ghost" size="sm" onClick={() => void run(() => api.revealInFolder(result.path))}>
</Button> Show in folder
</div> </Button>
</InlineNotification> </div>
{result.identicalToIssued === false ? ( {result.identicalToIssued === false ? (
<InlineNotification <InlineNotification
kind="warning" kind="warning"
+63
View File
@@ -0,0 +1,63 @@
import { readdirSync, readFileSync, statSync } from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
/**
* Carbon's notifications throw "component should have no interactive child nodes" when a button or link sits
* inside them, and a throw in an effect unmounts the whole app: the window goes blank (this made every successful
* export look like a freeze). There is no DOM test setup here, so this reads the sources instead. Put the
* buttons beside the notification, or use ActionableNotification's own action.
*/
const SRC = path.resolve(process.cwd(), "src");
const NOTIFICATIONS = ["InlineNotification", "ActionableNotification", "ToastNotification", "Callout"];
const INTERACTIVE = /<(Button|IconButton|button|a|Link|TextInput|Select|Checkbox|Toggle|OverflowMenu|ComboBox|Dropdown)\b/;
function sourceFiles(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const full = path.join(dir, name);
if (statSync(full).isDirectory()) return name === "pdf" ? [] : sourceFiles(full);
return /\.tsx$/.test(name) && !/\.test\.tsx$/.test(name) ? [full] : [];
});
}
/** Bodies of the notifications that have children, with the line they start on. */
function notificationBodies(source: string): Array<{ tag: string; line: number; body: string }> {
const out: Array<{ tag: string; line: number; body: string }> = [];
for (const tag of NOTIFICATIONS) {
const open = new RegExp(`<${tag}\\b`, "g");
for (let m = open.exec(source); m; m = open.exec(source)) {
let i = m.index + m[0].length;
let braces = 0;
for (; i < source.length; i++) {
const c = source[i];
if (c === "{") braces++;
else if (c === "}") braces--;
else if (c === ">" && braces === 0) break;
}
if (source[i - 1] === "/") continue; // self-closing: no children
const end = source.indexOf(`</${tag}>`, i);
out.push({ tag, line: source.slice(0, m.index).split("\n").length, body: source.slice(i + 1, end < 0 ? undefined : end) });
}
}
return out;
}
describe("Carbon notifications", () => {
it("never wrap an interactive element", () => {
const offenders: string[] = [];
for (const file of sourceFiles(SRC)) {
for (const { tag, line, body } of notificationBodies(readFileSync(file, "utf8"))) {
if (INTERACTIVE.test(body)) offenders.push(`${path.relative(SRC, file)}:${line} <${tag}>`);
}
}
expect(offenders).toEqual([]);
});
it("the scan finds an offender when there is one", () => {
const bad = `<InlineNotification kind="success" title="x">\n <Button>Open</Button>\n</InlineNotification>`;
const fine = `<InlineNotification kind="success" title="x" />\n<Button>Open</Button>`;
expect(notificationBodies(bad).some((n) => INTERACTIVE.test(n.body))).toBe(true);
expect(notificationBodies(fine)).toEqual([]);
});
});
+13
View File
@@ -125,6 +125,8 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` }; return { ok: r?.path === searchablePath, detail: `wrote ${r?.path}` };
}); });
await log.run("e2e: export notices render without crashing the window", async () => (await import("./selfTestUi")).checkExportFeedbackRenders());
await log.run("e2e: export flattened original", async () => { await log.run("e2e: export flattened original", async () => {
const r = await exportWith("flattened", "original", flattenedPath); const r = await exportWith("flattened", "original", flattenedPath);
return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` }; return { ok: r?.path === flattenedPath, detail: `wrote ${r?.path}` };
@@ -158,6 +160,17 @@ export async function runE2eSteps(log: StepLog, reportPath: string): Promise<voi
}; };
}); });
await log.run("e2e: a new invoice can be issued and exported after the first", async () => {
const first = inv.number;
const outcome = await issueAndArchive({ ...input, notes: "Self-test invoice 2" }, prefs, deps);
const next = `${outDir}/e2e-second.pdf`;
const r = await exportInvoice({ invoice: outcome.invoice, mode: "searchable", source: "original" }, { ...deps, save: async () => next });
return {
ok: outcome.archived && outcome.invoice.number !== first && r?.path === next,
detail: `${first}, then ${outcome.invoice.number} (archived=${outcome.archived}), exported ${r?.path}`,
};
});
await log.run("e2e: archiving different bytes is refused", async () => { await log.run("e2e: archiving different bytes is refused", async () => {
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n"); const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
const before = await api.readArchive(inv.id); const before = await api.readArchive(inv.id);
+59
View File
@@ -0,0 +1,59 @@
import { createRoot } from "react-dom/client";
import { Theme } from "@carbon/react";
import ExportFeedback from "../components/ExportFeedback";
import type { InvoiceExport } from "../hooks/useInvoiceExport";
import type { Invoice } from "./types";
/**
* Part of the real-webview self-test: mounts the export notices the way the app does and fails if React reports an
* uncaught error. Carbon throws when a notification has a button inside it, and that took the whole window
* down after every successful export; nothing that runs without a real DOM can see it.
*/
const noop = () => {};
const idle: InvoiceExport = {
busy: false,
progress: null,
request: null,
error: null,
result: null,
start: async () => null,
cancel: noop,
retry: noop,
exportSearchableInstead: noop,
dismiss: noop,
};
const CASES: Array<{ name: string; expect: string; exp: InvoiceExport }> = [
{
name: "saved",
expect: "Saved to",
exp: { ...idle, result: { path: "/tmp/voiced-selftest.pdf", mode: "searchable", invoice: {} as Invoice, identicalToIssued: false, archiveError: "not archived" } },
},
{ name: "failed", expect: "was not exported", exp: { ...idle, error: { message: "disk full", flatten: false } } },
];
export async function checkExportFeedbackRenders(): Promise<{ ok: boolean; detail: string }> {
const problems: string[] = [];
for (const c of CASES) {
const host = document.createElement("div");
document.body.appendChild(host);
const errors: string[] = [];
const root = createRoot(host, {
onUncaughtError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
onRecoverableError: (e) => errors.push(e instanceof Error ? e.message : String(e)),
});
root.render(
<Theme theme="g10">
<ExportFeedback exp={c.exp} />
</Theme>,
);
// Effects (where Carbon checks its children) run after the first paint.
await new Promise((r) => setTimeout(r, 400));
if (errors.length > 0) problems.push(`${c.name}: ${errors.join("; ").slice(0, 300)}`);
else if (!(host.textContent ?? "").includes(c.expect)) problems.push(`${c.name}: the notice did not render`);
root.unmount();
host.remove();
}
return { ok: problems.length === 0, detail: problems.join(" | ") || "the saved and failed notices render without throwing" };
}
+4 -1
View File
@@ -1,12 +1,15 @@
import React from "react"; import React from "react";
import { createRoot } from "react-dom/client"; import { createRoot } from "react-dom/client";
import App from "./App"; import App from "./App";
import ErrorBoundary from "./components/ErrorBoundary";
import "./styles/carbon.scss"; import "./styles/carbon.scss";
import { maybeRunSelfTest } from "./selftestBoot"; import { maybeRunSelfTest } from "./selftestBoot";
createRoot(document.getElementById("root") as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );
+4 -1
View File
@@ -44,6 +44,7 @@ import {
syncTag, syncTag,
} from "../lib/erpnextUi"; } from "../lib/erpnextUi";
import { useReturnFocus } from "../hooks/useReturnFocus"; import { useReturnFocus } from "../hooks/useReturnFocus";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import PdfPreview from "../components/PdfPreview"; import PdfPreview from "../components/PdfPreview";
import RecordPaymentModal from "../components/RecordPaymentModal"; import RecordPaymentModal from "../components/RecordPaymentModal";
@@ -385,7 +386,9 @@ export default function InvoiceDetail({ invoiceId, settings, onBack, onDuplicate
</OverflowMenu> </OverflowMenu>
</div> </div>
<ExportFeedback exp={exp} /> <ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
<div className="voiced-detail__grid"> <div className="voiced-detail__grid">
<div className="voiced-detail__main voiced-stack"> <div className="voiced-detail__main voiced-stack">
+4 -1
View File
@@ -49,6 +49,7 @@ import { openInErpnext } from "../lib/erpnextActions";
import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext"; import type { ErpnextConfig, ErpnextSyncStatus } from "../lib/erpnext";
import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi"; import { canOpenInErpnext, indexSyncStatuses, isConfigured, pushDisabledReason, sendableSelection, syncTag } from "../lib/erpnextUi";
import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal"; import SendToErpnextModal, { type SendTarget } from "../components/SendToErpnextModal";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { useToast } from "../components/ToastProvider"; import { useToast } from "../components/ToastProvider";
import type { InvoiceSummary, Settings } from "../lib/types"; import type { InvoiceSummary, Settings } from "../lib/types";
@@ -226,7 +227,9 @@ export default function InvoiceHistory({ settings, active, onOpen, onOpenSetting
<h1 className="voiced-page__title">Invoices</h1> <h1 className="voiced-page__title">Invoices</h1>
<p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p> <p className="voiced-page__subtitle">Every invoice you have issued. Open one to see its PDF, record payments or export it.</p>
<ExportFeedback exp={exp} /> <ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
{loading ? ( {loading ? (
<InlineLoading description="Loading invoices" /> <InlineLoading description="Loading invoices" />
+4 -1
View File
@@ -61,6 +61,7 @@ import { useInvoicePdf } from "../hooks/useInvoicePdf";
import { useInvoiceExport } from "../hooks/useInvoiceExport"; import { useInvoiceExport } from "../hooks/useInvoiceExport";
import { autoPushIssued } from "../lib/erpnextUi"; import { autoPushIssued } from "../lib/erpnextUi";
import ExportButton, { ExportModePicker } from "../components/ExportButton"; import ExportButton, { ExportModePicker } from "../components/ExportButton";
import ErrorBoundary from "../components/ErrorBoundary";
import ExportFeedback from "../components/ExportFeedback"; import ExportFeedback from "../components/ExportFeedback";
import { createExportDeps } from "../lib/exportDeps"; import { createExportDeps } from "../lib/exportDeps";
import { issueAndArchive, renderAndArchive } from "../lib/exportFlow"; import { issueAndArchive, renderAndArchive } from "../lib/exportFlow";
@@ -784,7 +785,9 @@ export default function NewInvoice({ settings, onSettingsChange, active, onActiv
onActionButtonClick={() => void retryArchive()} onActionButtonClick={() => void retryArchive()}
/> />
) : null} ) : null}
<ExportFeedback exp={exp} /> <ErrorBoundary fallback={(e) => <p className="voiced-muted">The export result could not be shown ({e.message}). The PDF was still saved.</p>}>
<ExportFeedback exp={exp} />
</ErrorBoundary>
<div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}> <div className="voiced-header-action" style={{ flexWrap: "wrap", marginTop: "0.5rem" }}>
<ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} /> <ExportButton onExport={(mode) => void exp.start(exportable, mode, "original")} disabled={busy} />
<Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}> <Button kind="tertiary" renderIcon={Copy} onClick={duplicateAsNew} disabled={busy}>