diff --git a/browse/js/app.js b/browse/js/app.js index f462891..3f9898e 100644 --- a/browse/js/app.js +++ b/browse/js/app.js @@ -132,8 +132,13 @@ var popQS = new URLSearchParams(location.search); if (popQS.get('hidden') === '1') window.app.state.showHidden = true; else window.app.state.showHidden = false; + // Join the shared nav token: rapid back/forward (or back/forward + // while an in-tool rescope is mid-flight) must not apply a stale + // listing on top of a newer one. + var seq = events.beginNav ? events.beginNav() : 0; try { var es = await loader.fetchServerChildren(path); + if (events.isCurrentNav && !events.isCurrentNav(seq)) return; window.app.state.currentPath = path; window.app.state.selectedId = null; window.app.state.lastPreviewedNodeId = null; diff --git a/browse/js/events.js b/browse/js/events.js index 68335de..423dc0e 100644 --- a/browse/js/events.js +++ b/browse/js/events.js @@ -133,6 +133,16 @@ } catch (_e) { /* private browsing edge cases */ } } + // Navigation sequence token. Every async flow that ends by replacing + // the tree root (refresh, rescope, reload, back/forward popstate) + // captures a token before its fetch and bails if a newer navigation + // has started by the time it resolves — otherwise a slow listing can + // land on top of a newer one and leave the tree out of sync with + // state.currentPath / the URL bar. + var navSeq = 0; + function beginNav() { return ++navSeq; } + function isCurrentNav(seq) { return seq === navSeq; } + async function refreshListing() { // Snapshot expanded paths + selection BEFORE setRoot clears the // tree, then re-apply after the new root is in place. Keeps @@ -141,6 +151,7 @@ // a refresh — including the auto-refresh triggered by the // "Show hidden files" toggle. var snap = tree.snapshotState(); + var seq = beginNav(); if (state.source === 'server') { var raw; try { @@ -149,8 +160,10 @@ statusError('Refresh failed: ' + e.message); return; } + if (!isCurrentNav(seq)) return; tree.setRoot(raw); await tree.restoreState(snap); + if (!isCurrentNav(seq)) return; tree.render(); statusInfo('Refreshed (' + raw.length + ' item' + (raw.length === 1 ? '' : 's') + ')'); @@ -162,8 +175,10 @@ statusError('Refresh failed: ' + e.message); return; } + if (!isCurrentNav(seq)) return; tree.setRoot(raw2); await tree.restoreState(snap); + if (!isCurrentNav(seq)) return; tree.render(); statusInfo('Refreshed'); } @@ -868,14 +883,20 @@ var loader = window.app.modules.loader; if (!loader) return; if (!dirPath.endsWith('/')) dirPath += '/'; + var seq = beginNav(); // Root-scope reload — refresh the visible top-level listing. if (dirPath === state.currentPath) { + var es; try { - var es = state.source === 'server' + es = state.source === 'server' ? await loader.fetchServerChildren(dirPath) : (state.rootHandle ? await loader.fetchFsChildren(state.rootHandle) : []); - tree.setRoot(es); - } catch (_e) { /* swallow */ } + } catch (e) { + statusError('Reload failed: ' + (e.message || e)); + return; + } + if (!isCurrentNav(seq)) return; + tree.setRoot(es); tree.render(); return; } @@ -887,13 +908,18 @@ if (tree.pathFor(n).replace(/\/$/, '') === noSlash) hit = n; }); if (hit) { + var raw; try { - var raw = state.source === 'server' + raw = state.source === 'server' ? await loader.fetchServerChildren(dirPath) : (hit.handle ? await loader.fetchFsChildren(hit.handle) : []); - tree.setChildren(hit.id, raw); - hit.expanded = true; - } catch (_e) { /* swallow */ } + } catch (e) { + statusError('Reload failed: ' + (e.message || e)); + return; + } + if (!isCurrentNav(seq)) return; + tree.setChildren(hit.id, raw); + hit.expanded = true; tree.render(); } } @@ -1370,8 +1396,7 @@ } if (state.source === 'fs') { if (!node.handle || node.handle.kind !== 'directory') return; - state.rootHandle = node.handle; - state.currentPath = node.handle.name + '/'; + var seq = beginNav(); var raw; try { raw = await loader.fetchFsChildren(node.handle); @@ -1379,6 +1404,12 @@ statusError('Failed to enter ' + node.name + ': ' + e.message); return; } + // Mutate scope state only after the fetch succeeds and only if + // we're still the latest navigation — a bail here leaves the + // previous scope intact rather than half-swapped. + if (!isCurrentNav(seq)) return; + state.rootHandle = node.handle; + state.currentPath = node.handle.name + '/'; tree.setRoot(raw); tree.render(); statusInfo('Entered ' + node.name); @@ -1389,6 +1420,7 @@ // history.pushState, fetches the new directory listing, and // re-renders the tree from scratch. Page DOES NOT reload. async function rescopeServer(url, displayName) { + var seq = beginNav(); var entries; try { entries = await loader.fetchServerChildren(url); @@ -1396,6 +1428,10 @@ statusError('Failed to enter ' + displayName + ': ' + (e.message || e)); return; } + // A newer navigation (another dblclick, a refresh, back/forward) + // started while this listing was in flight — drop this result so we + // don't pushState/setRoot on top of it. + if (!isCurrentNav(seq)) return; state.currentPath = url; // Selection / preview belong to the old scope; clear them so // the new root doesn't carry stale highlight state. @@ -1448,6 +1484,11 @@ // selection). Workflow modules call this after a move/accept so the // tree reflects the change without a manual reload. upload.js already // depends on it being present. - refreshListing: refreshListing + refreshListing: refreshListing, + // Shared navigation-sequence token so the popstate handler (app.js) + // can't race the in-tool navigations. beginNav() claims the latest + // token; isCurrentNav(seq) reports whether it's still latest. + beginNav: beginNav, + isCurrentNav: isCurrentNav }; })(); diff --git a/browse/js/tree.js b/browse/js/tree.js index 0fe9d41..8f9fbcc 100644 --- a/browse/js/tree.js +++ b/browse/js/tree.js @@ -512,7 +512,14 @@ // it as a directory handle; members // become ordinary dir/file nodes async function loadChildren(node) { - if (node.loaded) return; + if (node.loaded || node.loading) return; + // In-flight guard: a folder can be (re)toggled while its first + // load is still pending — rapid Enter/ArrowRight key-repeat, or a + // double-click landing during a single-click's load. Without this, + // both calls pass the !loaded check and fire duplicate fetches that + // race in setChildren. The flag serializes per-node so the second + // caller is a no-op until the first resolves. + node.loading = true; try { if (node.isZip && state.source === 'server' && !zipNestedInsideZip(node)) { setChildren(node.id, await loader.fetchServerChildren(pathFor(node) + '/')); @@ -532,6 +539,8 @@ } catch (e) { window.app.modules.events.statusError( 'Failed to load ' + node.name + ': ' + e.message); + } finally { + node.loading = false; } }