From 79d14ec6479dee98373fdf444b95e8f7be51afeb Mon Sep 17 00:00:00 2001 From: jelveh Date: Sat, 18 Jul 2026 17:51:03 -0700 Subject: [PATCH] fix: replace partial-load and uninstall-flag patches with convergent designs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous round's containment patches each spawned new corner cases (a persistently failing page froze all refreshes; the partial-load saveOrder bail silently discarded reorders; the tile-open flag flip lost races with in-flight loads, server recording, and popup blockers). Replace both mechanisms: - A partial installed-apps fetch (later page failed) now always applies, supplemented with the apps already known from the previous list — the grid and search can't shrink from one flaky request, refreshes never freeze, and an empty grid still gets the fetched pages. - saveOrder carries over saved names missing from the current list instead of refusing to persist, so no app's saved position is ever dropped; stale names are harmless (reconcileAppOrder ignores them) and preserve the position of apps that return. - The load-time uninstallable snapshot is no longer patched client-side on tile open. Uninstall now revokes, optimistically removes the tile when expected to stick, and refetches — the grid converges to server-side truth instead of guessing at recents state. --- src/gui/src/UI/Dashboard/TabApps.js | 99 +++++++++++++++-------------- 1 file changed, 52 insertions(+), 47 deletions(-) diff --git a/src/gui/src/UI/Dashboard/TabApps.js b/src/gui/src/UI/Dashboard/TabApps.js index d04ad3a5f..f35b07e27 100644 --- a/src/gui/src/UI/Dashboard/TabApps.js +++ b/src/gui/src/UI/Dashboard/TabApps.js @@ -171,19 +171,21 @@ function showUninstallModal ({ appName, appTitle, appUid, removesTile, self, $el try { await puter.perms.revokeApp(appUid, '*'); - // Only drop the tile when the uninstall sticks — a recommended/ - // recent app comes back on the next get-launch-apps load, so - // removing it here would just look like a broken flicker. + // A load fetched before the revoke must not apply — it would + // resurrect the pre-revoke grid. + self._invalidateInFlightLoads(); + // Drop the tile right away when we expect the uninstall to stick + // (immediate feedback) — but whether it really disappears depends + // on server-side state (recommended/recent lists) that load-time + // flags can only guess at, so refetch either way and let the grid + // converge to the truth. The saved order intentionally keeps the + // app's name: reconcileAppOrder ignores it while the app is gone + // and restores its position if it comes back. if ( removesTile ) { self._apps = self._apps.filter(a => a.name !== appName); - // 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(); - // 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 }); } + self.loadApps($el_window); } catch ( err ) { console.error('Failed to uninstall app:', err); } @@ -238,7 +240,7 @@ const TabApps = { _reduceMotionMQL: undefined, _loadPromise: null, _pendingLoad: null, - _partialLoad: false, + _savedOrderNames: null, html () { let h = '
'; @@ -291,15 +293,6 @@ 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'); } }); @@ -930,7 +923,7 @@ const TabApps = { this._pendingLoad = null; if ( pending.loadSeq < (this._appliedSeq || 0) ) return; this._appliedSeq = pending.loadSeq; - this._partialLoad = pending.partial; + this._savedOrderNames = pending.orderedNames; const orderedNames = this._hasCustomOrder && Array.isArray(this._apps) ? serializeAppOrder(this._apps) @@ -953,15 +946,19 @@ const TabApps = { 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); + // Carry over saved names that aren't in the current list (e.g. apps + // whose installedApps page failed to load this session): the saved + // order is the only record of their positions, so dropping them would + // lose those for good, while stale names are harmless — + // reconcileAppOrder ignores them. + if ( Array.isArray(this._savedOrderNames) ) { + const have = new Set(names); + for ( const name of this._savedOrderNames ) { + if ( ! have.has(name) ) names.push(name); + } + } + this._savedOrderNames = names; try { const p = puter.kv.set(APPS_ORDER_KV_KEY, JSON.stringify(names)); if ( p && typeof p.catch === 'function' ) { @@ -1000,11 +997,6 @@ 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 @@ -1036,22 +1028,20 @@ const TabApps = { if ( batch.length < PAGE_SIZE ) break; } catch ( err ) { // 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 — 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; + // failing must not fail the refresh — that would turn + // one flaky request among N into an empty (or frozen) + // grid. Return what we have and flag it incomplete; + // the merge below fills the gap from the previous + // list so the grid and saved order can't shrink. + if ( page === 1 ) throw err; + console.error(`Failed to fetch installedApps page ${page}; got ${all.length} apps before the failure:`, err); + return { apps: all, complete: false }; } } - return all; + return { apps: all, complete: true }; }; - const [installedApps, launchRes, savedOrderRaw] = await Promise.all([ + const [installedResult, launchRes, savedOrderRaw] = await Promise.all([ fetchAllInstalledApps(), fetch( `${window.api_origin}/get-launch-apps?icon_size=128`, @@ -1063,6 +1053,7 @@ const TabApps = { puter.kv.get(APPS_ORDER_KV_KEY).catch(() => null), ]); + const installedApps = installedResult.apps; const launchData = await launchRes.json(); // Normalize launch apps (recommended + recent) to same shape @@ -1118,6 +1109,20 @@ const TabApps = { merged.push({ ...app, uninstallable: isUninstallable(app.name) }); } + // A page beyond the first failed: fill the gap with apps we + // already know about (keeping their previous uninstallable flags) + // so one flaky request among N can't make apps vanish from the + // grid, from search, or from a subsequently saved order. Apps + // uninstalled remotely may linger until the next complete + // refresh — the same staleness any between-refresh window has. + if ( ! installedResult.complete && Array.isArray(this._apps) ) { + for ( const app of this._apps ) { + if ( seen.has(app.name) ) continue; + seen.add(app.name); + merged.push({ ...app }); + } + } + // Overlay the user's saved ordering (if any). New apps are appended // in their default order; stale names are ignored. let orderedNames = null; @@ -1137,13 +1142,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, partial: installedAppsPartial }; + this._pendingLoad = { $el_window, merged, orderedNames, loadSeq }; } return; } this._pendingLoad = null; this._appliedSeq = loadSeq; - this._partialLoad = installedAppsPartial; + this._savedOrderNames = orderedNames; this._hasCustomOrder = Array.isArray(orderedNames) && orderedNames.length > 0;