feat(editor): File→Quit navigates back to the project page
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 <noreply@anthropic.com>
This commit is contained in:
parent
d43f8ca719
commit
3a2255c817
4 changed files with 196 additions and 3 deletions
85
tests/web/quit-to-project.spec.ts
Normal file
85
tests/web/quit-to-project.spec.ts
Normal file
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
2
web/standalone/src/wasm/global.d.ts
vendored
2
web/standalone/src/wasm/global.d.ts
vendored
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit d9c3feecddad2ac33fc27a217f1a32885d0c0823
|
||||
Subproject commit d4a45100124cbfef846b999f7e1768892b1a9463
|
||||
Loading…
Reference in a new issue