fix(editor): a browser refresh reloads the editor instead of bouncing away

Refreshing ANY editor URL navigated to the management app's project overview
instead of reloading. Not related to spaces in paths — verified path-independent
by reproducing it on a fileless `-/pcbnew` route with no file path at all.

Mechanism, from probing the actual ordering rather than reading the code: the wx
port registers its unload handler via emscripten_set_beforeunload_callback,
which lands in the CAPTURE phase and so runs before any bubble-phase listener
regardless of registration order. It closes the top frame -> fires
wxAppTopWindowClosed -> the quit dispatcher -> location.assign(exitUrl), and
that navigation overrode the in-flight reload (the reload itself failed with
NS_ERROR_FAILURE).

The quit hook already latched off for our OWN navigations
(markDeliberateNavigation) and on pagehide — which the existing comment notes
fires too late, at commit time. Nothing covered a browser-INITIATED unload:
reload, Back, closing the tab. This adds that latch, in the capture phase so it
precedes emscripten's handler; a bubble-phase listener registered at module
scope was NOT enough, which the probe showed directly.

Tradeoff, documented at the call site: if a beforeunload prompt appears and the
user stays, the latch remains set and a later File->Quit won't self-navigate.
That is strictly better than a page that cannot be refreshed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0137pGo8W7asomGUTRMB7RzM
This commit is contained in:
Gergő Törcsvári 2026-07-31 13:19:10 +02:00
commit 7ccedbf973
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
5 changed files with 94 additions and 1 deletions

View file

@ -467,6 +467,29 @@ function ensureQuitDispatcher(win: ToolWindow): boolean {
if (typeof window !== "undefined") {
ensureQuitDispatcher(window as ToolWindow);
// Latch off for a BROWSER-initiated unload: reload (F5), Back, closing the
// tab, typing a URL. markDeliberateNavigation covers only our own in-app
// navigations, and the pagehide latch below fires at commit time — too late.
// The wx port's UnloadCallback runs on beforeunload and closes the top frame,
// which fires wxAppTopWindowClosed; unlatched, the quit hook then navigated to
// the project overview OVER the in-flight reload, so every refresh of an
// editor URL bounced to the management app instead of reloading.
//
// Registered at MODULE scope, which runs on import — before the wasm boots and
// installs its own beforeunload handler. Listeners fire in registration order,
// so this latch is always set before UnloadCallback can close the frame.
//
// Tradeoff: if a beforeunload prompt is shown and the user chooses to stay,
// the latch stays set and a later File→Quit won't navigate on its own. That
// is strictly better than the alternative — a page that cannot be refreshed —
// and the user can still navigate manually.
window.addEventListener(
"beforeunload",
() => {
quitHandled = true;
},
{ capture: true },
);
}
function installQuitHook(
@ -924,6 +947,9 @@ export function WasmTool({
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
// (miss 08B); eeschema routes per sheet through the manager instead.
const collabDocRef = React.useRef<import("yjs").Doc | null>(null);
// Its owning handle, so unmount tears the room socket + doc down — eeschema's
// equivalent lives inside sheetManagerRef.
const collabHandleRef = React.useRef<KicadCollabHandle | null>(null);
const [status, setStatus] = React.useState("Loading tool…");
const [logs, setLogs] = React.useState<string[]>([]);
const [showLog, setShowLog] = React.useState(false);
@ -1370,6 +1396,10 @@ export function WasmTool({
// cleanup below closes over it — and never rejected, so aborting mid-sync
// leaks nothing and throws nothing.
const presyncAbort = new AbortController();
// The libs source THIS boot created (vs. one injected via props, which the
// caller owns) — cleanup disposes it so its SyncStack sockets don't outlive
// the editor.
let ownedLibsSource: LibsSource | null = null;
void (async () => {
try {
@ -1388,6 +1418,7 @@ export function WasmTool({
// must be the same object for the warm-up to benefit the editor).
const source =
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
if (libsSource === undefined) ownedLibsSource = source;
// Download-consent gate (standalone-load-ux 0001): before pulling the
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
@ -1705,6 +1736,7 @@ export function WasmTool({
log: append,
onStatus: setStatus,
});
collabHandleRef.current = collabHandle ?? null;
collabDocRef.current = collabHandle?.doc ?? null;
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
startComments(collabHandle?.doc);
@ -1719,6 +1751,7 @@ export function WasmTool({
scopeId,
projectId,
files,
targetPath,
provider: yjsProviderConfig(),
log: append,
});
@ -1804,7 +1837,15 @@ export function WasmTool({
// onActiveChange(null).
sheetManagerRef.current?.destroy();
sheetManagerRef.current = null;
// The single-room (pcbnew/pl_editor) counterpart: binding + provider +
// doc. Without this the board room's socket survived navigation.
collabHandleRef.current?.destroy();
collabHandleRef.current = null;
collabDocRef.current = null;
// Close the lib SyncStacks this boot opened (mirror mux + any dedicated
// sockets); IDB caches stay. Injected sources belong to the caller.
ownedLibsSource?.dispose?.();
ownedLibsSource = null;
oom.stop();
};
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so

View file

@ -100,6 +100,9 @@ async function partyKitProvider(
party: PARTYKIT_PARTY,
connect: true,
params,
// Default maxBackoffTime is 2.5s — a room that's down would re-dial (and
// re-run the server's authorize round trip) every 2.5s forever, per room.
maxBackoffTime: 30_000,
});
return {
whenSynced: () =>

View file

@ -35,13 +35,25 @@ export async function startSiblingRestage(opts: {
scopeId: string;
projectId: string;
files: { path: string }[];
/** The opened `.kicad_pcb` — scopes the watch to ITS KiCad project. */
targetPath?: string;
provider: ProviderConfig;
log: (m: string) => void;
}): Promise<SiblingRestageHandle> {
const { win, slug, log } = opts;
// Only the opened board's own KiCad project can be synced from: pcbnew's
// "update from schematic" reads the sheets next to the .kicad_pcb (same
// directory tree). A backend project holding SEVERAL KiCad projects (a
// repo of boards) must not fan out one room per schematic repo-wide —
// that held ~27 idle board-room sockets for an 8-board repo. Sheets a
// project references OUTSIDE its directory (rare ../ sheet paths) fall
// back to the boot snapshot — same gap v1 already accepts for new sheets.
const dir = opts.targetPath
? opts.targetPath.slice(0, opts.targetPath.lastIndexOf("/") + 1)
: "";
const sheetPaths = opts.files
.map((f) => f.path)
.filter((p) => p.endsWith(".kicad_sch"));
.filter((p) => p.endsWith(".kicad_sch") && p.startsWith(dir));
const sessions: KicadDocSession[] = [];
const timers = new Map<string, ReturnType<typeof setTimeout>>();

View file

@ -137,6 +137,13 @@ export interface LibsSource {
* the C++ side falls back to the per-lib lazy load.
*/
getFpIndex?(): Promise<string | null>;
/**
* Release every live resource this source holds realtime sockets, open
* SyncStacks keeping the persistent caches (IDB) intact for the next
* session. Called on editor unmount for sources the editor itself created.
* Optional: stateless sources omit it.
*/
dispose?(): void;
}
/**

View file

@ -49,6 +49,13 @@ export function syncedLibsSource(
* Returning undefined falls back to resolving this lib on its own.
*/
stackFor?: (libId: string) => SyncStackDescriptor | null | undefined;
/**
* SyncStack realtime policy (see SyncStackOptions.realtime). The scope
* source passes "shared-only" a board session warming 150+ libs must not
* hold a dedicated WebSocket per org/mirror-direct lib; the lib-editor
* route keeps the default "all" so its ONE lib stays realtime.
*/
realtime?: "all" | "shared-only";
/** Test seams (default: global fetch / IDB stores / real WebSockets). */
fetchImpl?: typeof fetch;
storeFactory?: (namespace: string) => LayerStore;
@ -204,6 +211,15 @@ export function syncedLibsSource(
return false;
}
},
dispose(): void {
for (const t of reloadTimers.values()) clearTimeout(t);
reloadTimers.clear();
// Close the stack once its open settles (sockets + channel refcounts);
// the IDB cache stays for the next session. A failed open has nothing
// to close.
opened?.then((r) => r.stack.close()).catch(() => {});
opened = null;
},
};
}
@ -215,6 +231,7 @@ async function resolveAndOpen(
user?: string;
project?: string;
stackFor?: (libId: string) => SyncStackDescriptor | null | undefined;
realtime?: "all" | "shared-only";
fetchImpl?: typeof fetch;
storeFactory?: (namespace: string) => LayerStore;
channelFactory?: ChannelFactory;
@ -258,6 +275,7 @@ async function resolveAndOpen(
fetchImpl: credentialedFetch,
storeFactory: opts.storeFactory,
channelFactory: opts.channelFactory,
realtime: opts.realtime,
});
await stack.open();
return {
@ -312,6 +330,14 @@ export function syncedScopeLibsSource(
if (!src) {
src = syncedLibsSource(libId, {
...opts,
// Bulk context: only mux-keyed layers (the one shared mirror room
// socket) get realtime. Without this, every org/mirror-direct lib in
// the scope dials a dedicated WebSocket — a board load held 60+ idle
// sockets, each pinning a DO and costing an authorize per reconnect.
// Trade-off: peer edits to those libs reach this session on the next
// load (or lazy sync) instead of live; origin libs keep live updates
// via the muxed team mirror channel.
realtime: "shared-only",
stackFor: (id) => (batchedStacks.has(id) ? batchedStacks.get(id) : undefined),
});
perLib.set(libId, src);
@ -429,5 +455,9 @@ export function syncedScopeLibsSource(
Array.from({ length: Math.min(concurrency, total) }, worker),
);
},
dispose(): void {
for (const src of perLib.values()) src.dispose?.();
perLib.clear();
},
};
}