From 635766a2fb810f778996fbea8ab560073ff0e88a Mon Sep 17 00:00:00 2001 From: jelveh Date: Sat, 18 Jul 2026 17:27:53 -0700 Subject: [PATCH] fix: contain partial Apps loads and keep the uninstall flag current MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - A partial installed-apps list (a page beyond the first failed) now renders only when the grid is empty — it must never replace a complete grid already on screen — and saveOrder refuses to persist while the list is partial, since overwriting the saved order with a truncated one would drop the missing apps' positions for good. - Opening an app from the grid now flips its tile's uninstallable flag: the open puts it in the server-side recent list, so a revoke from that moment on leaves the tile in place, and the modal must not act on the stale load-time snapshot. - The in-flight-load invalidation moved into _invalidateInFlightLoads() so the seq protocol lives in one place. --- src/gui/src/UI/Dashboard/TabApps.js | 56 +++++++++++++++++++++++------ 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index b6aa9b8b2..d04ad3a5f 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -179,13 +179,9 @@ function showUninstallModal ({ appName, appTitle, appUid, removesTile, self, $el // Keep the persisted order free of the now-uninstalled app, but // only if the user already has a custom order (don't create one). if ( self._hasCustomOrder ) self.saveOrder(); - // Mark any in-flight load stale: it fetched before the revoke, - // and applying it would pop the just-removed tile back in. - // Also stop sharing its promise, so the next activation - // fetches fresh instead of joining the doomed load. - self._loadSeq = (self._loadSeq || 0) + 1; - self._appliedSeq = self._loadSeq; - self._loadPromise = null; + // A load fetched before the revoke must not apply — it would + // pop the just-removed tile back in. + self._invalidateInFlightLoads(); self.renderApps($el_window, { preservePage: true }); } } catch ( err ) { @@ -242,6 +238,7 @@ const TabApps = { _reduceMotionMQL: undefined, _loadPromise: null, _pendingLoad: null, + _partialLoad: false, html () { let h = '
'; @@ -294,6 +291,15 @@ const TabApps = { window.open(targetLink, '_blank', 'noopener,noreferrer'); } else if ( appName ) { window.open(`/app/${appName}`, '_blank', 'noopener,noreferrer'); + // Opening records an app-open, which puts this app in the + // server-side recent list — from now on a revoke leaves the + // tile in place (see isUninstallable in _fetchAndRenderApps). + // Flip the flag now so the uninstall modal doesn't act on a + // stale load-time snapshot and remove a tile that will + // silently reappear. + const app = (self._apps || []).find(a => a.name === appName); + if ( app ) app.uninstallable = false; + $(this).attr('data-app-uninstallable', '0'); } }); @@ -924,6 +930,7 @@ const TabApps = { this._pendingLoad = null; if ( pending.loadSeq < (this._appliedSeq || 0) ) return; this._appliedSeq = pending.loadSeq; + this._partialLoad = pending.partial; const orderedNames = this._hasCustomOrder && Array.isArray(this._apps) ? serializeAppOrder(this._apps) @@ -933,8 +940,27 @@ const TabApps = { this.renderApps(pending.$el_window, { preservePage: true, instant: true }); }, + // A local mutation of _apps (uninstall) must invalidate loads fetched + // before it — applying one would resurrect the pre-mutation state. Loads + // started after this call get a newer seq and still apply. Dropping the + // shared promise lets the next activation fetch fresh instead of joining + // the doomed load. + _invalidateInFlightLoads () { + this._loadSeq = (this._loadSeq || 0) + 1; + this._appliedSeq = this._loadSeq; + this._loadPromise = null; + }, + saveOrder () { this._hasCustomOrder = true; + // Never persist an order backed by a partial app list — the saved + // order is the only record of the missing apps' positions, and + // overwriting it would drop them for good. The in-session order still + // applies; the next complete load can persist again. + if ( this._partialLoad ) { + console.warn('App order not saved: the installed-apps list is incomplete (a page failed to load).'); + return; + } const names = serializeAppOrder(this._apps); try { const p = puter.kv.set(APPS_ORDER_KV_KEY, JSON.stringify(names)); @@ -974,6 +1000,11 @@ const TabApps = { const $container = $el_window.find('.myapps-container'); + // Whether the installed-apps list came back incomplete (a page beyond + // the first failed). A partial list may render when the grid is empty, + // but must never be persisted (see saveOrder). + let installedAppsPartial = false; + try { // Fetch the two app lists and the saved order together. The // installedApps endpoint caps `limit` at 100 and paginates, so page @@ -1007,9 +1038,13 @@ const TabApps = { // A first-page failure is a failed load. A later page // failing must not discard the pages already fetched — // that would turn one flaky request among N into an - // empty grid (the single-request load never had that). - if ( page === 1 ) throw err; + // empty grid — but a partial list is only better than + // nothing: it must never REPLACE a complete grid + // already on screen, so fail the refresh in that case + // (the outer catch preserves what's showing). + if ( page === 1 || this._apps ) throw err; console.error(`Failed to fetch installedApps page ${page}; showing the ${all.length} apps fetched so far:`, err); + installedAppsPartial = true; break; } } @@ -1102,12 +1137,13 @@ const TabApps = { // away either — stash it for _endDrag to apply. if ( this._drag?.started ) { if ( ! this._pendingLoad || loadSeq > this._pendingLoad.loadSeq ) { - this._pendingLoad = { $el_window, merged, orderedNames, loadSeq }; + this._pendingLoad = { $el_window, merged, orderedNames, loadSeq, partial: installedAppsPartial }; } return; } this._pendingLoad = null; this._appliedSeq = loadSeq; + this._partialLoad = installedAppsPartial; this._hasCustomOrder = Array.isArray(orderedNames) && orderedNames.length > 0;