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:
parent
b127b582bc
commit
7ccedbf973
5 changed files with 94 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: () =>
|
||||
|
|
|
|||
|
|
@ -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>>();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue