From 3a2255c817c62e7fbf87655305f90dfff85fe55d Mon Sep 17 00:00:00 2001 From: Istvan Matejcsok <119620946+matejcsok-ee@users.noreply.github.com> Date: Wed, 8 Jul 2026 18:19:24 +0200 Subject: [PATCH] =?UTF-8?q?feat(editor):=20File=E2=86=92Quit=20navigates?= =?UTF-8?q?=20back=20to=20the=20project=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every KiCad editor's File→Quit used to destroy the wx top frame and notify nobody, leaving the SPA stranded on the editor URL with a dead canvas. It now behaves like the browser Back button: return to wherever the user entered the editor from (project page, or a previous editor). - WasmTool installs window.wxAppTopWindowClosed (the wx wasm port fires it from the main frame's destructor — see the wxwidgets pointer bump). On a real quit it history.back()s when there is same-origin in-app history, else location.assign()s the project page (or "/" for lib editors). - The navigation is deferred one task (setTimeout 0): it fires inside the C++ destructor via EM_ASM under Asyncify, and a cross-document location.assign() started there is aborted by the continuing teardown; only same-document history.back() survives synchronously. - Latches off on pagehide (the port also closes the frame on page unload via UnloadCallback) and reloads on a bfcache pageshow so Forward never restores a dead frame. - e2e: quit-to-project.spec.ts covers both the history-back and the deep-link fallback paths. No kicad changes — the whole hook lives in the wxWidgets wasm port and the web app, keeping the kicad fork clean for upstream syncs. Co-Authored-By: Claude Opus 4.8 --- tests/web/quit-to-project.spec.ts | 85 ++++++++++++++++ web/standalone/src/components/WasmTool.tsx | 110 ++++++++++++++++++++- web/standalone/src/wasm/global.d.ts | 2 + wxwidgets | 2 +- 4 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 tests/web/quit-to-project.spec.ts diff --git a/tests/web/quit-to-project.spec.ts b/tests/web/quit-to-project.spec.ts new file mode 100644 index 0000000..4c8508c --- /dev/null +++ b/tests/web/quit-to-project.spec.ts @@ -0,0 +1,85 @@ +import { test, expect, type Page } from '@playwright/test'; +import { clickMenuBarItem, clickMenuItemByText } from '../e2e/utils/element-tracker'; + +/** + * File → Quit e2e: quitting an editor must leave it, like the browser Back + * button — return to wherever the user navigated in from (the project page), + * falling back to the project page on a deep link with no same-origin history. + * + * The wx wasm port notifies the page when the app's top window is destroyed + * (window.wxAppTopWindowClosed, wxwidgets src/wasm/toplevel.cpp); WasmTool maps + * that to history.back() / location.assign(projectPath). A quit vetoed by the + * unsaved-changes prompt never destroys the frame, so it never navigates. + * + * URL-only assertions — no screenshots. + */ + +/** Scope segment for the demo project (the reference backend serves it for any). */ +const SCOPE = 'default'; + +const EDITOR_URL_RE = /\/default\/projects\/demo\/demo\.kicad_sch/; +// Project overview: path ends at /projects/demo (optionally a query string). +const PROJECT_URL_RE = /\/default\/projects\/demo\/?(\?.*)?$/; + +async function waitForToolReady(page: Page, titleRe: RegExp): Promise { + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => page.title(), { + message: `editor never reached title ${titleRe}`, + timeout: 120000, + intervals: [1000], + }) + .toMatch(titleRe); + // The menu helpers drive the rendered-element registry. + await page.waitForFunction( + () => + !!(window as unknown as { wxElementRegistry?: { findAllRendered?: unknown } }) + .wxElementRegistry, + null, + { timeout: 30000 } + ); + // The boot and eager-library overlays (WasmTool, `absolute inset-0 z-30`) + // cover the whole editor including the menubar — synthetic menu clicks land + // on them until they clear (eeschema hydrates the full symbol set post-boot). + await expect(page.locator("div.absolute.inset-0.z-30")).toHaveCount(0, { + timeout: 180000, + }); +} + +async function quitViaFileMenu(page: Page): Promise { + expect(await clickMenuBarItem(page, 'File'), 'File menubar item clickable').toBe(true); + await clickMenuItemByText(page, 'Quit'); +} + +test.describe('web app — File → Quit leaves the editor', () => { + test('quit after entering from the project page navigates back to it', async ({ page }) => { + test.setTimeout(300000); // full wasm boot + + // Enter the editor the way a user does: the project page's "Open in …" + // link is a hard navigation, creating the history entry Quit pops. + await page.goto(`/${SCOPE}/projects/demo`); + await page + .getByRole('link', { name: /Open in Schematic Editor/i }) + .first() + .click(); + await page.waitForURL(EDITOR_URL_RE, { timeout: 30000 }); + await waitForToolReady(page, /demo — Schematic Editor/i); + + await quitViaFileMenu(page); + + await page.waitForURL(PROJECT_URL_RE, { timeout: 30000 }); + }); + + test('quit on a deep-linked editor falls back to the project page', async ({ page }) => { + test.setTimeout(300000); + + // Direct entry: no same-origin referrer, nothing meaningful to go back to — + // quit must land on the project page via the fallback URL. + await page.goto(`/${SCOPE}/projects/demo/demo.kicad_sch`); + await waitForToolReady(page, /demo — Schematic Editor/i); + + await quitViaFileMenu(page); + + await page.waitForURL(PROJECT_URL_RE, { timeout: 30000 }); + }); +}); diff --git a/web/standalone/src/components/WasmTool.tsx b/web/standalone/src/components/WasmTool.tsx index d7f41c6..557ce9f 100644 --- a/web/standalone/src/components/WasmTool.tsx +++ b/web/standalone/src/components/WasmTool.tsx @@ -246,6 +246,99 @@ function installToolNavigationHook( }; } +// The wx wasm port calls window.wxAppTopWindowClosed() when the app's MAIN +// frame is destroyed (wxwidgets src/wasm/toplevel.cpp) — i.e. on a real +// File→Quit / window close. A close vetoed by the unsaved-changes prompt never +// destroys the frame, so it never fires. The port also closes the frame while +// the page itself unloads (app.cpp UnloadCallback), so the dispatcher latches +// off as soon as any unload/navigation is under way. + +let activeQuitHook: (() => void) | undefined; +let quitHandled = false; + +const quitDispatcher = () => { + if (quitHandled) return; + quitHandled = true; + activeQuitHook?.(); +}; + +function ensureQuitDispatcher(win: ToolWindow): boolean { + if (win.wxAppTopWindowClosed === quitDispatcher) return true; + + try { + Object.defineProperty(win, "wxAppTopWindowClosed", { + configurable: true, + value: quitDispatcher, + }); + return true; + } catch { + return false; + } +} + +if (typeof window !== "undefined") { + ensureQuitDispatcher(window as ToolWindow); +} + +function installQuitHook( + win: ToolWindow, + opts: { fallbackUrl: string; log: (m: string) => void }, +): () => void { + const hook = () => { + // A same-origin referrer means we entered by an in-app hard navigation + // (ProjectView / NewFileDialog / tool-switch all location.assign), so + // going back lands wherever the user came from. Deep links and fresh tabs + // have no usable history — go to the fallback instead. + let sameOriginReferrer = false; + try { + sameOriginReferrer = + !!win.document.referrer && + new URL(win.document.referrer).origin === win.location.origin; + } catch { + sameOriginReferrer = false; + } + + // Defer the navigation out of the wasm callback: this fires from inside the + // frame's C++ destructor (via EM_ASM under Asyncify), and the teardown keeps + // running after we return. A cross-document location.assign() started here is + // aborted by that continuing teardown (only the same-document history.back() + // survives) — so hand it to a fresh task once the wasm stack has unwound. + setTimeout(() => { + if (sameOriginReferrer && win.history.length > 1) { + opts.log("[quit] editor closed — history.back()"); + win.history.back(); + } else { + opts.log(`[quit] editor closed — no in-app history, going to ${opts.fallbackUrl}`); + win.location.assign(opts.fallbackUrl); + } + }, 0); + }; + + if (!ensureQuitDispatcher(win)) { + opts.log("[quit] unable to install quit hook"); + } + activeQuitHook = hook; + + // Once the page is unloading for any reason, the hook must never navigate. + const markUnloading = () => { + quitHandled = true; + }; + win.addEventListener("pagehide", markUnloading); + + // A bfcache restore (Forward after quitting) would resurrect a page whose wx + // frame was already destroyed — force a clean re-boot instead. + const onPageShow = (e: PageTransitionEvent) => { + if (e.persisted) win.location.reload(); + }; + win.addEventListener("pageshow", onPageShow); + + return () => { + if (activeQuitHook === hook) activeQuitHook = undefined; + win.removeEventListener("pagehide", markUnloading); + win.removeEventListener("pageshow", onPageShow); + }; +} + /** * Read the opened file back from MEMFS (what the editor actually loaded) and * parse it into the full `KicadDoc` (ysync 0007 `fileToDoc`). Used to seed the @@ -748,14 +841,27 @@ export function WasmTool({ }, [ready]); React.useEffect(() => { - const removeNavigationHook = installToolNavigationHook(window as ToolWindow, { + const win = window as ToolWindow; + const removeNavigationHook = installToolNavigationHook(win, { slug, files, targetPath, log: append, }); - return () => removeNavigationHook(); + // File→Quit leaves the editor. Lib editors (/:scope/libs/:name) have no + // project overview to fall back to — go home instead. + const segments = win.location.pathname.split("/").filter(Boolean); + const removeQuitHook = installQuitHook(win, { + fallbackUrl: + segments[1] === "libs" ? "/" : projectPath(currentScope(), slug), + log: append, + }); + + return () => { + removeNavigationHook(); + removeQuitHook(); + }; }, [slug, files, targetPath, append]); React.useEffect(() => { diff --git a/web/standalone/src/wasm/global.d.ts b/web/standalone/src/wasm/global.d.ts index 13dbaee..dc72b04 100644 --- a/web/standalone/src/wasm/global.d.ts +++ b/web/standalone/src/wasm/global.d.ts @@ -46,6 +46,8 @@ declare global { FS?: EmscriptenFS; wxElementRegistry?: WxElementRegistry; kicadWebOpenTool?: (toolName: string, fileName: string) => boolean; + /** wx wasm port → page: the app's main frame was destroyed (File→Quit). */ + wxAppTopWindowClosed?: () => void; /** File System Access API (Chromium): writable local-folder sessions. */ showDirectoryPicker?(options?: { mode?: "read" | "readwrite"; diff --git a/wxwidgets b/wxwidgets index d9c3fee..d4a4510 160000 --- a/wxwidgets +++ b/wxwidgets @@ -1 +1 @@ -Subproject commit d9c3feecddad2ac33fc27a217f1a32885d0c0823 +Subproject commit d4a45100124cbfef846b999f7e1768892b1a9463