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
+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}` };
});
await log.run("e2e: export notices render without crashing the window", async () => (await import("./selfTestUi")).checkExportFeedbackRenders());
await log.run("e2e: export flattened original", async () => {
const r = await exportWith("flattened", "original", flattenedPath);
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 () => {
const other = new TextEncoder().encode("%PDF-1.4\n% a different file\n");
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" };
}