From eef34ce4c83a2485933e5c3d413d2d854f2144b7 Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Fri, 23 Oct 2026 14:23:20 -0700 Subject: [PATCH] perf(dashboard): load usage and storage once when the dashboard opens Home and Usage both load the monthly usage and the storage space when the dashboard opens, whichever tab it opens on, so every open sent /metering/usage and /df twice. Overlapping loads now share one request. Nothing is cached: a load after the shared one settles fetches again. --- src/gui/src/UI/Dashboard/TabHome.js | 5 ++- src/gui/src/UI/Dashboard/TabUsage.js | 5 ++- src/gui/src/UI/Dashboard/usageRequests.js | 25 +++++++++++ src/gui/src/helpers/shareInFlight.js | 37 ++++++++++++++++ src/gui/src/helpers/shareInFlight.test.js | 53 +++++++++++++++++++++++ 5 files changed, 121 insertions(+), 4 deletions(-) create mode 100644 src/gui/src/UI/Dashboard/usageRequests.js create mode 100644 src/gui/src/helpers/shareInFlight.js create mode 100644 src/gui/src/helpers/shareInFlight.test.js diff --git a/src/gui/src/UI/Dashboard/TabHome.js b/src/gui/src/UI/Dashboard/TabHome.js index a69a7ea97..2cc450142 100644 --- a/src/gui/src/UI/Dashboard/TabHome.js +++ b/src/gui/src/UI/Dashboard/TabHome.js @@ -20,6 +20,7 @@ import UIWindowSaveAccount from '../UIWindowSaveAccount.js'; import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js'; import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js'; +import { getMonthlyUsage, getStorageSpace } from './usageRequests.js'; import { appIconAttrs } from '../../helpers/appIcon.js'; import { filterLaunchableApps } from '../../helpers/launchAppsList.js'; import { isOrgSeat, orgSeatTeamName } from './orgSeat.js'; @@ -613,7 +614,7 @@ const TabHome = { // Load storage data try { - const res = await puter.fs.space(); + const res = await getStorageSpace(); // Guard capacity 0 — 0/0 would render literally as "NaN%". let usage_percentage = res.capacity ? ((res.used / res.capacity) * 100).toFixed(0) @@ -644,7 +645,7 @@ const TabHome = { // Load monthly usage data try { - const res = monthlyUsageOrThrow(await puter.auth.getMonthlyUsage()); + const res = monthlyUsageOrThrow(await getMonthlyUsage()); const budget = usageBudget(res.usage, res.allowanceInfo); const addonLeft = addonCreditsRemaining(res.allowanceInfo); // The server reports credits (already scaled) or raw amounts diff --git a/src/gui/src/UI/Dashboard/TabUsage.js b/src/gui/src/UI/Dashboard/TabUsage.js index 43872f5ba..1dc77d361 100644 --- a/src/gui/src/UI/Dashboard/TabUsage.js +++ b/src/gui/src/UI/Dashboard/TabUsage.js @@ -20,6 +20,7 @@ import { formatCredits, formatDollarsFromMicrocents, usageIsCredits } from './credits.js'; import { isOrgSeat } from './orgSeat.js'; import { addonCreditsRemaining, monthlyUsageOrThrow, usageBudget } from './usageBudget.js'; +import { getMonthlyUsage, getStorageSpace } from './usageRequests.js'; // Whether the server reported credits — decides whether the usage surfaces // render credits or fall back to dollars. @@ -302,7 +303,7 @@ function renderUsageTable () { } async function update_usage_details ($el_window) { - const monthlyUsagePromise = puter.auth.getMonthlyUsage().then(monthlyUsageOrThrow).then(res => { + const monthlyUsagePromise = getMonthlyUsage().then(monthlyUsageOrThrow).then(res => { const budget = usageBudget(res.usage, res.allowanceInfo); const addonLeft = addonCreditsRemaining(res.allowanceInfo); // The server reports credits (already scaled) or raw amounts (no @@ -372,7 +373,7 @@ async function update_usage_details ($el_window) { } }); - const spacePromise = puter.fs.space().then(res => { + const spacePromise = getStorageSpace().then(res => { // Guard capacity 0 — otherwise 0/0 renders literally as "NaN%". let usage_percentage = res.capacity ? (res.used / res.capacity * 100).toFixed(0) : '0'; usage_percentage = usage_percentage > 100 ? 100 : usage_percentage; diff --git a/src/gui/src/UI/Dashboard/usageRequests.js b/src/gui/src/UI/Dashboard/usageRequests.js new file mode 100644 index 000000000..645870ffb --- /dev/null +++ b/src/gui/src/UI/Dashboard/usageRequests.js @@ -0,0 +1,25 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import shareInFlight from '../../helpers/shareInFlight.js'; + +// Home and Usage both show these and both load them when the dashboard +// opens; a load overlapping the other's shares its request. +export const getMonthlyUsage = shareInFlight(() => puter.auth.getMonthlyUsage()); +export const getStorageSpace = shareInFlight(() => puter.fs.space()); diff --git a/src/gui/src/helpers/shareInFlight.js b/src/gui/src/helpers/shareInFlight.js new file mode 100644 index 000000000..5825a6433 --- /dev/null +++ b/src/gui/src/helpers/shareInFlight.js @@ -0,0 +1,37 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +/** + * Wraps an async call so overlapping callers share one call: until the + * first settles, every caller gets its promise. The next call after it + * settles starts afresh, so nothing is cached. + * + * @template T + * @param {() => Promise} fn + * @returns {() => Promise} + */ +export default function shareInFlight (fn) { + let pending = null; + return () => { + pending ??= Promise.resolve().then(fn).finally(() => { + pending = null; + }); + return pending; + }; +} diff --git a/src/gui/src/helpers/shareInFlight.test.js b/src/gui/src/helpers/shareInFlight.test.js new file mode 100644 index 000000000..f51b24c19 --- /dev/null +++ b/src/gui/src/helpers/shareInFlight.test.js @@ -0,0 +1,53 @@ +/* + * Copyright (C) 2024-present Puter Technologies Inc. + * + * This file is part of Puter. + * + * Puter is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published + * by the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +import { describe, expect, it, vi } from 'vitest'; +import shareInFlight from './shareInFlight.js'; + +describe('shareInFlight', () => { + it('gives overlapping callers the same call', async () => { + const fn = vi.fn(async () => 'answer'); + const shared = shareInFlight(fn); + const [a, b] = await Promise.all([shared(), shared()]); + expect(fn).toHaveBeenCalledTimes(1); + expect(a).toBe('answer'); + expect(b).toBe('answer'); + }); + + it('calls again once the previous call has settled', async () => { + const fn = vi.fn(async () => 'answer'); + const shared = shareInFlight(fn); + await shared(); + await shared(); + expect(fn).toHaveBeenCalledTimes(2); + }); + + it('shares a rejection, then lets the next call retry', async () => { + const fn = vi.fn() + .mockRejectedValueOnce(new Error('boom')) + .mockResolvedValueOnce('answer'); + const shared = shareInFlight(fn); + const first = shared(); + const second = shared(); + await expect(first).rejects.toThrow('boom'); + await expect(second).rejects.toThrow('boom'); + await expect(shared()).resolves.toBe('answer'); + expect(fn).toHaveBeenCalledTimes(2); + }); +});