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") {
|
if (typeof window !== "undefined") {
|
||||||
ensureQuitDispatcher(window as ToolWindow);
|
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(
|
function installQuitHook(
|
||||||
|
|
@ -924,6 +947,9 @@ export function WasmTool({
|
||||||
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
// The single-room collab doc (pcbnew/pl_editor), for the layout save-sync
|
||||||
// (miss 08B); eeschema routes per sheet through the manager instead.
|
// (miss 08B); eeschema routes per sheet through the manager instead.
|
||||||
const collabDocRef = React.useRef<import("yjs").Doc | null>(null);
|
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 [status, setStatus] = React.useState("Loading tool…");
|
||||||
const [logs, setLogs] = React.useState<string[]>([]);
|
const [logs, setLogs] = React.useState<string[]>([]);
|
||||||
const [showLog, setShowLog] = React.useState(false);
|
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
|
// cleanup below closes over it — and never rejected, so aborting mid-sync
|
||||||
// leaks nothing and throws nothing.
|
// leaks nothing and throws nothing.
|
||||||
const presyncAbort = new AbortController();
|
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 () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -1388,6 +1418,7 @@ export function WasmTool({
|
||||||
// must be the same object for the warm-up to benefit the editor).
|
// must be the same object for the warm-up to benefit the editor).
|
||||||
const source =
|
const source =
|
||||||
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
|
libsSource !== undefined ? libsSource : libsSourceConfig(projectId);
|
||||||
|
if (libsSource === undefined) ownedLibsSource = source;
|
||||||
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
// Download-consent gate (standalone-load-ux 0001): before pulling the
|
||||||
// (large) cold wasm + lib bundles, say how many MB and wait for the OK.
|
// (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
|
// Runs only on versioned CDN deploys (`meta.ver` — flat dev roots and
|
||||||
|
|
@ -1705,6 +1736,7 @@ export function WasmTool({
|
||||||
log: append,
|
log: append,
|
||||||
onStatus: setStatus,
|
onStatus: setStatus,
|
||||||
});
|
});
|
||||||
|
collabHandleRef.current = collabHandle ?? null;
|
||||||
collabDocRef.current = collabHandle?.doc ?? null;
|
collabDocRef.current = collabHandle?.doc ?? null;
|
||||||
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
startPresence(collabHandle?.provider, undefined, collabHandle?.doc);
|
||||||
startComments(collabHandle?.doc);
|
startComments(collabHandle?.doc);
|
||||||
|
|
@ -1719,6 +1751,7 @@ export function WasmTool({
|
||||||
scopeId,
|
scopeId,
|
||||||
projectId,
|
projectId,
|
||||||
files,
|
files,
|
||||||
|
targetPath,
|
||||||
provider: yjsProviderConfig(),
|
provider: yjsProviderConfig(),
|
||||||
log: append,
|
log: append,
|
||||||
});
|
});
|
||||||
|
|
@ -1804,7 +1837,15 @@ export function WasmTool({
|
||||||
// onActiveChange(null).
|
// onActiveChange(null).
|
||||||
sheetManagerRef.current?.destroy();
|
sheetManagerRef.current?.destroy();
|
||||||
sheetManagerRef.current = null;
|
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;
|
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();
|
oom.stop();
|
||||||
};
|
};
|
||||||
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
// Boot is one-shot per mount; deps intentionally exclude files/targetPath so
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,9 @@ async function partyKitProvider(
|
||||||
party: PARTYKIT_PARTY,
|
party: PARTYKIT_PARTY,
|
||||||
connect: true,
|
connect: true,
|
||||||
params,
|
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 {
|
return {
|
||||||
whenSynced: () =>
|
whenSynced: () =>
|
||||||
|
|
|
||||||
|
|
@ -35,13 +35,25 @@ export async function startSiblingRestage(opts: {
|
||||||
scopeId: string;
|
scopeId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
files: { path: string }[];
|
files: { path: string }[];
|
||||||
|
/** The opened `.kicad_pcb` — scopes the watch to ITS KiCad project. */
|
||||||
|
targetPath?: string;
|
||||||
provider: ProviderConfig;
|
provider: ProviderConfig;
|
||||||
log: (m: string) => void;
|
log: (m: string) => void;
|
||||||
}): Promise<SiblingRestageHandle> {
|
}): Promise<SiblingRestageHandle> {
|
||||||
const { win, slug, log } = opts;
|
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
|
const sheetPaths = opts.files
|
||||||
.map((f) => f.path)
|
.map((f) => f.path)
|
||||||
.filter((p) => p.endsWith(".kicad_sch"));
|
.filter((p) => p.endsWith(".kicad_sch") && p.startsWith(dir));
|
||||||
|
|
||||||
const sessions: KicadDocSession[] = [];
|
const sessions: KicadDocSession[] = [];
|
||||||
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
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.
|
* the C++ side falls back to the per-lib lazy load.
|
||||||
*/
|
*/
|
||||||
getFpIndex?(): Promise<string | null>;
|
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.
|
* Returning undefined falls back to resolving this lib on its own.
|
||||||
*/
|
*/
|
||||||
stackFor?: (libId: string) => SyncStackDescriptor | null | undefined;
|
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). */
|
/** Test seams (default: global fetch / IDB stores / real WebSockets). */
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
storeFactory?: (namespace: string) => LayerStore;
|
storeFactory?: (namespace: string) => LayerStore;
|
||||||
|
|
@ -204,6 +211,15 @@ export function syncedLibsSource(
|
||||||
return false;
|
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;
|
user?: string;
|
||||||
project?: string;
|
project?: string;
|
||||||
stackFor?: (libId: string) => SyncStackDescriptor | null | undefined;
|
stackFor?: (libId: string) => SyncStackDescriptor | null | undefined;
|
||||||
|
realtime?: "all" | "shared-only";
|
||||||
fetchImpl?: typeof fetch;
|
fetchImpl?: typeof fetch;
|
||||||
storeFactory?: (namespace: string) => LayerStore;
|
storeFactory?: (namespace: string) => LayerStore;
|
||||||
channelFactory?: ChannelFactory;
|
channelFactory?: ChannelFactory;
|
||||||
|
|
@ -258,6 +275,7 @@ async function resolveAndOpen(
|
||||||
fetchImpl: credentialedFetch,
|
fetchImpl: credentialedFetch,
|
||||||
storeFactory: opts.storeFactory,
|
storeFactory: opts.storeFactory,
|
||||||
channelFactory: opts.channelFactory,
|
channelFactory: opts.channelFactory,
|
||||||
|
realtime: opts.realtime,
|
||||||
});
|
});
|
||||||
await stack.open();
|
await stack.open();
|
||||||
return {
|
return {
|
||||||
|
|
@ -312,6 +330,14 @@ export function syncedScopeLibsSource(
|
||||||
if (!src) {
|
if (!src) {
|
||||||
src = syncedLibsSource(libId, {
|
src = syncedLibsSource(libId, {
|
||||||
...opts,
|
...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),
|
stackFor: (id) => (batchedStacks.has(id) ? batchedStacks.get(id) : undefined),
|
||||||
});
|
});
|
||||||
perLib.set(libId, src);
|
perLib.set(libId, src);
|
||||||
|
|
@ -429,5 +455,9 @@ export function syncedScopeLibsSource(
|
||||||
Array.from({ length: Math.min(concurrency, total) }, worker),
|
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