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);
+ });
+});