diff --git a/web/src/components/core/ProgressBackdrop.test.tsx b/web/src/components/core/ProgressBackdrop.test.tsx
index 0f2db8de4f..c15ca8e10f 100644
--- a/web/src/components/core/ProgressBackdrop.test.tsx
+++ b/web/src/components/core/ProgressBackdrop.test.tsx
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -85,9 +85,7 @@ describe("ProgressBackdrop", () => {
});
});
- // Test skipped because rerender fails when using installerRender,
- // caused by how InstallerProvider manages context.
- it.skip("shows 'Refreshing data...' message temporarily", async () => {
+ it("shows 'Refreshing data...' message temporarily", async () => {
// Start with active progress
mockProgresses([
{
@@ -117,9 +115,7 @@ describe("ProgressBackdrop", () => {
expect(mockStartTracking).toHaveBeenCalled();
});
- // Test skipped because rerender fails when using installerRender,
- // caused by how InstallerProvider manages context.
- it.skip("hides backdrop after queries are refetched", async () => {
+ it("hides backdrop after queries are refetched", async () => {
// Start with active progress
mockProgresses([
{
@@ -265,9 +261,7 @@ describe("ProgressBackdrop", () => {
);
});
- // Test skipped because rerender fails when using installerRender,
- // caused by how InstallerProvider manages context.
- it.skip("starts tracking when progress finishes", async () => {
+ it("starts tracking when progress finishes", async () => {
// Start with active progress
mockProgresses([
{
diff --git a/web/src/components/overview/OverviewPage.tsx b/web/src/components/overview/OverviewPage.tsx
index 9ec97dc516..e0b6f3f37a 100644
--- a/web/src/components/overview/OverviewPage.tsx
+++ b/web/src/components/overview/OverviewPage.tsx
@@ -20,7 +20,7 @@
* find current contact information at www.suse.com.
*/
-import React, { useState } from "react";
+import React, { useDeferredValue, useState } from "react";
import { Navigate } from "react-router";
import {
Button,
@@ -51,7 +51,7 @@ import { _ } from "~/i18n";
import type { Product } from "~/types/software";
import textStyles from "@patternfly/react-styles/css/utilities/Text/text";
-import { useProgress } from "~/hooks/use-progress-tracking";
+import { useProgressTracking } from "~/hooks/use-progress-tracking";
type ConfirmationPopupProps = {
product: Product;
@@ -93,19 +93,15 @@ const ConfirmationPopup = ({
);
};
-export default function OverviewPage() {
- const product = useProductInfo();
+const OverviewPageContent = ({ product }) => {
const issues = useIssues();
- const progresses = useProgress();
+ const { loading } = useProgressTracking();
+ const isReady = useDeferredValue(!loading);
const { actions } = useDestructiveActions();
const [showConfirmation, setShowConfirmation] = useState(false);
const hasIssues = !isEmpty(issues);
const hasDestructiveActions = actions.length > 0;
- if (!product) {
- return ;
- }
-
const [buttonLocationStart, buttonLocationLabel, buttonLocationEnd] = _(
// TRANSLATORS: This hint helps users locate the install button. Text inside
// square brackets [] appears in bold. Keep brackets for proper formatting.
@@ -123,10 +119,8 @@ export default function OverviewPage() {
const onCancel = () => setShowConfirmation(false);
- const isProposalReady = isEmpty(progresses);
-
const getInstallButtonText = () => {
- if (hasIssues || !isProposalReady) return _("Install");
+ if (hasIssues || !isReady) return _("Install");
if (hasDestructiveActions) return _("Install now with potential data loss");
return _("Install now");
};
@@ -169,20 +163,20 @@ export default function OverviewPage() {
size="lg"
variant={hasDestructiveActions ? "danger" : "primary"}
onClick={onInstallClick}
- isDisabled={hasIssues || !isProposalReady}
+ isDisabled={hasIssues || !isReady}
>
{getInstallButtonText()}
- {!isProposalReady && (
+ {!isReady && (
-
+
{_("Wait until current operations are completed.")}
)}
- {hasIssues && isProposalReady && (
+ {hasIssues && isReady && (
{_(
@@ -204,4 +198,14 @@ export default function OverviewPage() {
)}
);
+};
+
+export default function OverviewPage() {
+ const product = useProductInfo();
+
+ if (!product) {
+ return ;
+ }
+
+ return ;
}
diff --git a/web/src/hooks/model/progress.test.ts b/web/src/hooks/model/progress.test.ts
new file mode 100644
index 0000000000..e15823813a
--- /dev/null
+++ b/web/src/hooks/model/progress.test.ts
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import { renderHook } from "@testing-library/react";
+import { mockProgresses } from "~/test-utils";
+import type { Progress } from "~/model/status";
+import { useProgress } from "./progress";
+
+const fakeSoftwareProgress: Progress = {
+ scope: "software",
+ size: 3,
+ steps: [
+ "Updating the list of repositories",
+ "Refreshing metadata from the repositories",
+ "Calculating the software proposal",
+ ],
+ step: "Updating the list of repositories",
+ index: 1,
+};
+
+const fakeStorageProgress: Progress = {
+ scope: "storage",
+ size: 3,
+ steps: [],
+ step: "Activating storage devices",
+ index: 1,
+};
+
+describe("useProgress", () => {
+ beforeEach(() => {
+ mockProgresses([fakeStorageProgress, fakeSoftwareProgress]);
+ });
+
+ afterEach(() => {
+ jest.clearAllMocks();
+ });
+
+ it("returns all progresses when no scope is provided", () => {
+ const { result } = renderHook(() => useProgress());
+
+ expect(result.current).toEqual([fakeStorageProgress, fakeSoftwareProgress]);
+ });
+
+ it("returns the progress matching the given scope", () => {
+ const { result } = renderHook(() => useProgress("software"));
+
+ expect(result.current).toBe(fakeSoftwareProgress);
+ });
+
+ it("returns undefined when the given scope has no progress", () => {
+ const { result } = renderHook(() => useProgress("network"));
+
+ expect(result.current).toBeUndefined();
+ });
+});
diff --git a/web/src/hooks/model/progress.ts b/web/src/hooks/model/progress.ts
new file mode 100644
index 0000000000..9b93b7b377
--- /dev/null
+++ b/web/src/hooks/model/progress.ts
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) [2026] SUSE LLC
+ *
+ * All Rights Reserved.
+ *
+ * This program is free software; you can redistribute it and/or modify it
+ * under the terms of the GNU General Public License as published by the Free
+ * Software Foundation; either version 2 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 General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program; if not, contact SUSE LLC.
+ *
+ * To contact SUSE LLC about this file by physical or electronic mail, you may
+ * find current contact information at www.suse.com.
+ */
+
+import { isUndefined } from "radashi";
+import { useStatus } from "./status";
+
+import type { Progress, Scope } from "~/model/status";
+
+/**
+ * Convenience hook that returns the currently active progress(es).
+ *
+ * - When no `scope` is provided, all active progress objects are returned.
+ * - When a `scope` is provided, only the progress associated with that scope
+ * is returned (or `undefined` if none exists).
+ *
+ * This hook is a lightweight wrapper around `useStatus`/`useState`. It is
+ * intentionally defined in a separate file/module to enable proper testing:
+ * consumers can mock progress data directly without depending on or
+ * re-implementing the internal logic, which would be fragile and error-prone.
+ *
+ * An example of this kind of indirect testing can be found in the
+ * `useProgressTracking` unit tests, which would not be possible if
+ * `useStatus` and `useProgress` lived in the same file/module.
+ */
+export function useProgress(scope?: undefined): Progress[];
+export function useProgress(scope: Scope): Progress | undefined;
+export function useProgress(scope?: Scope): Progress[] | Progress | undefined {
+ const { progresses } = useStatus();
+
+ if (isUndefined(scope)) {
+ return progresses;
+ }
+
+ return progresses.find((p) => p.scope === scope);
+}
diff --git a/web/src/hooks/use-progress-tracking.test.ts b/web/src/hooks/use-progress-tracking.test.ts
index 98632343b5..d01a07bba3 100644
--- a/web/src/hooks/use-progress-tracking.test.ts
+++ b/web/src/hooks/use-progress-tracking.test.ts
@@ -1,5 +1,5 @@
/*
- * Copyright (c) [2025] SUSE LLC
+ * Copyright (c) [2025-2026] SUSE LLC
*
* All Rights Reserved.
*
@@ -20,32 +20,34 @@
* find current contact information at www.suse.com.
*/
+import { act } from "react";
import { renderHook, waitFor } from "@testing-library/react";
-import { useProgressTracking } from "./use-progress-tracking";
-import { useStatus } from "~/hooks/model/status";
+import { mockProgresses } from "~/test-utils";
import useTrackQueriesRefetch from "~/hooks/use-track-queries-refetch";
import { COMMON_PROPOSAL_KEYS } from "~/hooks/model/proposal";
import type { Progress } from "~/model/status";
-import { act } from "react";
-
-const mockProgressesFn: jest.Mock